ownerpro/asaas-php-sdk

Clean PHP SDK for the Asaas payment platform API

Maintainers

Package info

github.com/OwnerPro-Software/asaas-php-sdk

pkg:composer/ownerpro/asaas-php-sdk

Transparency log

Statistics

Installs: 35

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v3.0.0 2026-07-23 06:36 UTC

README

Clean PHP SDK for the Asaas payment platform API with typed request DTOs and result-based error handling.

Requirements

  • PHP 8.3+
  • illuminate/http ^12.0|^13.0

Works with Laravel 12 or 13 (auto-discovers ServiceProvider and Facade), and also works in any PHP project without Laravel.

Installation

composer require ownerpro/asaas-php-sdk

The package auto-discovers its ServiceProvider and Facade.

Configuration

Publish the config file:

php artisan vendor:publish --tag=asaas-config

Set the following environment variables:

ASAAS_API_KEY=your-api-key
ASAAS_ENVIRONMENT=sandbox       # or "production"
ASAAS_TIMEOUT=30                # request timeout in seconds (default: 30)
ASAAS_CONNECT_TIMEOUT=10        # TCP connect timeout in seconds (default: 10)

ASAAS_API_KEY is required. The AsaasServiceProvider throws RuntimeException the first time AsaasClient is resolved from the container if the key is missing or empty — keep this in mind when bootstrapping in CI or test environments where the env var may not be set.

Usage

Via Facade

use OwnerPro\Asaas\Asaas;

$result = Asaas::payments()->create([
    'customer' => 'cus_abc123',
    'billingType' => 'PIX',
    'value' => 150.00,
    'dueDate' => '2026-04-01',
]);

Via Dependency Injection

use OwnerPro\Asaas\AsaasClient;

public function __construct(private AsaasClient $asaas) {}

public function charge(): void
{
    $result = $this->asaas->payments()->create([
        'customer' => 'cus_abc123',
        'billingType' => 'PIX',
        'value' => 150.00,
        'dueDate' => '2026-04-01',
    ]);
}

Standalone (without Laravel)

No Laravel framework needed — only illuminate/http as a Composer dependency:

use OwnerPro\Asaas\AsaasClient;
use OwnerPro\Asaas\Support\Environment;

$client = AsaasClient::for(apiKey: 'your-api-key');
$result = $client->payments()->find('pay_abc123');

// Override defaults
$client = AsaasClient::for(
    apiKey: 'your-api-key',
    environment: Environment::Production,
    timeout: 60,           // request timeout in seconds (default: 30)
    connectTimeout: 5,     // TCP connect timeout in seconds (default: 10)
);

Multi-Tenant

This SDK supports two multi-tenant patterns:

  1. Existing Asaas account (standalone) — the tenant already has an Asaas account and provides their own apiKey. The integrator has no administrative visibility; the tenant operates independently.
  2. White-label subaccount — the integrator creates a subaccount on behalf of the tenant via accounts()->create() and drives the full onboarding (KYC, commercial info, document upload, bank account) without redirecting the tenant to the Asaas panel. The subaccount keeps its own balance and KYC status; the integrator only retains administrative visibility (listing, status checks). See My Account (myAccount()) for the onboarding endpoints.

In both patterns, instantiate one client per tenant using their apiKey:

// In Laravel — inherits environment/timeout from config
use OwnerPro\Asaas\Asaas;
use OwnerPro\Asaas\Support\Environment;

foreach ($tenants as $tenant) {
    $client = Asaas::for(apiKey: $tenant->asaas_api_key);
    $result = $client->payments()->list();
}

// Override per-tenant (any param omitted falls back to config/asaas.php)
$client = Asaas::for(
    apiKey: $tenant->asaas_api_key,
    environment: Environment::Production,
    timeout: 60,
    connectTimeout: 5,
);

// Standalone (without Laravel)
use OwnerPro\Asaas\AsaasClient;

foreach ($tenants as $tenant) {
    $client = AsaasClient::for(apiKey: $tenant->asaas_api_key);
    $result = $client->payments()->list();
}

AsaasClient::for() works in both worlds: outside Laravel it builds its own HTTP client, and inside a booted Laravel app it routes through the framework's HTTP factory — so per-tenant clients still honour Http::fake() and Http::preventStrayRequests() in your test suite.

Result Handling

All resource methods return AsaasResult or AsaasPaginatedResult.

$result = Asaas::payments()->create([...]);

if ($result->success) {
    $payment = $result->data;       // array<string, mixed>
    echo $payment['id'];
    echo $payment['status'];
} else {
    $errors = $result->errors;      // array of error details
}

204 No Content endpoints. accounts()->deleteAccessToken() and webhooks()->removeBackoff() return HTTP 204 with an empty body. $result->success is true but $result->data is [] (empty array). Check $result->success — never $result->data — to decide if the call worked.

Throwing on Failure

// Throws AsaasRequestException on failure, returns self on success
$result = Asaas::payments()->find('pay_abc123')->orFail();
$payment = $result->data;

Error Handling

use OwnerPro\Asaas\Support\AsaasRequestException;

try {
    Asaas::payments()->find('pay_invalid')->orFail();
} catch (AsaasRequestException $e) {
    $e->getMessage();    // First error description
    $e->statusCode;      // HTTP status code — always a 4xx here
    $e->errors;          // Full error array from API
    $e->response;        // ?RawResponse
}

Transport Failures (typed exceptions)

An AsaasResult is only ever an answer: a 2xx the SDK could read, or a 4xx verdict from Asaas about the operation. Anything else — a timeout, a DNS error, a connection refused, an unreadable body, a 5xx, a 408, a 429 — throws, because those cannot tell you the one thing that matters on money-moving endpoints: whether the API processed the request or not. A read timeout on transfers()->create() may mean the transfer was created; folding that into a failure result invites a blind retry that moves money twice.

There is no flag and no opt-out. The typed exceptions are the contract:

use OwnerPro\Asaas\Support\IndeterminateResultException;
use OwnerPro\Asaas\Support\RateLimitedException;
use OwnerPro\Asaas\Support\RequestNotDeliveredException;

try {
    $result = $client->transfers()->create($transferRequest);
} catch (RequestNotDeliveredException $e) {
    // The request provably never reached the API (DNS, TCP connect, TLS).
    // Nothing was processed — a direct retry is safe.
    $e->phase;          // 'connect'|'dns'|'tls'
    $e->getPrevious();  // original Illuminate\Http\Client\ConnectionException
} catch (IndeterminateResultException $e) {
    // The API MAY have processed the request: read timeout, connection lost
    // mid-transfer, a 2xx with an unreadable body, or a 5xx. NEVER retry
    // blindly — reconcile first (e.g. via the Asaas withdrawal-validation
    // webhook).
    $e->phase;          // 'body'|'read'|'redirect'|'server'|'timeout'|'transfer'|null
    $e->response;       // ?RawResponse — the received response for 'body' (2xx), 'server' (5xx) and 'timeout' (408), null otherwise
} catch (RateLimitedException $e) {
    // Asaas refused the request BEFORE processing it (429). Nothing moved, so
    // leave your own state untouched and back off.
    $e->retryAfter;     // ?int — seconds, when Asaas states a delay
    $e->response;       // RawResponse — always present
}

RequestNotDeliveredException and IndeterminateResultException extend TransportException (itself an AsaasException), so a single catch (TransportException $e) works when the distinction doesn't matter. RateLimitedException is deliberately outside that hierarchy: the request travelled and was answered, it just was not processed. Since the SDK never returns a result for an unanswered request, a method that returns at all returned an answer — you can read $result->success without wondering which kind of failure it was.

Both keep the original ConnectionException in getPrevious(), which is where the cURL errno behind the verdict is readable. That chain reaches the failed request and the access_token header on it, so these exceptions redact themselves like every other secret-holding object — dump(), dd(), var_dump(), print_r(), json_encode() and the framework error page show the message, phase, response, file and line instead of the property list. See Sensitive data in debug output for the two paths that still read private state directly.

Classification is deliberately conservative: RequestNotDeliveredException is thrown only on unequivocal cURL evidence that no bytes reached the API (errno 6/7/35/58/60). A timeout (cURL 28) is always indeterminate — curl reports zeroed connection timers on reused keep-alive connections, so connect_time cannot prove a connect-phase timeout. Anything ambiguous classifies as indeterminate, because the costs are asymmetric — a mislabelled "not delivered" invites a duplicate transfer, while a mislabelled "indeterminate" costs one reconciliation lookup.

Two received-response cases also throw, for the same reason:

  • A 2xx response whose body is not a JSON object or array (invalid JSON, empty body, or a bare JSON scalar) throws IndeterminateResultException with phase: 'body'. Exception: 204 No Content endpoints (deleteAccessToken, removeBackoff) return a success with empty data — an intentionally empty body is a definitive answer.
  • A 2xx list response whose pagination envelope cannot be read throws the same way: data that is not a list of objects, or a totalCount/limit that is not an integer, or a hasMore that is not a boolean. Nothing is coerced — totalCount bounds the walk and hasMore continues it, so reading "3" as 3 would end a walk early or run one forever while still looking like a complete answer.
  • A 5xx response throws IndeterminateResultException with phase: 'server' and the response attached. A 5xx is not Asaas answering about the operation — it is the server, or a proxy in front of it, reporting that it could not answer, so the operation may well have been processed. This holds for the whole range and regardless of the body: a 5xx carrying a canonical {"errors":[...]} envelope still throws.
  • A 3xx response throws IndeterminateResultException with phase: 'redirect' and the response attached. The SDK does not follow redirects, so the 3xx arrives rather than being chased. Asaas issues none, which is the point: something in front of the API answered in its place, and whether the operation was processed first is unknowable. Following one would be worse — Guzzle strips only Authorization and Cookie across origins, so the access_token header would travel to whatever host the Location names; its default protocol list allows an httpshttp downgrade; and its non-strict default replays a POST as a GET, whose 200 would be relayed as the POST's verdict.

Two 4xx statuses are not verdicts either, and neither returns a result:

  • A 408 throws IndeterminateResultException with phase: 'timeout' and the response attached. The server is reporting that it stopped waiting for the request — it may have processed what it already had.
  • A 429 throws RateLimitedException, carrying retryAfter (seconds, when Asaas states a delay in that form) and the response. A rate limit is a refusal taken before processing: nothing moved, so the operation's own state must stay untouched and the call is safe to repeat after backing off. Retry-After is only read in its delay-in-seconds form; when Asaas sends an HTTP-date, retryAfter is null and the raw value stays available through $e->response->header('Retry-After').

Every other 4xx returns an AsaasResult failure, as always: it is Asaas rejecting the operation — a definitive answer. Consequently an AsaasRequestException only ever carries a genuine 4xx verdict.

Resource ID Validation

Every method that takes a resource ID (find, update, delete, etc.) validates the input before making the HTTP call. IDs must be 1–255 chars and match [a-zA-Z0-9_-]+. Empty, oversized, or otherwise malformed IDs throw InvalidArgumentException synchronously — they never reach the API.

use InvalidArgumentException;

try {
    Asaas::payments()->find('');           // empty
    Asaas::payments()->find('pay/abc');    // illegal char
} catch (InvalidArgumentException $e) {
    // e.g. "Resource ID must be 1..255 chars; got 0."
}

