meilleursbiens / laravel-proabono-webhook
Handle ProAbono webhooks in a Laravel application, built on spatie/laravel-webhook-client.
Package info
github.com/cldt-fr/laravel-proabono-webhook
pkg:composer/meilleursbiens/laravel-proabono-webhook
Requires
- php: ^8.2
- illuminate/bus: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- spatie/laravel-webhook-client: ^3.7
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.0 || ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0 || ^5.0
README
ProAbono notifies your application of every billing event — a subscription starting, an invoice being paid, a card about to expire — by POSTing a webhook to a URL you own.
This package wires those notifications into a Laravel app. It is a thin, opinionated
layer on top of spatie/laravel-webhook-client
that adds what is specific to ProAbono:
- the signature validator matching ProAbono's scheme (which is not an HMAC of the body);
- the activation handshake, captured and surfaced through an Artisan command;
- the 44 documented triggers as a PHP enum;
- a typed payload reader over the real notification body — customer, subscription, offer, invoice, payment method;
- events and per-trigger jobs so your code never parses a raw payload.
Requirements
PHP 8.2+ and Laravel 12 or 13.
Laravel 11 is not supported: every 11.x release is affected by security advisories that were only ever fixed in 12.60+, so Composer's default policy refuses to install it at all.
Installation
composer require meilleursbiens/laravel-proabono-webhook
Publish the config file and the webhook_calls migration, then migrate:
php artisan vendor:publish --tag=proabono-webhook-config php artisan vendor:publish --tag=proabono-webhook-migrations php artisan migrate
Add your ProAbono secret key to .env:
PROABONO_WEBHOOK_SECRET=your-business-secret-key
You will find it in the ProAbono back office, under Integration. It is the same secret used to sign API calls — treat it as a credential.
That is all: the package registers POST /proabono/webhook for you. Change the path
with PROABONO_WEBHOOK_URL, or set proabono-webhook.route.enabled to false and
declare it yourself:
// routes/web.php Route::proAbonoWebhooks('billing/proabono');
The route is registered outside the web middleware group, so it has no session
and no CSRF verification — nothing to exclude, unlike a hand-rolled endpoint.
Webhook payloads are processed in a queued job. Use a real queue driver rather than
sync, so ProAbono gets its200 OKimmediately.
Activating the webhook in ProAbono
ProAbono will not send you anything until the endpoint has been validated.
-
In the back office, go to Integration → My webhooks → New webhook.
-
Set the notification URL to
https://your-app.test/proabono/webhookand tick the events you care about. -
Click Send verification code. ProAbono POSTs a one-time code to your URL.
-
Read the code back:
php artisan proabono:verification-code
INFO ProAbono verification code: ZXC-987 Type it back into the ProAbono back office to activate the webhook. -
Paste it into the back office. The webhook flips to Active.
The handshake is stored like any other call and is always accepted, even when you
filter triggers — so you can never lock yourself out of activation. If you would
rather forward the code somewhere (Slack, mail, a log), listen for
ProAbonoVerificationCodeReceived:
use MeilleursBiens\ProAbonoWebhook\Events\ProAbonoVerificationCodeReceived; Event::listen(function (ProAbonoVerificationCodeReceived $event) { Log::info("ProAbono verification code: {$event->code}"); });
For local development, expose your machine with php artisan serve behind
Expose or ngrok, and point the webhook at the public URL.
How the signature is verified
ProAbono does not sign the request body. Each call carries two headers:
| Header | Content |
|---|---|
x-proabono-key |
a random key, unique to this notification |
x-proabono-signature |
base64(sha256(x-proabono-key + your secret key)) |
Knowing the digest proves the sender knows your secret. ProAbonoSignatureValidator
recomputes it and compares in constant time; a call that fails is answered with a 500,
never stored, and fires spatie's InvalidWebhookSignatureEvent.
Because the signature does not cover the body, it authenticates the sender, not the payload. Always serve the endpoint over HTTPS.
While replaying captured payloads locally you can switch the check off — never in production:
PROABONO_WEBHOOK_VERIFY_SIGNATURE=false
Reacting to events
Per-trigger jobs
Map a trigger to a job in config/proabono-webhook.php. The job receives the stored
ProAbonoWebhookCall as its only constructor argument:
'jobs' => [ 'SubscriptionStarted' => \App\Jobs\ProAbono\HandleStartedSubscription::class, 'InvoiceDebitPaid' => \App\Jobs\ProAbono\HandlePaidInvoice::class, // '*' => \App\Jobs\ProAbono\HandleAnyEvent::class, ],
namespace App\Jobs\ProAbono; use Illuminate\Contracts\Queue\ShouldQueue; use MeilleursBiens\ProAbonoWebhook\Models\ProAbonoWebhookCall; class HandleStartedSubscription implements ShouldQueue { public function __construct(public ProAbonoWebhookCall $webhookCall) {} public function handle(): void { $notification = $this->webhookCall->notification(); $agent = Agent::where('proabono_customer_id', $notification->idCustomer())->first(); $agent?->activateSubscription($notification->idSubscription()); } }
Keys are matched case-insensitively, so subscription_started and SubscriptionStarted
both work.
Events
Every notification also fires two events.
A string event named after the trigger, receiving the webhook call:
use Illuminate\Support\Facades\Event; use MeilleursBiens\ProAbonoWebhook\Models\ProAbonoWebhookCall; Event::listen('proabono-webhooks::InvoiceDebitPaid', function (ProAbonoWebhookCall $call) { // ... });
And a class event for a single entry point:
use MeilleursBiens\ProAbonoWebhook\Events\ProAbonoWebhookReceived; use MeilleursBiens\ProAbonoWebhook\ProAbonoTrigger; Event::listen(function (ProAbonoWebhookReceived $event) { match ($event->trigger()) { ProAbonoTrigger::SubscriptionStarted => /* ... */, ProAbonoTrigger::SubscriptionTerminated => /* ... */, default => null, }; });
Filtering what you store
By default every notification is stored. Narrow it down when you only care about a few
triggers — anything else is answered 200 OK and dropped:
use MeilleursBiens\ProAbonoWebhook\ProAbonoTrigger; 'triggers' => [ ProAbonoTrigger::SubscriptionStarted, ProAbonoTrigger::SubscriptionTerminated, ProAbonoTrigger::InvoiceDebitPaid, ],
Reading a payload
ProAbonoWebhookCall::notification() returns a ProAbonoNotification: a read-only,
typed view over the JSON body.
Every notification shares the same envelope and always carries a Customer:
$notification = $webhookCall->notification(); $notification->id(); // 'trg_42' — ProAbono's id for this trigger $notification->trigger(); // ProAbonoTrigger::SubscriptionStarted|null $notification->triggerName(); // 'SubscriptionStarted' $notification->is(ProAbonoTrigger::SubscriptionStarted); // bool $notification->idBusiness(); // ?int $notification->idSegment(); // ?int $notification->referenceSegment(); // ?string $notification->dateTrigger(); // ?CarbonImmutable — when the event happened $notification->customer()?->reference(); // your own customer reference $notification->customer()?->email(); $notification->customer()?->status(); // 'Enabled', 'Suspended', …
Beyond that, each trigger carries the sub-objects its category implies:
| Sub-object | Present on | Accessors |
|---|---|---|
Customer |
every notification | id() reference() name() email() language() status() |
CustomerBuyer |
subscription events | same as Customer — the payer, when it differs from the consumer |
Offer |
subscription events | id() reference() name() stateLife() isVisible() isPriced() amountRecurrence() durationRecurrence() unitRecurrence() |
Subscription |
subscription events | id() status() state() isActive() dateStart() amountRecurrence() durationRecurrence() unitRecurrence() |
InvoiceDebit |
InvoiceDebit* events |
id() fullNumber() status() isPaid() state() dateIssue() datePayment() typePayment() amountSubtotal() amountTotal() |
InvoiceCredit |
InvoiceCreditIssued |
same, plus typeCredit() and reason() |
GatewayPermission |
charging and payment-method events | id() state() typePayment() typeGateway() nameDisplay() country() dateExpiration() isExpired() |
$notification->subscription()?->isActive(); // bool $notification->offer()?->reference(); // 'offer_sample' $notification->invoice()?->amountTotal(); // 4491 — debit or credit $notification->gatewayPermission()?->nameDisplay(); // '****-****-****-4242'
Shortcuts for the ids you match on most often:
$notification->idCustomer(); // Customer.Id $notification->referenceCustomer(); // Customer.ReferenceCustomer $notification->idSubscription(); // Subscription.Id $notification->idOffer(); // Offer.Id $notification->idInvoice(); // InvoiceDebit.Id, falling back to InvoiceCredit.Id
Anything not modelled stays reachable, and the untouched body is always kept in payload:
$notification->get('Subscription.StateSubscription'); // 'ActiveRunning' $notification->all(); // the raw body
A missing or unexpected value yields null rather than an exception, so a payload
change never breaks a queue worker mid-flight. An unknown trigger is not an error
either: trigger() returns null while triggerName() still gives you the raw
string, so a newly introduced ProAbono event is stored rather than dropped. Set
throw_on_unrecognized_payload to true to have such payloads surface through your
error tracker instead.
Amounts are integers in the smallest currency unit — 4491 means 44.91. The
currency itself is not part of the notification; read it from your offer or from the
ProAbono API.
A worked example
{
"Id": "trg_42",
"IdBusiness": 42,
"IdSegment": 42,
"ReferenceSegment": "sample",
"DateTrigger": "2026-08-26T07:39:55.01Z",
"TypeTrigger": "SubscriptionStarted",
"Customer": {
"Id": 42, "ReferenceCustomer": "customer_sample", "Name": "John Doe",
"Email": "john.doe@sample.com", "Language": "en", "Status": "Enabled"
},
"CustomerBuyer": { "Id": 1901, "ReferenceCustomer": "customer_buyer_sample", "…": "…" },
"Offer": {
"Id": 42, "ReferenceOffer": "offer_sample", "Name": "Sample offer",
"AmountRecurrence": 3900, "DurationRecurrence": 1, "UnitRecurrence": "Month"
},
"Subscription": {
"Id": 42, "Status": "Active", "StateSubscription": "ActiveRunning",
"DateStart": "2026-04-14T00:34:35.01Z",
"AmountRecurrence": 3900, "DurationRecurrence": 1, "UnitRecurrence": "Month"
}
}
Getting a sample for any trigger
The ProAbono back office serves a sample body for each trigger, which is where the
fixtures in tests/fixtures/samples/ come from:
GET https://via.proabono.com/Notification/Webhooks/Sample?TypeTrigger={Trigger}
Authorization: Basic base64(IdBusiness:ApiKey)
It answers {"TypeTrigger": "…", "Data": "<the body, JSON encoded>"}. ProAbonoNotification
unwraps that envelope on its own, so you can POST a sample straight to your endpoint
without unpacking it first.
Querying stored calls
use MeilleursBiens\ProAbonoWebhook\Models\ProAbonoWebhookCall; use MeilleursBiens\ProAbonoWebhook\ProAbonoTrigger; ProAbonoWebhookCall::forTrigger(ProAbonoTrigger::InvoiceDebitPaid)->latest()->get(); ProAbonoWebhookCall::verifications()->latest()->first(); ProAbonoWebhookCall::whereNotNull('exception')->get(); // calls whose job blew up
Prune old rows by scheduling model:prune in routes/console.php:
use Illuminate\Support\Facades\Schedule; use MeilleursBiens\ProAbonoWebhook\Models\ProAbonoWebhookCall; Schedule::command('model:prune', [ '--model' => [ProAbonoWebhookCall::class], ])->daily();
The window comes from proabono-webhook.delete_after_days (30 by default).
Retries and idempotency
ProAbono keeps re-sending a notification until it gets a response in the 200 range.
This package answers 200 as soon as the call is stored, so a retry means your app was
genuinely unreachable — but a retry can still arrive after your job has already run.
Make your handlers idempotent (guard on IdSubscription / IdInvoice plus the trigger,
or on the state you are about to write).
Supported triggers
The ProAbonoTrigger enum covers all 44 documented events.
| Category | Triggers |
|---|---|
| Customers | CustomerAdded, CustomerBillingAddressUpdated, CustomerSettingsPaymentUpdated, CustomerBillingSucceeded, CustomerBillingFailed, CustomerChargingSucceeded, CustomerChargingPending, CustomerChargingFailed, CustomerChargingAutoFailedNoPermission, CustomerChargingAutoFailedNoRetry, CustomerSuspended, CustomerEnabled, CustomerIsGreyListed |
| Subscriptions | SubscriptionStarted, SubscriptionRenewed, SubscriptionSuspendedCustomer, SubscriptionRestarted, SubscriptionSuspendedPaymentInfoMissing, SubscriptionSuspendedPaymentDue, SubscriptionTerminatedAtRenewal, SubscriptionTerminated, SubscriptionHistory, SubscriptionDeleted, SubscriptionUpdated, SubscriptionFeaturesUpdated, SubscriptionUpgraded, SubscriptionTerminatedForUpgrade, SubscriptionDateTermUpdated |
| Invoices | InvoiceDebitIssuedPaymentAuto, InvoiceDebitIssuedPaymentOffline, InvoiceDebitPaid, InvoiceDebitRefunded, InvoiceDebitCancelled, InvoiceDebitPaymentAutoFailed, InvoiceDebitPaymentAutoRequestedAuth, InvoiceDebitOverdue, InvoiceDebitDisputed, InvoiceDebitUncollectible, InvoiceCreditIssued |
| Payment methods | GatewayPermissionSoonExpired, GatewayPermissionExpired, GatewayPermissionDefective, GatewayPermissionInsufficientFunds, GatewayPermissionPaymentIssues |
ProAbonoTrigger::SubscriptionStarted->category(); // ProAbonoTriggerCategory::Subscription ProAbonoTriggerCategory::Invoice->triggers(); // all invoice triggers ProAbonoTrigger::tryFromLoose('subscriptionstarted'); // ProAbonoTrigger::SubscriptionStarted
Testing your integration
ProAbonoSignatureValidator::sign() forges a valid signature, so you can hit your own
endpoint from a test:
use MeilleursBiens\ProAbonoWebhook\SignatureValidator\ProAbonoSignatureValidator; $key = 'test-key'; $this->postJson('/proabono/webhook', ['TypeTrigger' => 'SubscriptionStarted'], [ 'x-proabono-key' => $key, 'x-proabono-signature' => ProAbonoSignatureValidator::sign($key, config('proabono-webhook.signing_secret')), ])->assertOk();
The package ships the back office's sample body for all 44 triggers in
tests/fixtures/samples/, and replays each of them through the endpoint in its own
suite. Run it with:
composer test
Reference
- ProAbono — Webhooks
- ProAbono — How to secure a webhook
- ProAbono — How to validate a webhook
- ProAbono — List of events notified by the webhooks
- spatie/laravel-webhook-client
License
The MIT License (MIT). Please see License File for more information.