PayKitBeta

Webhooks

This is a conceptual guide: prose, not a runnable console. For the live endpoints, code samples, and a test console, see the API reference.

Webhooks (§8)

What this is. PayKit POSTs a signed JSON event to a URL you own whenever something happens to a subscription: it activated, a renewal succeeded, a payment failed, access was suspended. Your app reacts (provision, revoke, notify) without polling us.

Why it matters. The response to POST /subscriptions tells you a subscription was created, not that it was paid for. Activation happens later, once the gateway confirms the charge and our worker commits the state change. The webhook is the durable, retried signal that the state actually changed, so treat your webhook handler (not the HTTP response you got when you started checkout) as the trigger for provisioning access.

It is four steps: register an endpoint, verify every delivery, respond fast and idempotently, then react to the events you care about.

1. Register an endpoint

The normal route is the dashboard: Developers → Webhooks → Add endpoint. Give it a URL and, optionally, a comma separated list of event types (leave it blank to receive everything).

The API equivalent needs webhooks.manage. From a dashboard session that also means a recent sign-in (step-up). A management API key carries webhooks.manage too, and API keys are exempt from step-up, so a management key can register, rotate and disable endpoints. Store it accordingly: anyone holding it can point your event stream at a URL of their choosing.

POST /v1/clients/{clientId}/webhook-endpoints
Authorization: Bearer <caller token>          # webhooks.manage, step-up
Idempotency-Key: <uuid>
Content-Type: application/json

{ "url": "https://yourapp.com/webhooks/paykit", "eventTypes": [] }
{
  "endpoint": { "id": "9f2c…", "url": "https://yourapp.com/webhooks/paykit", "eventTypes": [], "disabledAt": null },
  "secret": "whsec_…"
}

That secret is the signing secret and it is returned exactly once. Store it in your secret manager before you close the response. It is never retrievable again, but you do not have to re-register an endpoint to replace it: see Rotating the signing secret.

Your URL has to pass these checks at registration time (SSRF defense):

  • https:// only.
  • No credentials embedded in the URL (https://user:pass@host is rejected).
  • The hostname must resolve to a public address. Loopback, private ranges, link-local (including the cloud metadata address) and CGNAT ranges are all rejected.
  • Redirects are not followed at delivery time. A 3xx response counts as a failure, so register the final URL.

Endpoints are registered per client, so every endpoint receives events from every project on that client, test and live alike. Registering a second endpoint does not separate them: an endpoint has no project, and the event filter selects event types, not environments. The envelope does not name the project yet. To keep test and live apart, resolve each event's subject through the API (for example the subscription's customer) to learn which project it belongs to, and ignore the other environment's events.

GET /v1/clients/{clientId}/webhook-endpoints lists your endpoints (never the secrets). DELETE …/{id} soft-disables one: deliveries already queued for it are dropped rather than sent.

2. What a delivery looks like

Every delivery is a POST with a JSON envelope and four x-mbx-* headers:

POST /webhooks/paykit HTTP/1.1
content-type:     application/json
user-agent:       PayKit-Webhooks/1
x-mbx-event:      subscription.activated
x-mbx-delivery:   4f0a1c72d1f34e0b9c3a5e77b1d2a840
x-mbx-timestamp:  1785312000
x-mbx-signature:  t=1785312000,v1=6b1e…

{
  "id": "4f0a1c72d1f34e0b9c3a5e77b1d2a840",
  "type": "subscription.activated",
  "createdAt": "2026-07-27T09:20:00.000Z",
  "data": { "subscriptionId": "…", "from": "incomplete", "to": "active", "nextChargeAt": "…", "transactionId": "…" }
}
FieldMeaning
x-mbx-event / typeThe event name, always noun.verb_past
x-mbx-delivery / idUnique id for this delivery attempt group. Dedupe on this.
x-mbx-timestampUnix seconds at signing time, part of the signed string
x-mbx-signaturet=<unix seconds>,v1=<hex hmac-sha256>. May carry more than one v1 during a secret rotation. Accept the delivery if any of them matches.
createdAtISO 8601 UTC, when the delivery was signed
dataThe event payload (see the catalog below)