This guards against accidental URL-segment injection from unsanitized input. Validate or sanitize user-supplied IDs upstream if you need to surface a friendlier error.

Raw Response Access

Every AsaasResult and AsaasPaginatedResult carries the underlying HTTP response for debugging, rate limit tracking, and Asaas support tickets:

$result = Asaas::payments()->find('pay_abc123');

$result->response->status();                   // HTTP status code
$result->response->headers();                  // All response headers
$result->response->header('X-Request-Id');     // Single header (null if absent)
$result->response->body();                     // Raw response body

A result always carries its response: a request that got no readable answer never returns — it throws a TransportException subclass instead (see Transport Failures). There is no null-check to write here.

The RawResponse wrapper keeps your code decoupled from the underlying HTTP client.

Enums

The SDK provides backed string enums for all domain values. Request DTOs accept both enum instances and plain strings (backward compatible). Responses return raw strings — use EnumType::from() when you need a typed enum.

use OwnerPro\Asaas\Payment\BillingType;
use OwnerPro\Asaas\Payment\PaymentStatus;

// Using enums in requests (IDE autocompletion + typo prevention)
$result = Asaas::payments()->create(new CreatePaymentRequest(
    customer: 'cus_abc123',
    billingType: BillingType::Pix,
    value: 150.00,
    dueDate: '2026-04-01',
));

// Plain strings still work
$result = Asaas::payments()->create([
    'customer' => 'cus_abc123',
    'billingType' => 'PIX',
    'value' => 150.00,
    'dueDate' => '2026-04-01',
]);

// Responses return strings — hydrate to enums when needed
$payment = $result->data;
$payment['status'];                          // 'PENDING'
PaymentStatus::from($payment['status']);     // PaymentStatus::Pending

Available Enums

Enum Values
Payment\BillingType Undefined, Boleto, CreditCard, DebitCard, Transfer, Deposit, Pix, MundipaggCielo, VoucherCard, AsaasMoney
Payment\DiscountType Fixed, Percentage
Payment\FineType Fixed, Percentage
Payment\PaymentDocumentType Invoice, Contract, Media, Document, Spreadsheet, Program, Other
Payment\PaymentStatus Pending, Received, Confirmed, Overdue, Refunded, ReceivedInCash, RefundRequested, RefundInProgress, ChargebackRequested, ChargebackDispute, AwaitingChargebackReversal, DunningRequested, DunningReceived, AwaitingRiskAnalysis
Pix\PixAddressKeyType Cpf, Cnpj, Email, Phone, Evp
Pix\PixAddressKeyStatus AwaitingActivation, Active, AwaitingDeletion, AwaitingAccountDeletion, Deleted, Error
Pix\QrCodeFormat All, Image, Payload
PixTransaction\PixTransactionType Debit, Credit, CreditRefund, DebitRefund, DebitRefundCancellation
PixTransaction\PixTransactionStatus AwaitingBalanceValidation, AwaitingInstantPaymentAccountBalance, AwaitingCriticalActionAuthorization, AwaitingCheckoutRiskAnalysisRequest, AwaitingCashInRiskAnalysisRequest, Scheduled, AwaitingRequest, Requested, Done, Refused, Cancelled
PixTransaction\PixQrCodeType Static, Dynamic, DynamicWithAsaasAddressKey, Composite
PixTransaction\PixRecurringStatus AwaitingCriticalActionAuthorization, Pending, Scheduled, Cancelled, Done
PixTransaction\PixRecurringItemStatus Pending, Cancelled, Refused, Done
PixAutomatic\PixAutomaticFrequency Weekly, Monthly, Quarterly, Semiannually, Annually
PixAutomatic\PixAutomaticAuthorizationStatus Created, Active, Cancelled, Refused, Expired
PixAutomatic\PixAutomaticPaymentInstructionStatus AwaitingRequest, Scheduled, Done, Cancelled, Refused
Transfer\TransferOperationType Pix, Ted, Internal
Transfer\TransferRecurrenceFrequency Weekly, Monthly
Transfer\TransferStatus Pending, BankProcessing, Done, Cancelled, Failed
Invoice\InvoiceStatus Scheduled, Authorized, ProcessingCancellation, Canceled, CancellationDenied, Error
BillPayment\BillPaymentStatus Pending, BankProcessing, Paid, Failed, Cancelled, Refunded, AwaitingCheckoutRiskAnalysisRequest
Account\CompanyType Mei, Limited, Individual, Association
Account\PersonType Fisica, Juridica
Account\DocumentType 12 KYC types — AllowBankAccountDepositStatement, Custom, EmancipationOfMinors, EntrepreneurRequirement, IdentificationSelfie, Identification, Invoice, MeiCertificate, MinutesOfConstitution, MinutesOfElection, PowerOfAttorney, SocialContract
Account\AccessTokenPermission 33 cases — Payment, Transfer, Webhook, Invoice, Bill, PixDebit, PixCredit, PixAddressKey, PixRecurring, PixTransaction, PixAutomatic, Customer, CustomerNotification, PaymentRefund, Chargeback, Installment, Subscription, PaymentLink, Checkout, CreditCard, Anticipation, AnticipationConfig, Escrow, EscrowConfig, CreditBureau, PaymentDunning, FiscalInfo, MobilePhoneRecharge, FinancialTransaction, AccountInfo, PaymentCheckoutConfig, SubAccount, AccountDocument
Account\AccessTokenScope Read, ReadWrite
CreditCard\CreditCardBrand Visa, Mastercard, Elo, Diners, Discover, Amex, Cabal, Banescard, Credz, Sorocred, Credsystem, Jcb, Unknown
Webhook\WebhookSendType Sequentially, NonSequentially
Webhook\WebhookEvent 111 event types (PaymentCreated, PaymentReceived, TransferDone, etc.)
Statement\FinancialTransactionType 129 transaction types (PaymentReceived, Transfer, BillPayment, etc.)
Support\BankAccountType CheckingAccount, SavingsAccount

Input: Arrays or Request Objects

Every create() and update() method accepts either a plain array or a typed request object:

// Array (validated at runtime via required fields)
$result = Asaas::payments()->create([
    'customer' => 'cus_abc123',
    'billingType' => 'PIX',
    'value' => 100.00,
    'dueDate' => '2026-04-01',
]);

// Request object (validated at construction)
use OwnerPro\Asaas\Payment\BillingType;
use OwnerPro\Asaas\Payment\Request\CreatePaymentRequest;

$result = Asaas::payments()->create(new CreatePaymentRequest(
    customer: 'cus_abc123',
    billingType: BillingType::Pix,
    value: 100.00,
    dueDate: '2026-04-01',
));

Request objects can also be created from arrays via fromArray():

$data = CreatePaymentRequest::fromArray($request->validated());

Nested Value Objects

Fields like creditCard, creditCardHolderInfo, bankAccount, taxes, split, callback, and qrCode accept either a plain array or a typed DTO from OwnerPro\Asaas\Support\DTO. Using typed DTOs gives you IDE autocompletion and construction-time validation.

use OwnerPro\Asaas\Support\DTO\CreditCard;
use OwnerPro\Asaas\Support\DTO\CreditCardHolderInfo;

// Raw array (still works)
$result = Asaas::payments()->create([
    'customer' => 'cus_abc123',
    'billingType' => 'CREDIT_CARD',
    'value' => 200.00,
    'dueDate' => '2026-04-01',
    'creditCard' => [
        'holderName' => 'John Doe',
        'number' => '4111111111111111',
        'expiryMonth' => '06',
        'expiryYear' => '2028',
        'ccv' => '123',
    ],
    'creditCardHolderInfo' => [
        'name' => 'John Doe',
        'email' => 'john@example.com',
        'cpfCnpj' => '12345678901',
        'postalCode' => '01001000',
        'addressNumber' => '100',
        'phone' => '11999999999',
    ],
]);

// Typed DTOs (IDE autocompletion + construction-time validation)
$result = Asaas::payments()->create(new CreatePaymentRequest(
    customer: 'cus_abc123',
    billingType: 'CREDIT_CARD',
    value: 200.00,
    dueDate: '2026-04-01',
    creditCard: new CreditCard(
        holderName: 'John Doe',
        number: '4111111111111111',
        expiryMonth: '06',
        expiryYear: '2028',
        ccv: '123',
    ),
    creditCardHolderInfo: new CreditCardHolderInfo(
        name: 'John Doe',
        email: 'john@example.com',
        cpfCnpj: '12345678901',
        postalCode: '01001000',
        addressNumber: '100',
        phone: '11999999999',
    ),
));

Available nested DTOs (OwnerPro\Asaas\Support\DTO\*):

DTO Used in
CreditCard CreatePaymentRequest, CreditCardRequest, PayWithCreditCardRequest
CreditCardHolderInfo CreatePaymentRequest, CreditCardRequest, PayWithCreditCardRequest
BankAccount TransferRequest
Bank Nested inside BankAccount
Taxes CreateInvoiceRequest, UpdateInvoiceRequest
Split CreatePaymentRequest, UpdatePaymentRequest
SplitRefund RefundPaymentRequest
Callback CreatePaymentRequest, UpdatePaymentRequest
Transfer\Request\Recurring TransferRequest ({frequency, quantity}; frequency = TransferRecurrenceFrequency)
QrCodePayload PayQrCodeRequest
Discount CreatePaymentRequest, UpdatePaymentRequest ({value, dueDateLimitDays?, type?})
Interest CreatePaymentRequest, UpdatePaymentRequest ({value})
Fine CreatePaymentRequest, UpdatePaymentRequest ({value, type?})

Discount / Interest / Fine (since 2.0.0)discount, interest, and fine on CreatePaymentRequest / UpdatePaymentRequest are typed value objects, not floats. Each ships a coerce() helper that accepts a float (legacy shape, wrapped as value), an array, a DTO instance, Missing::Value, or null — so existing scalar callers keep working:

use OwnerPro\Asaas\Payment\DiscountType;
use OwnerPro\Asaas\Payment\FineType;
use OwnerPro\Asaas\Payment\Request\CreatePaymentRequest;
use OwnerPro\Asaas\Support\DTO\Discount;
use OwnerPro\Asaas\Support\DTO\Fine;
use OwnerPro\Asaas\Support\DTO\Interest;

// Legacy float still works — wrapped automatically as Discount(value: 10.0)
Asaas::payments()->create(new CreatePaymentRequest(
    customer: 'cus_abc123', billingType: 'PIX', value: 200.00, dueDate: '2026-04-01',
    discount: 10.0,
));

// Typed value object with the full Asaas-documented shape
Asaas::payments()->create(new CreatePaymentRequest(
    customer: 'cus_abc123', billingType: 'PIX', value: 200.00, dueDate: '2026-04-01',
    discount: new Discount(value: 10.0, dueDateLimitDays: 5, type: DiscountType::Percentage),
    interest: new Interest(value: 1.5),
    fine: new Fine(value: 2.0, type: FineType::Percentage),
));

Reading $request->discount returns ?Discount (not ?float) — access $request->discount->value for the scalar.

Example with Split (marketplace splits) and Callback (post-payment redirect):

