meilleursbiens/laravel-proabono-webhook

Handle ProAbono webhooks in a Laravel application, built on spatie/laravel-webhook-client.

Maintainers

Package info

github.com/cldt-fr/laravel-proabono-webhook

pkg:composer/meilleursbiens/laravel-proabono-webhook

Transparency log

Statistics

Installs: 17

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-26 07:49 UTC

This package is auto-updated.

Last update: 2026-08-26 07:50:00 UTC


README

Tests

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 its 200 OK immediately.

Activating the webhook in ProAbono

ProAbono will not send you anything until the endpoint has been validated.

  1. In the back office, go to Integration → My webhooks → New webhook.

  2. Set the notification URL to https://your-app.test/proabono/webhook and tick the events you care about.

  3. Click Send verification code. ProAbono POSTs a one-time code to your URL.

  4. 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.
    
  5. 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 unit4491 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

License

The MIT License (MIT). Please see License File for more information.