Note that id stays the same across retries of the same event to the same endpoint, but x-mbx-timestamp and the signature are recomputed on each attempt.

3. Verify the signature

Recompute HMAC-SHA256("<x-mbx-timestamp>.<raw request body>") with your endpoint secret and compare it, in constant time, to each v1= value. Then reject anything whose timestamp is too old (a five minute window is a sensible default) so a captured delivery cannot be replayed at you later.

Three rules that people get wrong:

  1. Verify against the raw body bytes, before any JSON parsing or re-serialization. Re-encoding the JSON changes the bytes and the signature will not match.
  2. Compare in constant time. A plain == on the hex leaks timing information.
  3. Check every v1 in the header, not just the first. A rotation signs each delivery with the new secret and the old one, so the header carries two values during the overlap. A receiver that reads only the first v1 still works, but it loses the whole point of the overlap: it will reject deliveries until it holds the new secret.

Reject with 401 and do no work when verification fails.

PayKit runs a receiver built to exactly these rules against its own billing account, and our test suite drives the real signer against it on every build. So the three rules above are not advice we hand out and never follow: if we broke the signature format, our own endpoint would stop accepting deliveries before yours did.

Node (Express)

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

// express.raw keeps the exact bytes we need to sign. Do NOT use express.json here.
app.post('/webhooks/paykit', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verify(raw, req.headers, process.env.PAYKIT_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(raw);
  enqueue(event);              // hand off, do not process inline
  res.status(200).end();       // ack fast
});

function verify(rawBody, headers, secret, maxAgeSec = 300) {
  const ts = headers['x-mbx-timestamp'];
  // Every v1, not just the first: mid-rotation there are two.
  const sigs = [...(headers['x-mbx-signature'] ?? '').matchAll(/v1=([0-9a-f]+)/g)].map((m) => m[1]);
  if (!ts || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > maxAgeSec) return false;
  const expected = Buffer.from(createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex'), 'hex');
  return sigs.some((sig) => {
    const a = Buffer.from(sig, 'hex');
    return a.length === expected.length && timingSafeEqual(a, expected);
  });
}

PHP

<?php
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_MBX_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_MBX_SIGNATURE'] ?? '';

// All matches: a rotation puts two v1 values in the header.
preg_match_all('/v1=([0-9a-f]+)/', $sig, $m);
$sigs = $m[1] ?? [];

if ($ts === '' || count($sigs) === 0 || abs(time() - (int) $ts) > 300) {
    http_response_code(401);
    exit;
}

$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('PAYKIT_WEBHOOK_SECRET'));
$ok = false;
foreach ($sigs as $v1) {
    if (hash_equals($expected, $v1)) {
        $ok = true;
    }
}
if (!$ok) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
enqueue($event);
http_response_code(200);

Python (Flask)

import hmac, os, re, time
from hashlib import sha256
from flask import request, abort

SECRET = os.environ["PAYKIT_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/paykit")
def paykit_webhook():
    raw = request.get_data()                      # bytes, before parsing
    ts = request.headers.get("x-mbx-timestamp", "")
    # findall, not search: a rotation puts two v1 values in the header.
    sigs = re.findall(r"v1=([0-9a-f]+)", request.headers.get("x-mbx-signature", ""))
    if not ts or not sigs or abs(time.time() - int(ts)) > 300:
        abort(401)

    expected = hmac.new(SECRET, f"{ts}.".encode() + raw, sha256).hexdigest()
    if not any(hmac.compare_digest(expected, sig) for sig in sigs):
        abort(401)

    enqueue(request.get_json())
    return "", 200

4. Rotating the signing secret

Rotation replaces an endpoint's secret in place. You do not lose the endpoint, its event filter or its queued deliveries, and you do not have to re-enter the URL.

The dashboard route is Developers → Webhooks → Rotate secret on the endpoint. The API equivalent needs webhooks.manage and a recent sign-in, like registration:

POST /v1/clients/{clientId}/webhook-endpoints/{endpointId}/rotate-secret
Authorization: Bearer <caller token>          # webhooks.manage, step-up
Idempotency-Key: <uuid>
Content-Type: application/json

{ "graceHours": 24 }
{
  "endpoint": { "id": "9f2c…", "url": "https://yourapp.com/webhooks/paykit", "eventTypes": [], "disabledAt": null,
                "secretRotatedAt": "2026-07-30T08:00:00.000Z", "previousSecretExpiresAt": "2026-07-31T08:00:00.000Z" },
  "secret": "whsec_…",
  "previousSecretExpiresAt": "2026-07-31T08:00:00.000Z"
}

The new secret is returned exactly once, on the same terms as registration.

The overlap. graceHours (0 to 168, default 24) is how long your previous secret keeps verifying. Until it lapses, every delivery is signed with both secrets and the header carries two v1 values, the new secret first:

x-mbx-signature: t=1785312000,v1=<new secret>,v1=<previous secret>

So a receiver holding either secret accepts the delivery, and you can deploy the new one whenever your release process allows. This only works if your verifier checks every v1 value, which is why the samples in section 3 use matchAll / preg_match_all / findall. If yours checks only the first, it will reject deliveries until it holds the new secret.

Once the window closes the previous secret stops verifying and deliveries carry a single signature again. Nothing needs doing at that point.

Rotating a leaked secret. Pass "graceHours": 0. The previous secret stops working the moment the call returns, so there is no window in which the leaked value is still accepted. Deliveries will fail until your receiver holds the new secret, which is the correct trade when the old one is compromised. The dashboard asks you to confirm this choice.

A retry with the same Idempotency-Key returns the original response rather than rotating again, so a lost response does not strand you with a secret you never saw. A rotation cannot be undone: if you lose the new secret before storing it, rotate again.

5. Respond fast, and be idempotent

  • Any 2xx means delivered. Anything else, including a timeout, is a failure that gets retried. We give up on a response after 10 seconds, so acknowledge first and do the real work on a queue. A slow handler turns into duplicate deliveries.
  • Delivery is at-least-once. The same event can arrive more than once (a retry after your 200 was lost, for example). Record x-mbx-delivery and ignore ids you have already processed.
  • Order is not guaranteed. Two events for the same subscription can arrive out of order. Where order matters, use the state in the payload rather than assuming a sequence, or re-read the subscription.
  • Treat webhooks as the trigger, not the truth. For anything that gates money or access, call GET /v1/…/entitlements or fetch the subscription after the event, the same discipline we apply to gateway webhooks ourselves.

6. Retries and failure

Deliveries are queued by a worker that ticks every 2 minutes, so a new event normally lands within a couple of minutes of the state change.

A failed attempt is retried with exponential backoff: 1 minute, then 2, 4, 8, 16, 32 and 64 minutes. After 8 attempts (about 2 hours in total) the delivery is marked dead and we stop. There is no automatic replay of dead deliveries, so if your endpoint was down for the window, reconcile by reading the affected subscriptions through the API.

Disabling an endpoint drops its queued deliveries. They are not resurrected if you re-enable a similar URL later.

The dashboard's Developers → Webhooks page lists recent deliveries with their status, attempt count and last response code, which is the first place to look when something did not arrive.

7. Event catalog

These are the events actually delivered to client endpoints today: subscription lifecycle transitions, stored cards, paid invoices, add-ons and coupon redemptions. This table is the contract, and a test fails the build if it and the delivered set ever differ. Internal signals (reconciliation deltas, security and health alerts, email bounces, dispute and refund bookkeeping, plan changes) are never sent to client endpoints.