use OwnerPro\Asaas\Payment\BillingType;
use OwnerPro\Asaas\Payment\Request\CreatePaymentRequest;
use OwnerPro\Asaas\Support\DTO\Callback;
use OwnerPro\Asaas\Support\DTO\Split;

Asaas::payments()->create(new CreatePaymentRequest(
    customer: 'cus_abc123',
    billingType: BillingType::Pix,
    value: 200.00,
    dueDate: '2026-04-01',
    split: [
        new Split(walletId: 'wallet_partner_a', percentualValue: 70.0),
        new Split(walletId: 'wallet_partner_b', fixedValue: 30.00),
    ],
    callback: new Callback(
        successUrl: 'https://example.com/return',
        autoRedirect: true,
    ),
));

New Request DTOs

Beyond create() and update(), several action methods now accept typed request objects:

Payment actions:

use OwnerPro\Asaas\Payment\Request\CreatePaymentRequest;
use OwnerPro\Asaas\Payment\Request\SimulatePaymentRequest;
use OwnerPro\Asaas\Payment\Request\RefundPaymentRequest;
use OwnerPro\Asaas\Payment\Request\PayWithCreditCardRequest;
use OwnerPro\Asaas\Payment\Request\ReceivePaymentInCashRequest;
use OwnerPro\Asaas\Support\DTO\CreditCard;
use OwnerPro\Asaas\Support\DTO\CreditCardHolderInfo;
use OwnerPro\Asaas\Support\DTO\SplitRefund;

// Simulate payment
use OwnerPro\Asaas\Payment\BillingType;

Asaas::payments()->simulate(new SimulatePaymentRequest(
    value: 500.00,
    billingTypes: [BillingType::CreditCard, BillingType::Pix],
    installmentCount: 3,
));

// Refund with split refunds
Asaas::payments()->refund('pay_abc123', new RefundPaymentRequest(
    value: 50.00,
    description: 'Partial refund',
    splitRefunds: [
        new SplitRefund(id: 'split_abc', value: 25.00),
        new SplitRefund(id: 'split_def', value: 25.00),
    ],
));

// Pay with credit card
Asaas::payments()->payWithCreditCard('pay_abc123', new PayWithCreditCardRequest(
    creditCard: new CreditCard(
        holderName: 'John Doe',
        number: '4111111111111111',
        expiryMonth: '06',
        expiryYear: '2028',
        ccv: '123',
    ),
    creditCardHolderInfo: new CreditCardHolderInfo(
        name: 'John Doe',
        email: 'john@example.com',
        cpfCnpj: '12345678901',
        postalCode: '01001000',
        addressNumber: '100',
        phone: '11999999999',
    ),
    remoteIp: '203.0.113.42', // payer's IP — required for Asaas antifraud analysis
));

// Create + charge with a saved card token in one shot (`POST /payments/`)
Asaas::payments()->createWithCreditCard(new CreatePaymentRequest(
    customer: 'cus_456',
    billingType: 'CREDIT_CARD',
    value: 150.00,
    dueDate: '2026-04-01',
    remoteIp: '203.0.113.42',
    creditCardToken: 'tok_xyz', // saved-card token from a previous authorization
));

// Receive payment in cash
Asaas::payments()->receiveInCash('pay_abc123', new ReceivePaymentInCashRequest(
    paymentDate: '2026-03-26',
    value: 100.00,
    notifyCustomer: true,
));

Pix Transactions:

use OwnerPro\Asaas\PixTransaction\Request\DecodeQrCodeRequest;
use OwnerPro\Asaas\PixTransaction\Request\PayQrCodeRequest;
use OwnerPro\Asaas\Support\DTO\QrCodePayload;

// Decode a QR code
Asaas::pixTransactions()->decodeQrCode(new DecodeQrCodeRequest(
    payload: '00020126580014br.gov.bcb.pix...',
));

// Pay a QR code with typed payload
Asaas::pixTransactions()->payQrCode(new PayQrCodeRequest(
    qrCode: new QrCodePayload(
        payload: '00020126580014br.gov.bcb.pix...',
        changeValue: 5.00,
    ),
    value: 150.00,
    description: 'QR Code payment',
));

Pix:

use OwnerPro\Asaas\Pix\Request\StaticQrCodeRequest;

Asaas::pix()->createStaticQrCode(new StaticQrCodeRequest(
    addressKey: 'abc-uuid-key',
    description: 'Store payment',
    value: 49.90,
    allowsMultiplePayments: true,
));

Bill Payments:

use OwnerPro\Asaas\BillPayment\Request\SimulateBillPaymentRequest;

Asaas::billPayments()->simulate(new SimulateBillPaymentRequest(
    identificationField: '23793.38128 60000.000003 00000.000400 1 84340000012345',
));

Partial Updates: Omitting Fields

Update DTOs (UpdatePaymentRequest, UpdateInvoiceRequest, UpdateWebhookRequest) use Missing::Value as the default for every field. Omit a field to leave it untouched; pass a value to change it. The Asaas spec marks every request-body field as nullable: false, so explicit null is not sent: fromArray(['dueDate' => null]) treats the field as omitted, exactly as if the key were absent. This matters when feeding a DTO straight from Laravel's $request->validated(), which legitimately yields null for untouched optional fields. Passing null directly to the constructor still raises TypeError — the array factory is the coercion boundary.

use OwnerPro\Asaas\Payment\Request\UpdatePaymentRequest;

// 1. Don't change — omit the field (default):
Asaas::payments()->update('pay_123', new UpdatePaymentRequest(
    value: 200.00,
    // description not passed → field not sent → API keeps current value
));

// 2. Set a new value:
Asaas::payments()->update('pay_123', new UpdatePaymentRequest(
    description: 'New description',
));

Array shape works identically — only keys present in the array reach the wire:

// Only updates value, description untouched:
Asaas::payments()->update('pay_123', ['value' => 200.00]);

Updated Request DTOs with Nested DTO Support

CreatePaymentRequest, CreditCardRequest, CreateInvoiceRequest, and TransferRequest now accept typed nested DTOs alongside plain arrays:

use OwnerPro\Asaas\CreditCard\Request\CreditCardRequest;
use OwnerPro\Asaas\Support\DTO\CreditCard;
use OwnerPro\Asaas\Support\DTO\CreditCardHolderInfo;

// Tokenize with typed DTOs
Asaas::creditCards()->tokenize(new CreditCardRequest(
    customer: 'cus_abc123',
    creditCard: new CreditCard(
        holderName: 'John Doe',
        number: '4111111111111111',
        expiryMonth: '06',
        expiryYear: '2028',
        ccv: '123',
    ),
    creditCardHolderInfo: new CreditCardHolderInfo(
        name: 'John Doe',
        email: 'john@example.com',
        cpfCnpj: '12345678901',
        postalCode: '01001000',
        addressNumber: '100',
        phone: '11999999999',
    ),
    remoteIp: '127.0.0.1',
));
use OwnerPro\Asaas\Invoice\Request\CreateInvoiceRequest;
use OwnerPro\Asaas\Support\DTO\Taxes;

// Create invoice with typed Taxes
Asaas::invoices()->create(new CreateInvoiceRequest(
    serviceDescription: 'Consulting services',
    observations: 'March 2026',
    value: 5000.00,
    deductions: 0.00,
    effectiveDate: '2026-03-26',
    municipalServiceName: 'Consultoria em TI',
    taxes: new Taxes(
        retainIss: false,
        iss: 2.0,
        pis: 0.65,
        cofins: 3.0,
        csll: 1.0,
        inss: 0.0,
        ir: 1.5,
    ),
    updatePayment: true, // since 2.0.0 — auto-discount the tax retention from the linked payment value
));
use OwnerPro\Asaas\Transfer\Request\TransferRequest;
use OwnerPro\Asaas\Transfer\TransferOperationType;
use OwnerPro\Asaas\Support\DTO\BankAccount;
use OwnerPro\Asaas\Support\DTO\Bank;
use OwnerPro\Asaas\Support\BankAccountType;

// Transfer with typed BankAccount
Asaas::transfers()->create(new TransferRequest(
    value: 1000.00,
    bankAccount: new BankAccount(
        ownerName: 'Jane Doe',
        cpfCnpj: '12345678901',
        agency: '0001',
        account: '123456',
        accountDigit: '1',
        bank: new Bank(code: '001'),
        bankAccountType: BankAccountType::CheckingAccount,
    ),
    operationType: TransferOperationType::Ted,
));

Pagination

List methods return AsaasPaginatedResult:

$result = Asaas::payments()->list(['limit' => 10]);

$result->data;        // list of arrays (each array is a raw API response)
$result->totalCount;  // total items available
$result->hasMore;     // more pages available?
$result->limit;       // as echoed by the API
$result->offset;      // as requested by the SDK for this page

// Fetch next page
$nextPage = $result->next();

The two paging fields have different provenance, which matters only if you assert on them. limit is whatever the API echoed. offset is the value the SDK asked for, because it is always known — an envelope that omits offset would otherwise report 0 for every page. next() advances by the number of rows the page actually delivered (not by limit, which a short page would overshoot) and returns null once a page comes back empty.

all() has five brakes. A walk that ends on anything but a plain empty page yields a final AsaasPaginatedError rather than returning quietly — a silent stop would look exactly like a complete walk. Code that already checks each yielded item for AsaasPaginatedError needs no change.

Brake Fires when Yields
Empty page the page carries no rows nothing, when the same page says hasMore: false; PAGINATION_TRUNCATED when it still promises another
Repeated page a page carries exactly the rows of the page before it and still says hasMore: true PAGINATION_STALLED
totalCount reached, hasMore still true the envelope contradicts itself PAGINATION_INCONSISTENT
hasMore: false short of totalCount the envelope contradicts itself the other way PAGINATION_SHORT
Page ceiling 10 000 pages fetched, the last of them still saying hasMore: true, and no brake above ever fired PAGINATION_RUNAWAY

An empty page always ends the walk — there is nothing to advance past — but one that still sets hasMore: true is an envelope contradicting itself, and rows may be missing behind it. That is what PAGINATION_TRUNCATED reports.

PAGINATION_SHORT is the mirror of PAGINATION_INCONSISTENT, and the direction that actually loses rows: the endpoint counted 100 for your filters and then declared the walk over after 20. Ending is the only thing to do — hasMore: false leaves nothing to advance to — but on a listing that drives reconciliation, ending quietly reads as "the other 80 do not exist". A totalCount of 0 is what an envelope omitting the field reports, so it never triggers this.

The ceiling is the last resort: an endpoint that ignores offset and answers in an unstable order repeats no page and reports no usable totalCount, so nothing else marks the walk complete. It counts pages, so the row it stops at is 10 000 × whatever limit you passed: a million rows at the API's maximum page size of 100, but only 100 000 at limit: 10. A million is past any real account, so on a full-size page it only ever fires on a fault — with a small page size it can land in front of a set the walk would otherwise finish, which is why the error says to raise limit rather than blaming the endpoint.

The last two matter on endpoints whose query parameters Asaas never documented. One that ignored offset would answer every request with the same non-empty page and hasMore: true, and the empty-page rule alone would let the generator emit duplicates forever.

