singraworks / laravel-asaas
Typed Laravel SDK for the Asaas payments API.
Requires
- php: ^8.4
- illuminate/bus: ^12.0 || ^13.0
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/log: ^12.0 || ^13.0
- illuminate/queue: ^12.0 || ^13.0
- illuminate/routing: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- singraworks/br-fields: ^1.0
- spatie/laravel-data: ^4.23
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/pao: ^1.1.5
- laravel/pint: ^1.32.1
- laravel/vet: ^0.2.1
- orchestra/testbench: ^10.12 || ^11.0
- pestphp/pest: ^4.7.8 || ^5.0
- pestphp/pest-plugin-type-coverage: ^4.0.4 || ^5.0
- phpstan/phpstan: ^2.3.1
- rector/rector: ^2.7
- roave/security-advisories: dev-master
Suggests
None
Provides
None
Conflicts
- brianium/paratest: 7.26.1
Replaces
None
This package is not auto-updated.
Last update: 2026-10-12 00:17:15 UTC
README
A typed Laravel SDK for the Asaas payments API. Requests and responses are objects, not arrays, so PHPStan checks a call against the API before it runs, and every failure is an exception you can catch by name.
Status: early. Customers, Payments (boleto and Pix), Subscriptions, Bills (paying boletos) and Webhooks exist so far; the rest of the API follows the same shape.
Install
composer require singraworks/laravel-asaas
Requires PHP 8.4+ and Laravel 12+. The service provider is discovered automatically.
Set the key in .env. Asaas keys carry their environment in the prefix ($aact_prod_ or $aact_hmlg_), and the package follows it, so there is nothing else to set:
ASAAS_API_KEY=$aact_hmlg_...
Asaas requires a User-Agent on accounts created after June 2024; the package sends APP_NAME/1.0 unless ASAAS_USER_AGENT says otherwise. To change timeouts, retries, or to name extra accounts, publish the config:
php artisan vendor:publish --tag=asaas-config
Use
One method per Asaas resource, one object per request and response.
use SingraWorks\BrFields\Documents\Cpf; use SingraWorks\LaravelAsaas\Data\CreateCustomer; use SingraWorks\LaravelAsaas\Data\CustomerFilters; use SingraWorks\LaravelAsaas\Data\UpdateCustomer; use SingraWorks\LaravelAsaas\Facades\Asaas; $customer = Asaas::customers()->create(new CreateCustomer( name: 'Ana Souza', cpfCnpj: Cpf::from('249.715.637-92'), email: 'ana@example.com', )); $customer->id; // 'cus_000005401844' $customer->personType; // PersonType::Individual $customer->dateCreated; // DateTimeImmutable $customer->cpfCnpj->formatted(); // '249.715.637-92' $customer->cpfCnpj->redacted(); // '***.715.637-**' Asaas::customers()->find($customer->id); Asaas::customers()->update($customer->id, new UpdateCustomer(mobilePhone: '11999999999')); Asaas::customers()->delete($customer->id); Asaas::customers()->restore($customer->id);
A request object sends only the fields you set. UpdateCustomer leaves the rest untouched on the Asaas side, exactly as the API's PUT does.
Documents
cpfCnpj is a Cpf or Cnpj from singraworks/br-fields, both ways. Building one checks the number, so an invalid document throws InvalidDocument where you can still tell the person what is wrong, and never reaches Asaas; on the wire it travels as bare digits whatever punctuation it came with. Document::from() picks the kind by length when a field may hold either. For input that arrives through a request, singraworks/laravel-br-fields has the matching validation rules with translated messages.
use SingraWorks\LaravelAsaas\Support\Document; $request->validate(['document' => ['required', new CpfOrCnpj]]); new CreateCustomer(name: $request->name, cpfCnpj: Document::from($request->document));
Payments
A payment is Asaas's word for a charge. Create one for boleto, Pix or Undefined (the customer picks on the invoice page); card data is not taken yet.
use SingraWorks\LaravelAsaas\Data\CreatePayment; use SingraWorks\LaravelAsaas\Data\Discount; use SingraWorks\LaravelAsaas\Data\ReceiveInCash; use SingraWorks\LaravelAsaas\Data\UpdatePayment; use SingraWorks\LaravelAsaas\Enums\BillingType; use SingraWorks\LaravelAsaas\Enums\ValueType; $payment = Asaas::payments()->create(new CreatePayment( customer: $customer->id, billingType: BillingType::Pix, value: 129.9, dueDate: new DateTimeImmutable('+7 days'), discount: new Discount(value: 10, type: ValueType::Percentage, dueDateLimitDays: 0), )); $payment->status; // PaymentStatus::Pending $payment->invoiceUrl; // the page the customer pays on Asaas::payments()->pixQrCode($payment->id)->payload; // "copia e cola" Asaas::payments()->identificationField($payment->id)->barCode; // boleto only Asaas::payments()->status($payment->id); Asaas::payments()->update($payment->id, new UpdatePayment(billingType: BillingType::Pix, value: 150, dueDate: $later)); Asaas::payments()->receiveInCash($payment->id, new ReceiveInCash(paymentDate: $today, value: 150)); Asaas::payments()->refund($payment->id); // whole; pass a RefundPayment for part of it Asaas::payments()->delete($payment->id);
UpdatePayment always carries billingType, value and dueDate, because Asaas asks for them on every update. PaymentFilters turns dueDateFrom/dueDateTo and the other range pairs into Asaas's dueDate[ge]/dueDate[le].
Subscriptions
A subscription generates one payment per cycle, by boleto, Pix or Undefined; card subscriptions are not taken yet, though a CreditCard one made here is paid through the invoice link. Asaas generates each payment 40 days before it is due (14 or 7, by account setting), so a first due date inside that window gets its payment at once, and nextDueDate moves a cycle on.
use SingraWorks\LaravelAsaas\Data\CreateSubscription; use SingraWorks\LaravelAsaas\Data\UpdateSubscription; use SingraWorks\LaravelAsaas\Enums\SubscriptionCycle; use SingraWorks\LaravelAsaas\Enums\SubscriptionStatus; $subscription = Asaas::subscriptions()->create(new CreateSubscription( customer: $customer->id, billingType: BillingType::Pix, value: 49.90, nextDueDate: new DateTimeImmutable('+7 days'), cycle: SubscriptionCycle::Monthly, maxPayments: 12, // or endDate:, or neither to run until removed )); Asaas::subscriptions()->payments($subscription->id); // the payments it generated, one page Asaas::subscriptions()->update($subscription->id, new UpdateSubscription(value: 59.90, updatePendingPayments: true)); Asaas::subscriptions()->update($subscription->id, new UpdateSubscription(status: SubscriptionStatus::Inactive)); Asaas::subscriptions()->delete($subscription->id);
UpdateSubscription sends only what you set. A change reaches the payments generated from then on; updatePendingPayments: true carries it to the pending ones already generated. delete() ends the subscription and removes its pending and overdue payments; status: SubscriptionStatus::Inactive pauses it and keeps them. SubscriptionFilters leaves removed subscriptions out unless includeDeleted or deletedOnly asks for them.
Dates
Asaas keeps every date in São Paulo time and sends none with a zone, so the package parses what comes back in AsaasClient::TIMEZONE (America/Sao_Paulo); setTimezone() on the result if you want another one. On the way out a date is sent as the calendar day the object says it is, so an explicit new DateTimeImmutable('2026-10-15') means October 15 whatever your app's zone. A relative date is the one to watch: Asaas checks "not before today" and "not after today" against its own clock, so build it in Asaas's zone:
$today = new DateTimeImmutable('today', new DateTimeZone(AsaasClient::TIMEZONE));
Bills
A bill is a boleto the account pays out of its own balance, Asaas's "Pague Contas".1 Simulate first: it reads the digitable line back and quotes the fee and the earliest date it can be scheduled for.
use SingraWorks\LaravelAsaas\Data\CreateBill; use SingraWorks\LaravelAsaas\Enums\BillStatus; $simulation = Asaas::bills()->simulate($digitableLine); $simulation->bankSlipInfo->value; // what the boleto is worth today $simulation->bankSlipInfo->beneficiaryCpfCnpj; // Cpf|Cnpj $simulation->fee; if (Asaas::finance()->balance() >= $simulation->bankSlipInfo->value + $simulation->fee) { $bill = Asaas::bills()->pay(new CreateBill( identificationField: $digitableLine, scheduleDate: new DateTimeImmutable('+2 days'), // omit to pay today; an overdue boleto cannot be scheduled description: 'Energia 10/2026', externalReference: 'ap-4411', )); } $bill->status->isFinal(); // false while Pending, BankProcessing or in risk analysis Asaas::bills()->cancel($bill->id); // while $bill->canBeCancelled Asaas::bills()->list(BillStatus::Failed)->items[0]->failReasons;
pay() and create() are the same call. Asaas accepting the request is not the payment: follow the BILL_* webhook events, where $webhook->bill() carries the bill, to learn when it is Paid, or Failed with its failReasons. value and dueDate are for boletos that do not carry them, such as card statements; discount, interest and fine are amounts, and utility bills and taxes refuse a discount.
Lists
list() returns one page with Asaas's envelope around it. cursor() returns a LazyCollection that fetches the next page only when the previous one has been consumed.
$page = Asaas::customers()->list(new CustomerFilters(groupName: 'vip'), limit: 50, offset: 100); $page->items; // list<Customer> $page->totalCount; // across every page $page->hasMore; foreach (Asaas::customers()->cursor(new CustomerFilters(email: 'ana@example.com')) as $customer) { // ... }
Other accounts
withApiKey() takes any key and follows its prefix; pass an Environment for a key without one. for() reaches an account by name. Both return a new client and leave the default one alone.
use SingraWorks\LaravelAsaas\Environment; Asaas::withApiKey($tenant->asaas_key)->customers()->find($id); Asaas::withApiKey('legacy-key', Environment::Production)->customers()->find($id); Asaas::for('tenant-a')->customers()->find($id);
Names come from asaas.accounts in the config by default. When the accounts live in your database, write an AccountResolver and name it in asaas.account_resolver; for(), the webhook endpoint and asaas:webhook --account all read from it:
use SingraWorks\LaravelAsaas\Account; use SingraWorks\LaravelAsaas\AccountResolver; final class TenantAccounts implements AccountResolver { public function find(string $name): ?Account { $tenant = Tenant::where('slug', $name)->first(); return $tenant ? new Account($tenant->asaas_key, webhookToken: $tenant->webhook_secret) : null; } }
// config/asaas.php 'account_resolver' => App\Billing\TenantAccounts::class,
Webhooks
Asaas tells your application what happened (PAYMENT_RECEIVED, PAYMENT_OVERDUE, …) by posting to a URL you register. The package serves that URL and turns each post into one Laravel event.
Set the shared secret, and the route exists; without it there is no endpoint, since one without a secret is open to anyone:
ASAAS_WEBHOOK_TOKEN=whsec_... # 32 to 255 characters, no whitespace
Every event is stored before Asaas gets its answer, so publish and run the migration for that table:
php artisan vendor:publish --tag=asaas-migrations php artisan migrate
Then register the endpoint on Asaas, with the same secret:
php artisan asaas:webhook # registers route('asaas.webhook') for every PAYMENT_*, SUBSCRIPTION_* and BILL_* event
php artisan asaas:webhook --url=https://abc.ngrok.app/asaas/webhook --event=PAYMENT_RECEIVED --event=PAYMENT_OVERDUE
It asks for an email, or reads ASAAS_WEBHOOK_EMAIL; Asaas writes there when deliveries fail. Run it again after a change, or after Asaas pauses the webhook, and it updates the one it finds for that URL instead of creating another. The same can be done by hand through Asaas::webhooks()->create(new CreateWebhook(...)).
Then listen. Every accepted event dispatches WebhookReceived once, from a queued job:
use SingraWorks\LaravelAsaas\Enums\EventType; use SingraWorks\LaravelAsaas\Events\WebhookReceived; final class RecordPayment { public function handle(WebhookReceived $event): void { $webhook = $event->webhook; match ($webhook->type) { EventType::PaymentReceived => Order::markPaid($webhook->payment()), EventType::PaymentOverdue => Order::markOverdue($webhook->payment()->id), default => null, // an event you did not ask for, or one newer than this package }; } }
The endpoint checks the asaas-access-token header, stores the event in asaas_received_webhooks, answers 200, and queues a job that dispatches WebhookReceived (on asaas.webhooks.queue, or your default queue). A redelivery of an event already stored is answered 200 and ignored. Leave your listener synchronous: it already runs on a worker, and the job can only record whether listeners ran if they run inside it. A listener with ShouldQueue counts as processed the moment it is queued. $webhook->payment() maps the payload into a Payment only when you call it, $webhook->subscription() into a Subscription for the SUBSCRIPTION_* events and $webhook->bill() into a Bill for the BILL_* ones, so a field the package cannot read fails in your listener as UnexpectedPayload, never in the response to Asaas. $webhook->id is Asaas's event id, for your own idempotency ledger; $webhook->payload is the raw body. The payment in a delivery is the payment as it stood when Asaas sent it, not when the event happened: a PAYMENT_CREATED delivered after a quick delete already says deleted: true. Take the event type as what happened, and the payload as the latest state. type is null for an event name the package does not know yet, with the name kept in $webhook->event.
What arrived, and what failed
Each row in asaas_received_webhooks is one event: Asaas's event_id, the event name, the account label, the payload as it arrived, and a status. The status is pending until a worker takes it, processing while listeners run, processed once they all return, or failed with the exception that stopped it. attempts counts the runs. Once a row exists, nothing that happens after it, whether a listener throwing or a queue that is down, is sent back to Asaas: the row records it, and the exception is reported to your handler as usual.
use SingraWorks\LaravelAsaas\Enums\WebhookStatus; use SingraWorks\LaravelAsaas\Models\ReceivedWebhook; ReceivedWebhook::where('status', WebhookStatus::Failed)->count(); ReceivedWebhook::where('created_at', '>=', today())->count(); // what arrived today ReceivedWebhook::where('status', WebhookStatus::Pending) ->where('created_at', '<', now()->subHour())->get(); // queued, but no worker took them
Fix what failed, then run it again. A row named by id is retried in any state short of processed, so one left processing by a worker that died can be freed:
php artisan asaas:webhook:retry # every failed event php artisan asaas:webhook:retry 42 57 # these rows
Processed rows older than asaas.webhooks.prune_after_days (30; null keeps them all) go with model:prune. Pending and failed rows stay until handled:
Schedule::command('model:prune', ['--model' => [ReceivedWebhook::class]])->daily();
Refused deliveries
A delivery the endpoint refuses leaves no row, since until the token checks out it could come from anyone. It dispatches WebhookRejected instead, with a WebhookRejection reason, the URL's account label and the caller's IP. The caller still gets only the 401 or 400:
| Reason | What it usually means |
|---|---|
UnknownAccount |
The URL's label names no account the resolver knows, or there is no token for it |
MissingToken |
No asaas-access-token header; rarely Asaas |
WrongToken |
The secret on Asaas and the one here have drifted apart |
Malformed |
An authenticated body with no event id or name |
Asaas retries a refused delivery and pauses the webhook after 15 in a row, so WrongToken and UnknownAccount are worth an alert. Anyone can post to the endpoint, so throttle it:
use SingraWorks\LaravelAsaas\Events\WebhookRejected; use SingraWorks\LaravelAsaas\Enums\WebhookRejection; Event::listen(function (WebhookRejected $rejected): void { if (in_array($rejected->reason, [WebhookRejection::WrongToken, WebhookRejection::UnknownAccount], true)) { RateLimiter::attempt('asaas-rejected', 1, fn () => Ops::alert("Asaas webhook refused: {$rejected->reason->value}"), 3600); } });
Deliveries that never arrive
Nothing in the application sees a delivery that never reaches it: a webhook deleted on Asaas, disabled, missing its token, or with its queue paused after failures. asaas:webhook:check asks Asaas about the webhook for the route, or --url, and fails when there is none or when it is disabled, interrupted, penalized or tokenless, saying what to do for each. It also dispatches WebhookUnhealthy with the URL, the account, the webhook and the list of WebhookIssues, for an alert of your own. Schedule it:
Schedule::command('asaas:webhook:check')->hourly(); Schedule::command('asaas:webhook:check --account=acme')->hourly();
A paused queue keeps events for up to 14 days; php artisan asaas:webhook resumes it once the cause is fixed. For accounts kept in a database, loop over them yourself: Asaas::for($tenant->slug)->webhooks()->forUrl($url)?->issues() returns the same list, empty when all is well.
To test locally, point a sandbox webhook at the app through a tunnel such as ngrok or Cloudflare Tunnel. Asaas::webhooks() also lists, finds, updates and deletes webhooks, and removeBackoff() lifts the penalty after a run of failures.
Several accounts
The endpoint takes an optional label, /asaas/webhook/{account}, so one URL per account tells deliveries apart without any lookup. php artisan asaas:webhook --account=acme registers route('asaas.webhook', ['account' => 'acme']) on that account, with its own webhookToken when it has one and the shared token otherwise, and each event arrives with $webhook->account === 'acme'. A label the resolver does not know is answered 401. The same registration from code, say when a tenant signs up:
use SingraWorks\LaravelAsaas\Webhooks\WebhookRegistration; Asaas::for($tenant->slug)->webhooks()->register(new WebhookRegistration( url: route('asaas.webhook', ['account' => $tenant->slug]), email: $tenant->billing_email, authToken: $tenant->webhook_secret, name: config('app.name'), ));
register() finds the webhook Asaas already posts to that URL and updates it, or creates one, and returns which it did. The label is the only thing the package uses to tell accounts apart: Asaas offers no call that tells a key which account it belongs to, so $webhook->accountId, Asaas's own id for the account, is there to read but not to look up.
Errors
Every exception implements AsaasException. A failed response throws a RequestFailed subclass named after the status, carrying the Response:
| Status | Exception | Also |
|---|---|---|
| 400 | ValidationFailed |
errors() lists Asaas's code and description pairs |
| 401 | Unauthenticated |
|
| 403 | Forbidden |
a GET with a body also lands here, on Asaas's side |
| 404 | NotFound |
|
| 429 | RateLimited |
retryAfter() reads RateLimit-Reset, in seconds |
| 5xx | ServerError |
|
| other | UnexpectedResponse |
also a 2xx whose body the package cannot read; getPrevious() says why |
A dropped connection is retried as asaas.retry says; a failed response is not, so the caller decides.
Debugging
Set ASAAS_LOG_CHANNEL to a channel from config/logging.php and the package writes one debug line per request with the method, path, status and duration. A failed response, or a successful one the package cannot read, also carries the raw body:
asaas.DEBUG: Asaas GET customers/cus_1 404 {"method":"GET","path":"customers/cus_1","status":404,"duration_ms":212,"body":"{\"errors\":[...]}"}
The webhook endpoint writes a line per delivery too: Asaas webhook PAYMENT_RECEIVED stored (or duplicate) with the event id and account label, or Asaas webhook rejected: wrong_token with the reason, label and IP.
Every line is debug: the exception that follows is how the package says something went wrong, and the channel's level is where you decide how much of the trace to keep. Bodies hold customer data, so give the channel the retention that data deserves. The API key and request bodies are never logged.
Before any request leaves, InvalidApiKey reports a missing key, a key whose prefix contradicts the named environment, or a key with no known prefix and no environment given. UnknownAccount reports a for() name the AccountResolver does not know.
Testing your application
The package uses Laravel's HTTP client, so Http::fake() works as it does anywhere else:
Http::fake(['https://api-sandbox.asaas.com/v3/customers' => Http::response(['id' => 'cus_1', 'name' => 'Ana', 'dateCreated' => '2026-10-10'])]);
Against the sandbox itself, Asaas::sandbox() does what a customer or a bank would: confirmPayment($id) marks a payment paid, which is also how a sandbox account gets balance, and overduePayment($id) makes one overdue without waiting. It throws SandboxOnly on a production key, before any request.
Development
composer install composer test # the full gate: Rector and Pint dry runs, PHPStan, type coverage, code coverage composer lint # apply Rector and Pint fixes composer analyse # PHPStan only composer test:mutate # mutation testing composer test:e2e # against the real sandbox; needs ASAAS_SANDBOX_API_KEY composer ci:check # what CI runs
composer test:e2e calls the Asaas sandbox and stays out of composer test and CI, since its results are checked in the Asaas panel. Export ASAAS_SANDBOX_API_KEY with a $aact_hmlg_ key first (.env.e2e is gitignored for keeping it): without one the suite skips, and with a production key it refuses to run. Each test names what it creates E2E e2e-<timestamp> and deletes it when it finishes, except bills, which Asaas does not let anyone delete.
One e2e test receives real deliveries: it serves the package behind PHP's built-in server, opens a tunnel to it, registers that URL on the sandbox, creates and deletes a payment and a subscription (and pays a bill, given a boleto line, see below), and waits up to 90 seconds for their PAYMENT_*, SUBSCRIPTION_* and BILL_CREATED events to arrive, the payment's each stored and processed once. It needs cloudflared (no account) or ngrok (auth token configured) on the machine and skips otherwise; ASAAS_E2E_TUNNEL=cloudflared|ngrok picks one when both are present. It deletes its webhook when done, so a run never leaves the sandbox posting into the void.
The bill test pays a real sandbox boleto, so it needs one: export the digitable line of a sandbox boleto as ASAAS_E2E_BILL_IDENTIFICATION_FIELD; without it the test skips, and the delivery test leaves bills out. The test cancels the bill when Asaas still allows it; a bill that went through spends the line, and the next run needs a new one. It does not pass yet.1
Thresholds are in composer.json. Coverage needs pcov or Xdebug. CI runs the gate for every pairing of PHP 8.4 or 8.5 with Laravel 12 or 13, each against both the lowest and the highest dependency sets. Domain terms are in GLOSSARY.md.
License
MIT. See LICENSE. Maintained by Singra.
Footnotes
-
Bills are not yet verified against the sandbox. Paying a boleto there fails with
Boleto não registrado na rede bancária, for boletos issued through the API and from the sandbox panel alike, with or without waiting minutes for registration. The calls follow the API reference and pass the package's own tests; the question is with Asaas support (October 2026). ↩ ↩2