EventWhen it fires
subscription.createdA subscription was created and is awaiting its first charge (incomplete)
subscription.trial_startedA subscription entered a trial
subscription.activatedFirst successful charge, a trial converted, or a subscription to a plan that costs nothing (see below). Access should be granted here
subscription.renewedA renewal charge succeeded and the period rolled forward
payment.failedA charge failed. Fires on the first failure and again on each dunning retry that fails
subscription.action_requiredThe bank challenged the charge (OTP, PIN or 3DS). The payload carries the URL the customer must complete
subscription.attention_requiredA renewal is due but cannot be attempted: money is owed and the subscription has no card to charge (the customer removed their only card, or a 100%-off coupon ended on a subscription that never had one). The state does not change and PayKit checks again daily. This needs you to get a card from the customer; it is not a payment failure
subscription.recoveredA previously failing subscription is paying again and is back to active
subscription.grace_startedRetries are exhausted and the subscription entered its grace window
subscription.suspendedGrace expired. Access should be revoked here
subscription.pausedThe subscription was paused
subscription.resumedA paused subscription is active again
subscription.cancel_scheduledCancellation was requested for the end of the current period. Access continues until then
subscription.cancelledThe subscription was cancelled
subscription.expiredThe subscription reached its terminal end
addon.purchasedAn add-on grant became active: a paid purchase completed, or a free grant was comped
addon.renewedA period-bound add-on grant (or a limit raise) renewed alongside its subscription's own charge
addon.expiredA time-bound add-on grant reached its own expiry (the periodic expiry sweep, not a subscription cycle)
addon.revokedAn add-on grant was revoked manually, its subscription ended, its purchase was refunded in full, or its purchase was charged back (a dispute was opened against it)
subscription.reinstatedA suspended subscription was reinstated: the customer's current default card was charged and a fresh period started. Access should be granted again here
payment_method.attachedA card was stored for a customer, from any route: a checkout, an add-on purchase, a setup session, or re-adding a card they had removed
payment_method.detachedA stored card was removed
payment_method.expiringA stored card expires this month or next, and the customer has been emailed about it
invoice.paidAn invoice was paid. Fires for subscription renewals, first payments, reinstatements and add-on purchases
coupon.redeemedA coupon code was redeemed for a customer, on a subscription or an add-on purchase, and the discount is now active
coupon.redemption_exhaustedA discount has run its course: its last discounted period was charged, or it reached its end date
coupon.redemption_cancelledA discount was cancelled before it ran its course: you cancelled the redemption, the subscription it belonged to ended, or the add-on purchase it was redeemed on failed
coupon.redemption_supersededA newer redemption replaced this one on the same subscription, so it no longer applies
usage.ratedA usage period was rated after its settle delay. Fires for every rating, including a zero amount, so you can confirm a period closed
usage.chargedA usage charge succeeded. The payload carries the invoice and transaction
usage.charge_failedA usage charge attempt failed. final: true means retries are used up; an active or past_due subscription is then escalated into its own dunning (grace, then suspension)
usage.action_requiredThe bank challenged a usage charge (OTP, PIN or 3DS). The payload carries the URL the customer must complete

The two events that matter most for provisioning a subscription are subscription.activated (grant) and subscription.suspended (revoke). payment.failed is the one to hang customer messaging off. For add-ons the same shape holds: addon.purchased is the grant event, and addon.expired / addon.revoked are the revoke events.

For access, treat subscription.reinstated exactly like subscription.activated: it is the same transition (a subscription is billing again) reached deliberately rather than by a retry succeeding.

invoice.paid must be deduped on invoiceId, not on x-mbx-delivery. Every other event is safe to dedupe on the delivery id, but a charge path that is retried after a crash can produce two genuinely distinct deliveries for the same invoice. If you are emailing a receipt or writing a row per payment, key it on data.invoiceId.

payment_method.attached and payment_method.detached never carry the card authorization or its fingerprint. The payload is display-safe only: paymentMethodId, customerId, brand, last4, expMonth, expYear.

payment_method.expiring is shaped differently, because it is raised from a subscription the card pays for: paymentMethodId, subscriptionId, cardBrand, cardLast4, expMonth, expYear. It fires once per card, even when the card pays for several subscriptions, and only for a customer with a reachable email address.