The repeated-page brake is what catches that, and it fires before the duplicate rows are handed over: next() always advances the cursor by the rows just delivered, so a page repeating the one before it while still promising another is an endpoint that disregarded the offset. totalCount alone is not enough — on a page shorter than the filtered set it only fires after the same rows have gone out several times, and a consumer summing a value field would already have double-counted. An envelope that omits totalCount reports 0 and never reaches that brake at all; the repeated-page check ends those walks too.

hasMore is load-bearing there. Two consecutive pages carrying the same rows are ambiguous on their own — a sequence of fixtures in stubPages() produces exactly the shape a stalled endpoint does — but a page saying the walk is over ends it either way, so only a repeat that still promises another page is a walk that cannot advance.

Lazy Iteration

The all() method returns a Generator that auto-paginates:

foreach (Asaas::payments()->all(['limit' => 100]) as $payment) {
    if ($payment instanceof \OwnerPro\Asaas\Support\AsaasPaginatedError) {
        // Handle error — iteration stops after this
        Log::error('Pagination failed at offset '.$payment->offset, $payment->errors);
        break;
    }

    echo $payment['id'];
}

// Or collect all at once
$allPayments = iterator_to_array(Asaas::payments()->all());

If Asaas rejects a page mid-iteration (a 4xx), the Generator yields an AsaasPaginatedError instead of throwing — the rows read so far are already in hand, and the walk ends without breaking out of your foreach. A transport failure is the other case: the outcome of that page is unknown, so it throws out of the iteration after yielding the rows already read. This object carries:

  • errors — the error list from the API
  • response — the raw HTTP response
  • offset — the page offset that failed
  • limit — the page size

You can opt-in to exceptions by calling orFail() on the error object:

foreach (Asaas::payments()->all() as $payment) {
    if ($payment instanceof \OwnerPro\Asaas\Support\AsaasPaginatedError) {
        $payment->orFail(); // throws AsaasRequestException or AsaasPaginationException
    }

    processPayment($payment);
}

orFail() throws one of two types, and which one says where the fault came from:

  • AsaasRequestException — Asaas refused the page. The rows are its envelope and statusCode is its 4xx, exactly as on a single call.
  • AsaasPaginationException — the SDK's own diagnosis: one of the five brakes above fired, so the rows carry a PAGINATION_* code written by this SDK. It carries errors, response (the page the walk stopped on, when there is one), offset and limit — but no status code, because no one stated one. It extends AsaasException, not AsaasRequestException, so that an AsaasRequestException always means "Asaas answered with a 4xx verdict".

Resources

Response shape. Every resource method returns AsaasResult with $data typed as array<string, mixed> — the SDK passes Asaas's JSON response through verbatim and does not decode it into typed response objects. To learn which fields each endpoint returns (e.g. id, status, bankSlipUrl, netValue, dateCreated), consult either the OpenAPI spec under specs/domains/<domain>.json or the public Asaas docs at https://docs.asaas.com/. Nested sub-objects observed on the response side (creditCard, discount, fine, interest, callback, escrow, chargeback, etc.) follow the same array<string, mixed> rule — the SDK's request-side DTOs in OwnerPro\Asaas\Support\DTO\* are reference-only for field names, not response value objects.

Payments (payments())

Asaas::payments()->create(array|CreatePaymentRequest $data): AsaasResult
Asaas::payments()->createWithCreditCard(array|CreatePaymentRequest $data): AsaasResult
Asaas::payments()->find(string $id): AsaasResult
Asaas::payments()->list(array $query = []): AsaasPaginatedResult
Asaas::payments()->update(string $id, array|UpdatePaymentRequest $data): AsaasResult
Asaas::payments()->delete(string $id): AsaasResult
Asaas::payments()->refund(string $id, array|RefundPaymentRequest $data = []): AsaasResult
Asaas::payments()->listRefunds(string $id, array $query = []): AsaasPaginatedResult
Asaas::payments()->allRefunds(string $id, array $filters = []): Generator
Asaas::payments()->refundBankSlip(string $id): AsaasResult
Asaas::payments()->restore(string $id): AsaasResult
Asaas::payments()->captureAuthorized(string $id): AsaasResult
Asaas::payments()->payWithCreditCard(string $id, array|PayWithCreditCardRequest $data): AsaasResult
Asaas::payments()->receiveInCash(string $id, array|ReceivePaymentInCashRequest $data = []): AsaasResult
Asaas::payments()->undoReceivedInCash(string $id): AsaasResult
Asaas::payments()->status(string $id): AsaasResult
Asaas::payments()->billingInfo(string $id): AsaasResult
Asaas::payments()->pixQrCode(string $id): AsaasResult
Asaas::payments()->identificationField(string $id): AsaasResult
Asaas::payments()->viewingInfo(string $id): AsaasResult
Asaas::payments()->getChargeback(string $id): AsaasResult
Asaas::payments()->getEscrow(string $id): AsaasResult
Asaas::payments()->finishEscrow(string $escrowId): AsaasResult  // guarantee id, not the payment id — see below
Asaas::payments()->simulate(array|SimulatePaymentRequest $data): AsaasResult
Asaas::payments()->limits(): AsaasResult
Asaas::payments()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

// Payment documents (multipart upload + CRUD)
Asaas::payments()->uploadDocument(
    string $paymentId,
    string|resource $file,
    PaymentDocumentType|string $type,
    bool $availableAfterPayment,
    string $filename,
): AsaasResult
Asaas::payments()->listDocuments(string $paymentId, array $query = []): AsaasPaginatedResult
Asaas::payments()->findDocument(string $paymentId, string $documentId): AsaasResult
Asaas::payments()->updateDocument(string $paymentId, string $documentId, array|UpdatePaymentDocumentRequest $data): AsaasResult
Asaas::payments()->deleteDocument(string $paymentId, string $documentId): AsaasResult

// Split lookup (paid by you / received by you)
Asaas::payments()->listSplitsPaid(array $query = []): AsaasPaginatedResult
Asaas::payments()->findSplitPaid(string $id): AsaasResult
Asaas::payments()->listSplitsReceived(array $query = []): AsaasPaginatedResult
Asaas::payments()->findSplitReceived(string $id): AsaasResult

getEscrow() and finishEscrow() take different identifiers. getEscrow() takes the payment id (GET /v3/payments/{id}/escrow) and returns the guarantee in its id field; finishEscrow() takes that guarantee id (POST /v3/escrow/{id}/finish), which is a UUID, not a pay_… id. Passing a payment id to finishEscrow() gets a 404:

$escrow = Asaas::payments()->getEscrow('pay_080225913252')->orFail();

Asaas::payments()->finishEscrow($escrow->data['id']); // 4f468235-cec3-482f-b3d0-348af4c7194

Lean Payments (leanPayments())

Slim-response variants of the standard payment endpoints — same request DTOs, smaller response payloads. Useful for high-throughput create/find flows where you don't need every field back.

Asaas::leanPayments()->create(array|CreatePaymentRequest $data): AsaasResult
Asaas::leanPayments()->createWithCreditCard(array|CreatePaymentRequest $data): AsaasResult
Asaas::leanPayments()->list(array $query = []): AsaasPaginatedResult
Asaas::leanPayments()->find(string $id): AsaasResult
Asaas::leanPayments()->update(string $id, array|UpdatePaymentRequest $data): AsaasResult
Asaas::leanPayments()->delete(string $id): AsaasResult
Asaas::leanPayments()->captureAuthorized(string $id): AsaasResult
Asaas::leanPayments()->restore(string $id): AsaasResult
Asaas::leanPayments()->refund(string $id, array|RefundPaymentRequest $data = []): AsaasResult
Asaas::leanPayments()->receiveInCash(string $id, array|ReceivePaymentInCashRequest $data = []): AsaasResult
Asaas::leanPayments()->undoReceivedInCash(string $id): AsaasResult
Asaas::leanPayments()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

Credit card pre-authorization (two-step capture)

Set authorizeOnly: true on CreatePaymentRequest (or via array) to reserve the value without capturing it. Capture later with captureAuthorized($id).

Card fields — creditCard, creditCardHolderInfo, creditCardToken, remoteIp and authorizeOnly — are only accepted by createWithCreditCard(); create() posts to the endpoint that does not declare them, so an authorizeOnly sent through create() is dropped and the later capture fails. remoteIp is required and validated before the request leaves the SDK:

$result = Asaas::payments()->createWithCreditCard(new CreatePaymentRequest(
    customer: 'cus_abc123',
    billingType: 'CREDIT_CARD',
    value: 200.00,
    dueDate: '2026-04-01',
    creditCard: new CreditCard(/* ... */),
    creditCardHolderInfo: new CreditCardHolderInfo(/* ... */),
    remoteIp: '203.0.113.10',
    authorizeOnly: true,
));

Asaas::payments()->captureAuthorized($result->data['id']);

Pix Keys (pix())

Asaas::pix()->createKey(array|PixKeyRequest $data): AsaasResult  // type must be 'EVP' — see below
Asaas::pix()->findKey(string $id): AsaasResult
Asaas::pix()->listKeys(array $query = []): AsaasPaginatedResult
Asaas::pix()->deleteKey(string $id): AsaasResult
Asaas::pix()->createStaticQrCode(array|StaticQrCodeRequest $data = []): AsaasResult
Asaas::pix()->deleteStaticQrCode(string $id): AsaasResult
Asaas::pix()->tokenBucket(): AsaasResult
Asaas::pix()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

createKey() only mints random (EVP) keys — POST /v3/pix/addressKeys declares "enum": ["EVP"]. CPF, CNPJ, EMAIL and PHONE keys are registered in the Asaas panel, not through the API, so PixKeyRequest rejects them with an InvalidArgumentException instead of letting the request earn a remote 400. The shared PixAddressKeyType enum still carries all five cases because transfers legitimately accept them (TransferRequest::$pixAddressKeyType).

Pix Transactions (pixTransactions())

Asaas::pixTransactions()->decodeQrCode(array|DecodeQrCodeRequest $data): AsaasResult
Asaas::pixTransactions()->payQrCode(array|PayQrCodeRequest $data): AsaasResult
Asaas::pixTransactions()->find(string $id): AsaasResult
Asaas::pixTransactions()->list(array $query = []): AsaasPaginatedResult
Asaas::pixTransactions()->cancel(string $id): AsaasResult
Asaas::pixTransactions()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

// Pix recurrings (scheduled outflow)
Asaas::pixTransactions()->listRecurrings(array $query = []): AsaasPaginatedResult
Asaas::pixTransactions()->findRecurring(string $id): AsaasResult
Asaas::pixTransactions()->cancelRecurring(string $id): AsaasResult
Asaas::pixTransactions()->listRecurringItems(string $id, array $query = []): AsaasPaginatedResult
Asaas::pixTransactions()->cancelRecurringItem(string $itemId): AsaasResult
Asaas::pixTransactions()->allRecurrings(array $filters = []): Generator (yields array|AsaasPaginatedError)
Asaas::pixTransactions()->allRecurringItems(string $id, array $filters = []): Generator (yields array|AsaasPaginatedError)

Pix Automático (pixAutomatic())

Authorized recurring debits (Pix Automático) — payer authorizes the receiver to charge periodically.