A useful pair: payment_method.expiring tells a customer their card is about to stop working, and payment_method.attached tells you they fixed it, so a "your card is expiring" banner can clear itself without polling.

A plan that costs nothing activates immediately. Subscribe a customer to a plan version priced at 0 and the subscription is created active, not incomplete: there is no charge for a gateway to make, so there is no checkout and no payment webhook to wait for. You receive subscription.activated straight away, with no transactionId, and the subscription's entitlements resolve from that moment. It then renews on schedule, emitting subscription.renewed each period and writing no transaction rows at all, so a free tier costs nothing to carry and still reconciles cleanly. The same holds for a custom-priced plan negotiated to 0, and for the annual cadence of a free monthly plan.

One case is deliberately excluded. A custom plan version subscribed without a negotiated amount stays incomplete even if the version's suggested price happens to be 0, because on a custom plan that figure is a placeholder rather than a decision to charge nothing. Pass customAmountMinor: "0" when you mean it.

Add-on purchases have no equivalent of subscription.action_required. When an add-on purchase's charge is challenged (3DS/OTP), the purchase API responds 202 and the grant sits pending, exactly like a subscription's challenged charge, but there is no webhook telling you the challenge happened, and none telling you it was later abandoned or declined either: that path voids the invoice and revokes the grant internally with no outbox row, so it never reaches this delivery pipeline. If a successful challenge completes, you still get the ordinary addon.purchased delivery. An abandoned or declined one produces nothing. Poll for the grantId appearing in GET .../addon-grants or the entitlements read (a pending grant is invisible to both, so there is no pending state to watch it leave) instead of waiting on a webhook that will not arrive. See the add-on purchase docs for the full story.

Payload shapes

Every lifecycle event that is a state transition carries the subscription and both sides of the move, plus whatever is specific to that event:

{ "subscriptionId": "…", "from": "active", "to": "past_due", "failureCode": "insufficient_funds" }
EventExtra fields in data
subscription.createdcustomerId, planVersionId, state, trialEndsAt (no from/to)
subscription.activatednextChargeAt, transactionId. Both are absent when nothing was paid (a free plan, or a 100% off coupon): there is no charge to name
subscription.renewedamountMinor, currency
payment.failed (first failure)failureCode
payment.failed (dunning retry)state, attempt, failureCode (no from/to)
payment.failed (a usage charge ran out of retries)failureCode: "usage_charge_uncollectible", usagePeriodId, and the usage charge's amountMinor and currency (not the plan price)
subscription.recovered (a usage debt was paid)reason: "usage_debt_paid", usagePeriodId, and the usage charge's amountMinor and currency
subscription.action_requiredactionUrl, the link the customer must complete
subscription.grace_startedgraceUntil
subscription.cancel_scheduledeffectiveAt, requestedBy (no from/to)
subscription.cancelledrefund, when cancelling produced one