Asaas::pixAutomatic()->createAuthorization(array|AuthorizationRequest $data): AsaasResult
Asaas::pixAutomatic()->listAuthorizations(array $query = []): AsaasPaginatedResult
Asaas::pixAutomatic()->findAuthorization(string $id): AsaasResult
Asaas::pixAutomatic()->cancelAuthorization(string $id): AsaasResult
Asaas::pixAutomatic()->listPaymentInstructions(array $query = []): AsaasPaginatedResult
Asaas::pixAutomatic()->findPaymentInstruction(string $id): AsaasResult
Asaas::pixAutomatic()->allAuthorizations(array $filters = []): Generator (yields array|AsaasPaginatedError)
Asaas::pixAutomatic()->allPaymentInstructions(array $filters = []): Generator (yields array|AsaasPaginatedError)

Transfers (transfers())

Asaas::transfers()->create(array|TransferRequest $data): AsaasResult
Asaas::transfers()->createInternal(array|InternalTransferRequest $data): AsaasResult
Asaas::transfers()->find(string $id): AsaasResult
Asaas::transfers()->list(array $query = []): AsaasPaginatedResult
Asaas::transfers()->cancel(string $id): AsaasResult
Asaas::transfers()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

Webhooks (webhooks())

Asaas::webhooks()->create(array|CreateWebhookRequest $data): AsaasResult
Asaas::webhooks()->find(string $id): AsaasResult
Asaas::webhooks()->list(array $query = []): AsaasPaginatedResult
Asaas::webhooks()->update(string $id, array|UpdateWebhookRequest $data): AsaasResult
Asaas::webhooks()->delete(string $id): AsaasResult
Asaas::webhooks()->removeBackoff(string $id): AsaasResult
Asaas::webhooks()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