Add-on grant events use a different base shape: no subscription, no from/to. Every one carries grantId and addonCode, plus customerId except for addon.renewed (it fires from the renewal's own charge path, which never looks the customer row up on its own), plus:

EventExtra fields in data
addon.purchasedexpiresAt (null for a permanent grant, or a consumable that has not run out)
addon.renewedexpiresAt, the grant's new period end. No customerId (see above). Plus versionId and unitAmountMinor when the renewal moved the grant onto the add-on's current version (see below); both are absent when it stayed where it was
addon.expiredexpiredAt, the grant's own expiry timestamp
addon.revokednone beyond the base fields. The payload does not say which of the four triggers fired, so treat it as "this grant is gone" and revoke access the same way whichever it was

Usage events (usage.*) describe one usage period, not a subscription transition, so they have no from/to. All four carry the same common payload, taken from the rated period:

{
  "usagePeriodId": "…", "subscriptionId": "…", "customerId": "…", "externalRef": "site_123",
  "featureCode": "bandwidth_bytes", "periodStart": "2026-08-31T22:00:00.000Z", "periodEnd": "2026-09-30T22:00:00.000Z",
  "quantity": "39728447488", "includedQuantity": "26843545600", "unitSize": "1073741824",
  "billableUnits": "12", "unitAmountMinor": "600", "amountMinor": "7200", "capped": false, "currency": "ZAR"
}

Plus, per event:

EventExtra fields in data
usage.ratedstatus: no_charge (the amount was zero, nothing will be charged) or due (a charge is about to be attempted)
usage.chargedinvoiceId, transactionId
usage.charge_failedinvoiceId (always null: an invoice is issued only when a charge succeeds), transactionId (null when no usable card was found), failureCode, attempt, final, nextAttemptAt (null when final is true)
usage.action_requiredtransactionId, actionUrl (the link the customer must complete), holdUntil (when PayKit verifies the payment by reference and either records it as paid or counts the attempt as failed)

usage.charge_failed with final: true means retries are used up and the period is uncollectible. For an active subscription it is followed by the subscription's own payment.failed (to past_due), then subscription.grace_started, then subscription.suspended or subscription.cancelled per the subscription's policy. A past_due subscription escalates again: payment.failed is sent again and its retries are marked used up, with no state change. For a subscription in any other state there is no escalation: dunning is already running or has ended. Recover an uncollectible period with POST …/usage-periods/{id}/retry. When that pays the last unpaid escalated usage period of a subscription the escalation moved from active, and the subscription is still past_due or grace, it returns to active and subscription.recovered is sent.

Dedupe usage.charged on usagePeriodId: one usage charge is made per subscription, feature and period.

Amounts are always minor units as a string ("4990" is R49.90) with a separate ISO currency, never a float. Ids arrive dash-less, so normalize before comparing them to ids you stored with dashes.

A renewing add-on follows its add-on's current price. When you publish a new version of an add-on, every grant that renews with a subscription moves onto that version at its next renewal and is charged the new price. There is no per customer grandfathering: the price a customer pays is always the price in your catalog on their renewal date. This is the one place a catalog edit changes what an existing customer pays, so if you tell customers about price changes, addon.renewed is the event to hang that on: it carries versionId and unitAmountMinor whenever the move happened. A grant stays where it is, silently and at its old price, when the current version is priced in another currency, is custom-priced, or is not a recurring one.

New fields can be added to data at any time and new event types can be introduced, so parse defensively: ignore fields you do not know, and ignore event types you do not handle rather than erroring on them.

8. Testing your handler

There is no "send test event" button yet. The way to exercise a handler end to end is to run the real flow in a test project:

  1. Point an endpoint at a public tunnel to your local server (ngrok, cloudflared). Remember the URL must be public HTTPS, so localhost will be rejected.
  2. Create a customer and a subscription in your test project, then complete hosted checkout with a Paystack test card.
  3. You should see subscription.created and then subscription.activated land within a couple of minutes. Both appear in Developers → Webhooks → Recent deliveries regardless of whether your handler accepted them.

For signature verification specifically, it is worth a unit test: sign a fixed body with a known secret and assert your verifier accepts it, then flip one byte and assert it rejects.

9. Troubleshooting

SymptomLikely cause
Registration rejected with invalid_urlNot HTTPS, credentials in the URL, or the hostname resolves to a private or loopback address
Signature never matchesVerifying against re-serialized JSON instead of the raw bytes, or signing body alone instead of <timestamp>.<body>
Signatures started failing right after a rotationYour verifier reads only the first v1 and still holds the previous secret. Check every v1 in the header, or deploy the new secret
Deliveries show a non-2xx statusYour handler is erroring, or a framework is returning 3xx (redirects count as failures and are not followed)
Deliveries time outThe handler is doing work inline. Acknowledge within 10 seconds and process asynchronously
The same event processed twiceMissing dedupe. Store x-mbx-delivery and skip ids you have seen
Nothing arrives at allThe endpoint is disabled, its eventTypes filter excludes the event, or the event is internal and never delivered

Related

  • api-reference.md: the full integration surface, including the subscription lifecycle these events describe.
  • entitlement-tokens.md: how to gate features once a webhook has told you access changed.