Asaas requires interrupted on create. The Asaas API rejects POST /v3/webhooks with HTTP 400 ("O parâmetro poolInterrupted deve ser informado"poolInterrupted is the Asaas validator's internal variable name; the documented and accepted JSON field is interrupted) when the field is absent. CreateWebhookRequest defaults interrupted to false so the request is always accepted. UpdateWebhookRequest::interrupted follows the usual partial-update pattern (Missing = not sent).

Webhook Verification

When you create a webhook with an authToken, Asaas sends that token in the asaas-access-token header on every delivery. Use WebhookVerifier to validate incoming requests with a timing-safe comparison:

use OwnerPro\Asaas\Webhook\WebhookVerifier;

$verifier = new WebhookVerifier(authToken: 'your-webhook-auth-token');

// Verify the token (timing-safe via hash_equals)
if (! $verifier->verify($request->header('asaas-access-token', ''))) {
    abort(401);
}

// Optional: verify the request came from a known Asaas IP
if (! $verifier->isFromAsaas($request->ip())) {
    abort(403);
}

The IP check is only as trustworthy as the IP you hand it. WebhookVerifier compares whatever address it receives; the trust boundary is the caller's. Laravel's $request->ip() resolves from the X-Forwarded-For header for requests arriving through a trusted proxy, so with TrustProxies set to * (common behind a load balancer) and an edge that does not strip client-supplied forwarding headers, an attacker can spoof a known Asaas IP and pass this check. Restrict TrustProxies to your real infrastructure proxies before treating isFromAsaas() as meaningful. Either way, the authToken verification above is the primary defense — the IP allowlist is defense in depth, never a substitute.

The default known Asaas IPs are 52.67.12.206, 18.230.8.159, 54.94.136.112, and 54.94.183.101. You can override them if Asaas updates their IP list:

$verifier = new WebhookVerifier(
    authToken: 'your-webhook-auth-token',
    trustedIps: [...WebhookVerifier::DEFAULT_IPS, '10.0.0.1'],
);

The verifier holds the shared secret, so it redacts itself like every other secret-holding object: dump(), dd(), var_dump(), print_r() and the framework error page show authToken: *** alongside the readable trustedIps, and serialize() throws. This matters here because debugging a rejected delivery is exactly when you reach for dd($verifier). See Sensitive data in debug output.

Invoices (invoices())

Asaas::invoices()->create(array|CreateInvoiceRequest $data): AsaasResult
Asaas::invoices()->find(string $id): AsaasResult
Asaas::invoices()->list(array $query = []): AsaasPaginatedResult
Asaas::invoices()->update(string $id, array|UpdateInvoiceRequest $data): AsaasResult
Asaas::invoices()->authorize(string $id): AsaasResult
Asaas::invoices()->cancel(string $id, array|CancelInvoiceRequest|null $data = null): AsaasResult
Asaas::invoices()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

municipalServiceName is a create-only field. PUT /v3/invoices/{id} accepts only serviceDescription, observations, externalReference, value, deductions, effectiveDate, updatePayment and taxes, so UpdateInvoiceRequest does not carry it — passing the key in an array is ignored rather than sent.

Accounts (accounts())

Asaas::accounts()->create(array|AccountRequest $data): AsaasResult
Asaas::accounts()->find(string $id): AsaasResult
Asaas::accounts()->list(array $query = []): AsaasPaginatedResult
Asaas::accounts()->listAccessTokens(string $accountId): AsaasResult
Asaas::accounts()->createAccessToken(string $accountId, array|CreateAccessTokenRequest|null $data = null): AsaasResult
Asaas::accounts()->updateAccessToken(string $accountId, string $tokenId, array|UpdateAccessTokenRequest $data): AsaasResult
Asaas::accounts()->deleteAccessToken(string $accountId, string $tokenId): AsaasResult
Asaas::accounts()->escrowConfig(string $accountId): AsaasResult
Asaas::accounts()->setEscrowConfig(string $accountId, array|EscrowConfigRequest $data): AsaasResult
Asaas::accounts()->defaultEscrowConfig(): AsaasResult
Asaas::accounts()->setDefaultEscrowConfig(array|EscrowConfigRequest $data): AsaasResult
Asaas::accounts()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

Subaccount API key permissions

By default, Asaas issues a brand-new subaccount key with all permissions in READ_WRITE (which is fine for most cases). To start a subaccount locked down to a narrow scope — or to widen/shrink an existing key later — pass accessTokenConfig on creation, or permissions on update:

use OwnerPro\Asaas\Account\AccessTokenPermission;
use OwnerPro\Asaas\Account\AccessTokenScope;
use OwnerPro\Asaas\Account\Request\AccessTokenConfig;
use OwnerPro\Asaas\Account\Request\AccessTokenPermissionConfig;
use OwnerPro\Asaas\Account\Request\AccountRequest;
use OwnerPro\Asaas\Account\Request\UpdateAccessTokenRequest;

// On subaccount creation — initial key with a curated scope
$result = Asaas::accounts()->create(new AccountRequest(
    name: 'Subconta Integrada',
    email: 'integrator@example.com',
    cpfCnpj: '12345678000199',
    mobilePhone: '11999999999',
    incomeValue: 25000.0,
    address: 'Av Paulista',
    addressNumber: '1000',
    province: 'Bela Vista',
    postalCode: '01310100',
    accessTokenConfig: new AccessTokenConfig(
        name: 'Chave de Integração',
        permissions: [
            new AccessTokenPermissionConfig(name: AccessTokenPermission::Payment, scope: AccessTokenScope::ReadWrite),
            new AccessTokenPermissionConfig(name: AccessTokenPermission::Transfer, scope: AccessTokenScope::ReadWrite),
            new AccessTokenPermissionConfig(name: AccessTokenPermission::Webhook,  scope: AccessTokenScope::ReadWrite),
        ],
    ),
));

// Later — widen an existing key's permissions
Asaas::accounts()->updateAccessToken('acc_123', 'tok_1', new UpdateAccessTokenRequest(
    name: 'Chave de Integração',
    enabled: true,
    expirationDate: '2026-12-31 23:59:59',
    permissions: [
        new AccessTokenPermissionConfig(name: 'TRANSFER', scope: 'READ_WRITE'),
    ],
));

Required on update. UpdateAccessTokenRequest makes name, enabled and expirationDate mandatory in the constructor — the Asaas PUT /accounts/{id}/accessTokens/{tokenId} rejects partial updates. CreateAccessTokenRequest keeps every field optional because POST accepts a bare body (Asaas applies its defaults).

AccessTokenPermission covers all 33 permission codes documented by Asaas (PAYMENT, TRANSFER, WEBHOOK, PIX_*, INVOICE, BILL, etc.); AccessTokenScope is READ or READ_WRITE. Both DTOs accept the enum or the raw string for forward-compat.

An empty permissions list is rejected, not omitted. Omitting permissions mints a key with all permissions (READ_WRITE on everything) — that is Asaas's documented default. An explicit permissions: [] reads as the opposite request, so folding it into an omission would answer "no privileges" with the most privileged key Asaas issues. {"permissions": []} has no documented meaning either, so there is no third option that both reaches the wire and behaves: [] raises InvalidArgumentException and you say which one you meant. Pass at least one AccessTokenPermissionConfig to restrict a key, or leave the field out to accept the all-permissions default.

This applies to CreateAccessTokenRequest, UpdateAccessTokenRequest and the accessTokenConfig carried inline on accounts()->create(). If you build the payload from $request->validated(), note that an absent key is simply missing from the array — [] only reaches the DTO when the client explicitly sent an empty array.

Permission ↔ endpoint mapping (heuristic)

Heuristic mapping — not normative. Asaas does not publish an official permission ↔ endpoint correspondence: the OpenAPI spec declares security only at the top of each domain file (no per-operation security or x-permission extension), and the public docs page that once described the mapping no longer resolves. The table below is our best inference from the enum names and Asaas API domains. Treat it as best-effort documentation, not contract: validate in sandbox before restricting a production key, and report drift via a GitHub issue if a restricted key returns 401/403 on an endpoint listed here.

Permission Endpoints (heuristic) SDK Resource
Customer /v3/customers/* not exposed — use Connector directly
CustomerNotification /v3/customers/{id}/notifications, /v3/notifications/* not exposed — use Connector directly
Payment /v3/payments, /v3/payments/{id}, /v3/payments/{id}/status, /v3/payments/{id}/billingInfo, /v3/payments/{id}/viewingInfo, /v3/payments/{id}/identificationField, /v3/payments/{id}/pixQrCode, /v3/payments/{id}/receiveInCash, /v3/payments/{id}/undoReceivedInCash, /v3/payments/{id}/captureAuthorizedPayment, /v3/payments/{id}/payWithCreditCard, /v3/payments/{id}/refund, /v3/payments/{id}/restore, /v3/payments/limits, /v3/payments/simulate, /v3/lean/payments/* PaymentResource, LeanPaymentResource
PaymentRefund /v3/payments/{id}/refunds, /v3/payments/{id}/bankSlip/refund PaymentResource::refunds*
Chargeback /v3/chargebacks/*, /v3/payments/{id}/chargeback partial — PaymentResource::getChargeback
Installment /v3/installments/* not exposed — use Connector directly
Subscription /v3/subscriptions/* not exposed — use Connector directly
PixCredit /v3/payments/{id}/pixQrCode (receive via Pix); dynamic QR Code PaymentResource::pixQrCode
PaymentLink /v3/paymentLinks/* not exposed — use Connector directly
Checkout /v3/checkouts/* not exposed — use Connector directly
CreditCard /v3/creditCard/tokenizeCreditCard, /v3/creditCard/preAuthorization/config CreditCardResource
Anticipation /v3/anticipations, /v3/anticipations/{id}, /v3/anticipations/{id}/cancel, /v3/anticipations/simulate, /v3/anticipations/limits not exposed — use Connector directly
AnticipationConfig /v3/anticipations/configurations not exposed — use Connector directly
Escrow /v3/escrow/{id}/finish, /v3/payments/{id}/escrow, /v3/accounts/{id}/escrow PaymentResource::getEscrow/finishEscrow, AccountResource::escrowConfig
EscrowConfig /v3/accounts/escrow, /v3/accounts/{id}/escrow (PUT/POST config) AccountResource::escrowConfig/setEscrowConfig/defaultEscrowConfig/setDefaultEscrowConfig
CreditBureau /v3/creditBureauReport, /v3/creditBureauReport/{id} not exposed — use Connector directly
PaymentDunning /v3/paymentDunnings/* not exposed — use Connector directly
FiscalInfo /v3/fiscalInfo/* (config, services, NBS, taxes, federalServiceCodes, taxClassificationCodes, taxSituationCodes, operationIndicatorCodes, municipalOptions, nationalPortal) FiscalInfoResource
Invoice /v3/invoices, /v3/invoices/{id}, /v3/invoices/{id}/authorize, /v3/invoices/{id}/cancel InvoiceResource
PixDebit /v3/pix/qrCodes/decode, /v3/pix/qrCodes/pay (pay) PixTransactionResource::decodeQrCode/payQrCode
PixAddressKey /v3/pix/addressKeys, /v3/pix/addressKeys/{id}, /v3/pix/tokenBucket/addressKey PixResource::*Key
PixRecurring /v3/pix/transactions/recurrings, /v3/pix/transactions/recurrings/{id}, /v3/pix/transactions/recurrings/{id}/cancel, /v3/pix/transactions/recurrings/{id}/items, /v3/pix/transactions/recurrings/items/{id}/cancel PixTransactionResource::recurring*
PixTransaction /v3/pix/transactions, /v3/pix/transactions/{id}, /v3/pix/transactions/{id}/cancel, /v3/pix/qrCodes/static, /v3/pix/qrCodes/static/{id} PixTransactionResource, PixResource::*StaticQr*
PixAutomatic /v3/pix/automatic/authorizations, /v3/pix/automatic/authorizations/{id}, /v3/pix/automatic/paymentInstructions, /v3/pix/automatic/paymentInstructions/{id} PixAutomaticResource
Transfer /v3/transfers, /v3/transfers/{id}, /v3/transfers/{id}/cancel TransferResource
Bill /v3/bill, /v3/bill/{id}, /v3/bill/{id}/cancel, /v3/bill/simulate BillPaymentResource
MobilePhoneRecharge /v3/mobilePhoneRecharges/* not exposed — use Connector directly
FinancialTransaction /v3/financialTransactions, /v3/finance/balance, /v3/finance/payment/statistics, /v3/finance/split/statistics StatementResource
AccountInfo /v3/myAccount/, /v3/myAccount/accountNumber, /v3/myAccount/commercialInfo/, /v3/myAccount/fees/, /v3/myAccount/status/, /v3/wallets/ MyAccountResource (partial)
PaymentCheckoutConfig /v3/myAccount/paymentCheckoutConfig/ MyAccountResource::*PaymentCheckoutConfig
Webhook /v3/webhooks, /v3/webhooks/{id}, /v3/webhooks/{id}/removeBackoff WebhookResource
SubAccount /v3/accounts, /v3/accounts/{id}, /v3/accounts/{id}/accessTokens, /v3/accounts/{id}/accessTokens/{accessTokenId} AccountResource
AccountDocument /v3/myAccount/documents, /v3/myAccount/documents/{id}, /v3/myAccount/documents/files/{id} MyAccountResource::*Document*

Ambiguities worth flagging when validating in sandbox: PIX_CREDIT vs PIX_DEBIT (receive vs pay via Pix), ESCROW vs ESCROW_CONFIG (operations vs configuration), ANTICIPATION vs ANTICIPATION_CONFIG (analogous), and ACCOUNT_INFO vs SUB_ACCOUNT (read of own account vs CRUD over child accounts). The path /v3/payments/{id}/chargeback lives under the Payment URL prefix but is expected to require CHARGEBACK (resource-name wins over path-prefix).

My Account (myAccount())

Operates on the current account behind the apiKey — used for tenant onboarding (KYC, commercial info, document upload, bank account). Must be called with the subaccount's apiKey, not the master.

Asaas::myAccount()->status(): AsaasResult
Asaas::myAccount()->approveSandbox(): AsaasResult           // sandbox-only
Asaas::myAccount()->accountNumber(): AsaasResult
Asaas::myAccount()->fees(): AsaasResult
Asaas::myAccount()->commercialInfo(): AsaasResult
Asaas::myAccount()->updateCommercialInfo(array|CommercialInfoRequest $data): AsaasResult
Asaas::myAccount()->documents(): AsaasResult
Asaas::myAccount()->uploadDocumentFile(string $documentId, string|resource $file, DocumentType|string $type, string $filename): AsaasResult
Asaas::myAccount()->findDocumentFile(string $fileId): AsaasResult
Asaas::myAccount()->updateDocumentFile(string $fileId, string|resource $file, string $filename): AsaasResult
Asaas::myAccount()->deleteDocumentFile(string $fileId): AsaasResult
Asaas::myAccount()->paymentCheckoutConfig(): AsaasResult
Asaas::myAccount()->updatePaymentCheckoutConfig(
    array|PaymentCheckoutConfigRequest $data,
    string|resource|null $logoFile = null,
    ?string $logoFilename = null,
): AsaasResult
Asaas::myAccount()->wallets(array $query = []): AsaasPaginatedResult
Asaas::myAccount()->delete(array|DeleteAccountRequest $data): AsaasResult

Out of scope. Sandbox-only endpoints such as POST /sandbox/myAccount/approve (auto-approval shortcut for testing) are intentionally not exposed: they have no production counterpart and would only obscure the real KYC flow. Use the Connector directly if you need to reach a sandbox-only path in your test harness.

Subaccount onboarding (white label)

After accounts()->create() returns a new subaccount, the tenant must complete KYC and send commercial info before they can transact. Drive the documented portion of the flow without redirecting the tenant to the Asaas panel by instantiating a tenant-scoped client with the subaccount's apiKey and calling myAccount().

use OwnerPro\Asaas\Account\DocumentType;
use OwnerPro\Asaas\Account\Request\CommercialInfoRequest;
use OwnerPro\Asaas\AsaasClient;
use OwnerPro\Asaas\Account\CompanyType;
use OwnerPro\Asaas\Support\Environment;

$tenantClient = AsaasClient::for(
    apiKey: $tenant->asaas_api_key,
    environment: Environment::Production,
);

// 1. Pull current onboarding status
$status = $tenantClient->myAccount()->status();
// $status->data: ['general' => ..., 'commercialInfo' => ..., 'documentation' => ..., 'bankAccountInfo' => ...]

// 2. Update commercial info (partial — only changed fields)
$tenantClient->myAccount()->updateCommercialInfo(new CommercialInfoRequest(
    incomeValue: 12000.0,
    companyType: CompanyType::Limited,
));

// 3. Upload required KYC documents
$documents = $tenantClient->myAccount()->documents();
$documentId = $documents->data['data'][0]['id'];

$tenantClient->myAccount()->uploadDocumentFile(
    documentId: $documentId,
    file: fopen('/tmp/rg.png', 'rb'),
    type: DocumentType::Identification,
    filename: 'rg.png',
);

// 4. The tenant registers their bank account through the Asaas web panel —
//    no public API endpoint is documented for /myAccount/bankAccountInfo.
//    Monitor $status['bankAccountInfo'] to track approval (PENDING → APPROVED).

Every upload filename (uploadDocumentFile, updateDocumentFile, updatePaymentCheckoutConfig, payments()->uploadDocument, fiscalInfo()->save) is validated before it reaches the HTTP client: directory components are stripped, and a name over 255 chars, or one carrying a double quote, a backslash or a control character, throws InvalidArgumentException. Those characters break out of the multipart Content-Disposition header — the double quote closes the value outright, and a trailing backslash escapes the closing quote — and would let a caller-supplied name forge extra form fields. A browser-supplied getClientOriginalName() can therefore be passed straight through.

A name the HTTP client would read as absent is rejected for a different reason: it then falls back to the stream's uri metadata and ships the local file's name, disclosing what your server called it. "Absent" is Guzzle's definition rather than PHP's — it tests the name with empty(), so '' and the single character '0' are both rejected ('0.png' is fine). For the same reason Connector::postMultipart() requires a filename on every entry of $files: omitting it skips the guard entirely. A part that is not a file belongs in $data.

The part name each file is sent under is validated by the same rules, for callers reaching Connector::postMultipart() directly — as are any extra headers such a caller hangs on a part: the HTTP client writes them into the part preamble as "{$name}: {$value}\r\n" with no validation of its own, so a CR or LF in either half would close the header block and append arbitrary parts. Header names must be RFC 7230 tokens and values must carry no control characters; quotes and backslashes are allowed there, since a header value is not a quoted string. One name is refused outright: a part may not carry its own Content-Disposition, because Guzzle writes its own only when the caller supplied none — so it would silently replace the name and filename just validated.

Listen to the ACCOUNT_STATUS_* webhook events (already in WebhookEvent) to react to approvals and rejections asynchronously.

Credit Cards (creditCards())

Asaas::creditCards()->tokenize(array|CreditCardRequest $data): AsaasResult
Asaas::creditCards()->getPreAuthorizationConfig(): AsaasResult
Asaas::creditCards()->setPreAuthorizationConfig(array|PreAuthConfigRequest $data): AsaasResult

Bill Payments (billPayments())

Asaas::billPayments()->create(array|CreateBillPaymentRequest $data): AsaasResult
Asaas::billPayments()->find(string $id): AsaasResult
Asaas::billPayments()->list(array $query = []): AsaasPaginatedResult
Asaas::billPayments()->simulate(array|SimulateBillPaymentRequest $data = []): AsaasResult
Asaas::billPayments()->cancel(string $id): AsaasResult
Asaas::billPayments()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

Statements (statements())

Asaas::statements()->list(array $query = []): AsaasPaginatedResult
Asaas::statements()->balance(): AsaasResult
Asaas::statements()->paymentStatistics(array $query = []): AsaasResult
Asaas::statements()->splitStatistics(array $query = []): AsaasResult
Asaas::statements()->all(array $filters = []): Generator (yields array|AsaasPaginatedError)

Fiscal Info (fiscalInfo())

Configures fiscal data and digital certificates for NFS-e (Nota Fiscal de Serviço eletrônica) issuance, plus lookup of municipal/federal/national service classification codes.

Asaas::fiscalInfo()->recover(): AsaasResult
Asaas::fiscalInfo()->save(
    array|FiscalInfoRequest $data,
    string|resource|null $certificateFile = null,
    ?string $certificateFilename = null,
): AsaasResult
Asaas::fiscalInfo()->municipalOptions(): AsaasResult
Asaas::fiscalInfo()->services(array $query = []): AsaasPaginatedResult
Asaas::fiscalInfo()->federalServiceCodes(array $query = []): AsaasPaginatedResult
Asaas::fiscalInfo()->nbsCodes(array $query = []): AsaasPaginatedResult
Asaas::fiscalInfo()->operationIndicatorCodes(array $query = []): AsaasPaginatedResult
Asaas::fiscalInfo()->taxClassificationCodes(array $query = []): AsaasPaginatedResult
Asaas::fiscalInfo()->taxSituationCodes(array $query = []): AsaasPaginatedResult
Asaas::fiscalInfo()->configureNationalPortal(bool $enabled): AsaasResult

save() is a multipart/form-data POST. Pass $certificateFile (binary string or file resource) only when uploading an A1 digital certificate; the form-only call works the same way without it. email and simplesNacional are the only required fields on FiscalInfoRequest for a first save; re-saves accept any subset (FiscalInfoRequest is a partial-update DTO — omitted fields never reach the wire).

Server-side defaults. simplesNacional and culturalProjectsPromoter default to true on Asaas when the field is omitted from the body. The SDK deliberately omits them on partial updates so a re-save never silently overwrites a value the consumer set in a previous call. If you need stability across Asaas-side default changes, pin the value at every call site instead of relying on the omission semantics.

Date formats

Asaas expects two distinct string formats for date fields. Mismatch (T, Z, timezones) is rejected with HTTP 400 — the SDK does not parse DateTimeInterface for you; pass already-formatted strings.

DTO field(s) Format Example
CreatePaymentRequest::$dueDate, UpdatePaymentRequest::$dueDate YYYY-MM-DD '2026-04-01'
ReceivePaymentInCashRequest::$paymentDate YYYY-MM-DD '2026-04-01'
AccountRequest::$birthDate, CommercialInfoRequest::$birthDate YYYY-MM-DD '1985-06-15'
CreateInvoiceRequest::$effectiveDate, UpdateInvoiceRequest::$effectiveDate YYYY-MM-DD '2026-04-01'
TransferRequest::$scheduleDate, PayQrCodeRequest::$scheduleDate YYYY-MM-DD '2026-04-01'
BankAccount::$ownerBirthDate (via TransferRequest::$bankAccount) YYYY-MM-DD '1990-05-15'
DecodeQrCodeRequest::$expectedPaymentDate YYYY-MM-DD '2026-04-01'
AuthorizationRequest::$startDate, AuthorizationRequest::$finishDate YYYY-MM-DD '2026-04-01'
CreateBillPaymentRequest::$dueDate YYYY-MM-DD '2026-04-01'
CreateAccessTokenRequest::$expirationDate, UpdateAccessTokenRequest::$expirationDate YYYY-MM-DD HH:MM:SS '2026-12-31 23:59:59'
StaticQrCodeRequest::$expirationDate YYYY-MM-DD HH:MM:SS '2026-12-31 23:59:59'

Available filters per list endpoint

Every list($query = []) method accepts an associative array of filters. Below is the canonical set of filters accepted by the Asaas API, per resource. Pass any combination as $query; the SDK forwards them verbatim (offset / limit are wired by paginate() and rarely need to be set manually).

payments()->list() / leanPayments()->list()GET /v3/payments and /v3/lean/payments

Filter Type Notes
customer string Customer ID
customerGroupName string Group name
billingType string BOLETO, PIX, CREDIT_CARD, UNDEFINED
status string Payment status
subscription string Subscription ID
installment string Installment ID
externalReference string Integrator-controlled reference
paymentDate string YYYY-MM-DD Exact payment date
invoiceStatus string Linked invoice status
estimatedCreditDate string YYYY-MM-DD Expected credit date
pixQrCodeId string Linked QR code ID
anticipated bool Already-anticipated flag
anticipable bool Eligible for anticipation
dateCreated[ge], dateCreated[le] string YYYY-MM-DD Created-at range
paymentDate[ge], paymentDate[le] string YYYY-MM-DD Payment date range
estimatedCreditDate[ge], estimatedCreditDate[le] string YYYY-MM-DD Estimated credit range
dueDate[ge], dueDate[le] string YYYY-MM-DD Due-date range
user string Created-by user ID
checkoutSession string Checkout session ID

payments()->listSplitsPaid() / listSplitsReceived()

Filter Type Notes
paymentId string Source payment
status string Split status
paymentConfirmedDate[ge], paymentConfirmedDate[le] string YYYY-MM-DD Confirmation range
creditDate[ge], creditDate[le] string YYYY-MM-DD Credit range

accounts()->list()GET /v3/accounts

Filter Type Notes
cpfCnpj string CPF/CNPJ exact match
email string Account email
name string Account name
walletId string Wallet ID

invoices()->list()GET /v3/invoices

Filter Type Notes
effectiveDate[Ge], effectiveDate[Le] string YYYY-MM-DD Effective-date range (capitalised in spec)
payment string Payment ID
installment string Installment ID
externalReference string Reference
status string Invoice status
customer string Customer ID

transfers()->list()GET /v3/transfers

Filter Type Notes
dateCreated[ge], dateCreated[le] string YYYY-MM-DD Created-at range
transferDate[ge], transferDate[le] string YYYY-MM-DD Transfer-date range
type string PIX, TED, INTERNAL

pix()->listKeys()GET /v3/pix/addressKeys

Filter Type Notes
status string Key status
statusList string Comma-separated status list

pixTransactions()->list()GET /v3/pix/transactions

Filter Type Notes
status string Transaction status
type string Transaction type
endToEndIdentifier string EndToEndId

pixTransactions()->listRecurrings()GET /v3/pix/transactions/recurrings

Filter Type Notes
status string Status
value float Exact value
searchText string Free-text search

pixAutomatic()->listAuthorizations()GET /v3/pix/automatic/authorizations

Filter Type Notes
status string Authorization status
customerId string Customer ID

pixAutomatic()->listPaymentInstructions()GET /v3/pix/automatic/paymentInstructions

Filter Type Notes
authorizationId string Source authorization
customerId string Customer ID
paymentId string Linked payment
status string Instruction status

statements()->list()GET /v3/financialTransactions

Filter Type Notes
startDate, finishDate string YYYY-MM-DD Inclusive range
order string asc / desc

billPayments()->list() and webhooks()->list() only support offset / limit. fiscalInfo() lookup endpoints (federalServiceCodes, nbsCodes, services, taxClassificationCodes, taxSituationCodes, operationIndicatorCodes) accept code / description / codeDescription / taxSituationCode depending on the endpoint.

Sensitive data in debug output

Objects that hold secrets — your API key, card numbers, CVVs, CPF/CNPJ, bank account numbers, holder e-mail and phone — redact themselves before anything can print them. This covers the whole debugging surface:

Path Result
var_dump(), print_r() redacted via __debugInfo()
dump(), dd(), Ignition / Flare error pages redacted via a VarDumper caster
json_encode(), (string) redacted via jsonSerialize() / __toString()
serialize() throws on request DTOs, AsaasClient, AsaasConnector and WebhookVerifier — secrets must never reach a queue, cache or session (results are exempt, see below). Transport failures do not refuse it, and they hold the failed request in getPrevious(): serializing one writes your API key into the payload
stack traces redacted via #[SensitiveParameter]
var_export() not redacted — the function ignores __debugInfo(); never point it at a client, a DTO, a transport failure or a webhook verifier. Results and RawResponse are safe: they hold no object that reaches your API key
$card = new CreditCard('JOHN DOE', '4111111111111111', '12', '2030', '737');

dd($card);
// OwnerPro\Asaas\Support\DTO\CreditCard {
//   holderName: "JOHN DOE"
//   number: "********1111"
//   ccv: "***"
// }

dd(AsaasClient::for(apiKey: 'my-secret-key'));
// OwnerPro\Asaas\AsaasClient { resources: array:14 [...] }   // no API key, no headers

The mask is a constant width, not one asterisk per hidden character — the length of a secret is not published either.

Redaction never touches the wire — toArray() still returns the real values, so the payload Asaas receives is unaffected. On the way back it is also view-only, with one exception: see $result->errors below.

Secrets Asaas sends back

Five response fields carry a credential, and a result object exposes the response body on a public property:

Field Returned by
apiKey accounts()->create() — the subaccount key, shown once
accessToken the accounts()->…AccessToken…() endpoints
authToken webhooks()->list() / get() — the webhook shared secret
creditCardToken the card tokenization endpoints
username fiscalInfo()->recover() / save() — the municipal-portal login

The first four grant the same authority as the value they stand in for. username is half a credential rather than a whole one, and is on the list because the request side already masks it — redacting it on the way out and printing it on the way back is not a line worth drawing.

AsaasResult and AsaasPaginatedResult scrub those field names — at any nesting depth, and in every row of a page — before print_r(), var_dump(), dump(), dd(), json_encode() or an error page can print them. The json_encode() path is the one that reaches a log file: Log::info('created', ['result' => $result]) hands the result to Monolog, which encodes its context. RawResponse scrubs both the body and the headers it shows while debugging:

$result = $asaas->accounts()->create([...]);

dd($result);
// data: array:3 [ "id" => "acc_1", "walletId" => "w_1", "apiKey" => "***" ]

$result->data['apiKey'];      // the real key — store it, it is shown once
$result->response->body();    // the exact bytes Asaas sent, unredacted

$result->errors is the exception to all of the above: there the value itself is replaced, not the view of it. When Asaas answers a failure with something other than the canonical error envelope, the SDK synthesizes a row whose description is the response body — and a rejected accounts()->create() answers with the subaccount payload, key included. A description is a free-text string, so nothing downstream can recognise a credential inside it at print time; the scrub therefore happens when the row is built, and $result->errors[0]['description'] holds *** from then on. Reach for $result->response->body() if you need what Asaas actually sent. Code matching on description text should not expect the raw body back.

Two things this does not do, by design:

  • serialize() is allowed on results. Unlike AsaasClient and the request DTOs, results are legitimately cached and queued, so they do not refuse it — and a serialized result of accounts()->create() carries the subaccount key in clear. Persist the fields you need, not the result.
  • $result->data is a plain array. Log::info('created', $result->data) logs the real key: nothing can intercept an array you pass on yourself. Reach for $result->data['apiKey'] to store the key, never to print it. Unlike errors, data is redacted only in the debug views — the array itself always holds the real values.

dump() / dd() coverage needs no wiring at all. The caster is installed by bootstrap/redaction.php, which Composer includes from vendor/autoload.php — before your framework boots, before any code of yours runs, and it is a no-op when symfony/var-dumper is not installed.

Why an autoloaded file and not the service provider. Symfony's AbstractCloner copies the caster list in its constructor, and Laravel builds the cloner behind dump()/dd() while service providers are still registering — before any boot() runs. A caster installed from a provider would always arrive too late and the dump would print the live key. Loading it with the autoloader is the only placement early enough to be unconditional.

Custom classes join in by implementing OwnerPro\Asaas\Support\Redactable and returning the safe view from __debugInfo() — the caster is keyed on the interface, so no further wiring is needed. The MasksSensitiveData trait supplies mask(), __toString(), jsonSerialize() and the serialization guards.

Custom Connector

All resources depend on the Connector interface (OwnerPro\Asaas\Support\Connector) rather than the concrete AsaasConnector. You can provide your own implementation for testing, logging, caching, or any custom behavior.

The Connector interface defines seven methods: the four HTTP verbs (get, post, put, delete), postMultipart for file uploads, plus paginate and all. The PaginatesResults trait provides default implementations of paginate() and all() built on top of get(), so you only need to implement the five HTTP methods:

use OwnerPro\Asaas\AsaasClient;
use OwnerPro\Asaas\Support\Connector;
use OwnerPro\Asaas\Support\PaginatesResults;

class MyLoggingConnector implements Connector
{
    use PaginatesResults;

    public function get(string $path, array $query = []): AsaasResult { /* ... */ }
    public function post(string $path, array $data = []): AsaasResult { /* ... */ }
    public function put(string $path, array $data = []): AsaasResult { /* ... */ }
    public function delete(string $path): AsaasResult { /* ... */ }
    public function postMultipart(string $path, array $data, array $files = []): AsaasResult { /* ... */ }
    // paginate() and all() are provided by the trait
}

$client = new AsaasClient(new MyLoggingConnector());

Testing your integration

The SDK ships with a first-class test fake. Use it to assert outgoing requests and stub responses without touching the network.

Construction

use OwnerPro\Asaas\AsaasClient;
use Illuminate\Support\Facades\Http;

// Empty fake — every unmatched request throws NoMatchingStubException (loud).
$asaas = AsaasClient::fake();

// Stub via constructor.
$asaas = AsaasClient::fake([
    'payments'      => ['id' => 'pay_123', 'status' => 'PENDING'],
    'payments/*'    => ['id' => 'pay_123'],
    'pix/qrCodes/*' => Http::sequence()->push([...])->push([...]),
]);

// Or fluent (chainable).
$asaas = AsaasClient::fake()
    ->stub('webhooks', ['id' => 'wh_1'])
    ->stubError('payments', status: 400, body: ['errors' => [['code' => 'invalid_value']]])
    ->stubException('payments/*', new \Illuminate\Http\Client\ConnectionException('timeout'));

Simulating transport failures

The fake mirrors the typed transport exception contract — stub failures per phase. The stubs raise a production-shaped Guzzle ConnectException carrying the real cURL errno, so they flow through the same classifier as live traffic:

use OwnerPro\Asaas\Support\IndeterminateResultException;
use OwnerPro\Asaas\Support\RequestNotDeliveredException;

$asaas = AsaasClient::fake()
    ->stubRequestNotDelivered('transfers', phase: 'dns')       // 'connect'|'dns'|'tls'
    ->stubIndeterminateResult('pix/qrCodes/pay', phase: 'read'); // 'body'|'read'|'redirect'|'server'|'timeout'|'transfer'|null

try {
    $asaas->transfers()->create([...]);
} catch (RequestNotDeliveredException $e) {
    // safe-retry branch under test
}

phase: 'body', phase: 'server', phase: 'timeout' and phase: 'redirect' stub a response instead of a cURL error — an unreadable 200, a 502, a 408 and a 302 respectively — because that is what production sees in those cases. phase: null stubs the classifier's default branch: a failure whose point could not be proven, which production reaches on a cURL errno outside the map.

To pin the classifier itself rather than the fake's phase table, stub the errno and let it decide:

use OwnerPro\Asaas\Support\IndeterminateResultException;

$asaas = AsaasClient::fake()->stubTransportErrno('transfers', errno: 52); // CURLE_GOT_NOTHING

try {
    $asaas->transfers()->create([...]);
} catch (IndeterminateResultException $e) {
    $e->phase; // 'read' — decided by the classifier, not by the stub
}

Any errno works, mapped (6, 7, 18, 28, 35, 52, 55, 56, 58, 60, 92) or not, so a test can cover every line of the mapping and its default through the public API.

A request that fails in transport is still a request that was sent: the cURL-error stubs record it (with a null response), so assertSent/assertSentCount see it and assertNotSent/assertNothingSent correctly fail. Raw stubException() bypasses that recording — reach for the phase stubs when the test asserts on what was sent.

stubException() also does not guarantee the exception you passed is the one your test catches. It is raised where a real transport failure would be, so the connector classifies it on the way out: a ConnectionException surfaces as RequestNotDeliveredException or IndeterminateResultException exactly as production traffic does. Only an exception the connector does not handle travels through untouched. Assert on the typed exception, or use the phase stubs, which say what they produce.

Closure stubs receive (Illuminate\Http\Client\Request $request, array $options) from Laravel's HTTP client and must return one of: a PromiseInterface (what Http::response() builds), an Illuminate\Http\Client\Response, an array body, or a string body. The $options parameter can be ignored when not needed:

$asaas->stub('payments/*', function (\Illuminate\Http\Client\Request $r): \GuzzleHttp\Promise\PromiseInterface {
    return Http::response(['id' => 'pay_'.uniqid()], 201);
});

Pass response headers via the fourth stubError() parameter when simulating retry/throttling responses. A stubbed 429 throws RateLimitedException on the way out, as production does — the request stays visible to the assertion helpers either way:

$asaas->stubError('payments', status: 429, body: [], headers: ['Retry-After' => '30']);

Path patterns

Stub paths are relative to the API base URL — no https://..., no /v3 prefix. Patterns use Str::is semantics where * is a glob. A pattern starting with http:// or https:// throws InvalidArgumentException, in stubs and in assertions alike: prefixing the base URL onto an already-absolute pattern yields a glob that matches nothing, which would silently turn assertNotSent() — and assertSent(..., times: 0) — into assertions that can never fail.

A trailing * is appended automatically when the pattern doesn't already end in one, so path-only patterns also match URLs that carry query strings or extra segments. 'payments' matches /payments, /payments?limit=10, /payments/pay_123. Use an explicit suffix when you need a tighter match — e.g. 'payments/*' matches only individual-payment paths, never the index endpoint.

Patterns are prefix-globs, not segment-bounded: 'payments' also matches sibling-prefix paths like /paymentsBook if such an endpoint existed. Stick to either explicit segments ('payments/*') or full paths when adjacent endpoints share a prefix.

When multiple stubs match the same URL, the first registered wins. Construct ordering matters: list specific stubs before broader ones if both could match.

Pagination

['data' => [...]] infers hasMore=false and totalCount=count($data) automatically (and fills sensible defaults for object, limit, offset). Providing either hasMore or totalCount disables inference entirely — supply all paging fields you care about yourself in that case. To drive multi-page flows through ->all():

$asaas->stubPages('payments', [
    ['data' => [['id' => 'a']]],
    ['data' => [['id' => 'b']]],
]);

stubPages() infers the envelope across the whole sequence: every page but the last gets hasMore=true, and totalCount counts the rows of all pages, so ->all() walks the sequence to the end. A page keeps every key it declares — totalCount, limit, offset, anything extra.

hasMore is the one key with a caveat. On a non-final page it is only a default, so a page declaring hasMore: false keeps it and the walk ends there — that is how you pin "the walk stops when the server says it does". On the final page it is overridden to false, because a last page promising another one sends the walk off the end of the sequence and into Laravel's raw response sequence is empty. One consequence worth knowing: a realistic fixture pasted into several slots carries its own hasMore: false everywhere and stops the walk after the first page. stubPages() needs at least one page; an empty list is rejected.

A page carrying no rows is rejected too, unless it is the whole sequence. There is no position where one is servable: before the end it is served with hasMore=true, which is the PAGINATION_TRUNCATED contradiction — no rows, more promised — so ->all() stops there and the pages behind it never reach the caller; at the end it is never requested at all, because the page carrying the final row must still say hasMore=true while the inferred totalCount already says the walk is complete, which ->all() reports as PAGINATION_INCONSISTENT. Drop the empty page and let the last page carrying rows end the sequence. On its own it is the no-results fixture and is served as written.

totalCount has a coherence rule of its own. A non-final page may not declare a count the walk has already delivered by the time it ends — ->all() stops at the declared count and reports PAGINATION_INCONSISTENT there, so the pages behind it would never reach the caller. Declare the count of the whole walk, or leave the key out and let it be inferred; a non-final totalCount: 0 is fine, since that is what an envelope omitting the key reports.

A single stub() is a different contract: it is one response replayed for every matching request, so it describes one page only. Declaring hasMore: true on one keeps ->list() reporting hasMore, while ->all() receives the empty terminal page once it asks for a different offset — otherwise the walk would re-request the same rows forever. The page it describes is the offset it declares (0 when it declares none), so a stub written as page two ('offset' => 10) answers ->list(['offset' => 10]) with its own body; a request carrying no offset at all gets the body too. Model real multi-page walks with stubPages().

Patterns are relative to the base URL, which already ends in /v3. An absolute URL, a leading v3/ segment (in any casing) and whitespace inside the pattern are all rejected: each one would build a pattern that matches nothing, and a pattern that matches nothing makes assertNotSent() pass no matter what the code under test did. Surrounding whitespace is trimmed, so ' payments' is fine.

Assertions

use Illuminate\Http\Client\Request;

$asaas->assertSent('payments', fn (Request $r) => $r['value'] === 100.0);
$asaas->assertSent('payments/*', times: 2);     // also: exact-N count for a pattern
$asaas->assertNotSent('webhooks/*');
$asaas->assertSentCount(3);                      // total across all patterns
$asaas->assertNothingSent();

$asaas->assertSentInOrder([
    'accounts',
    'accounts/*',
    'accounts/*/accessTokens',
]);

// Inspect raw recordings.
$asaas->recorded();             // list<array{Request, ?Response}>
$asaas->recorded('payments');

The first argument is either a URL pattern or a predicate — never two predicates. assertSent(fn ($r) => ..., fn ($r) => ...) throws InvalidArgumentException: only one of them could ever be applied, which would make the assertion impossible to fail. Combine the conditions into a single closure instead.

A matcher closure may accept the response as a second parameter, but it must be typed as nullable — a request that failed in transport is recorded with a null response (see Simulating transport failures), and a non-nullable Response hint raises a TypeError as soon as one is in the stream:

use Illuminate\Http\Client\Response;

$asaas->assertSent('payments', fn (Request $r, ?Response $response): bool => $response?->status() === 201);

Recordings accumulate for the lifetime of the fake — there is no flush() or forget(). Build a fresh AsaasClient::fake() per test (the recommended pattern) to start with a clean slate.

Loud catch-all

Unmatched requests throw OwnerPro\Asaas\Testing\NoMatchingStubException with the request method, URL, and the list of registered patterns. This catches forgotten stubs early instead of letting tests pass against empty 200 responses.

Laravel container

The service provider binds the real AsaasClient as a singleton and registers an alias so AsaasClientContract resolves to the same instance. Type-hint application code against AsaasClientContract to make the seam swappable:

public function __construct(private readonly AsaasClientContract $asaas) {}

Swap the fake in tests by overriding the contract binding:

$this->app->instance(\OwnerPro\Asaas\Contracts\AsaasClientContract::class, AsaasClient::fake([...]));

The Asaas facade resolves through AsaasClientContract too, so the swap above covers facade calls (Asaas::payments()->...) as well as injected code — one override, both styles.

FakeAsaasClient does not extend AsaasClient (the production class is final), so code that type-hints the concrete AsaasClient cannot receive the fake from the container. Migrate those constructor signatures to AsaasClientContract to make them testable.

License

MIT