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@hostis 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
3xxresponse 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": "…" }
}
| Field | Meaning |
|---|---|
x-mbx-event / type | The event name, always noun.verb_past |
x-mbx-delivery / id | Unique id for this delivery attempt group. Dedupe on this. |
x-mbx-timestamp | Unix seconds at signing time, part of the signed string |
x-mbx-signature | t=<unix seconds>,v1=<hex hmac-sha256>. May carry more than one v1 during a secret rotation. Accept the delivery if any of them matches. |
createdAt | ISO 8601 UTC, when the delivery was signed |
data | The 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:
- 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.
- Compare in constant time. A plain
==on the hex leaks timing information. - Check every
v1in 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 firstv1still 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
2xxmeans 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
200was lost, for example). Recordx-mbx-deliveryand 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/…/entitlementsor 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.
| Event | When it fires |
|---|---|
subscription.created | A subscription was created and is awaiting its first charge (incomplete) |
subscription.trial_started | A subscription entered a trial |
subscription.activated | First successful charge, a trial converted, or a subscription to a plan that costs nothing (see below). Access should be granted here |
subscription.renewed | A renewal charge succeeded and the period rolled forward |
payment.failed | A charge failed. Fires on the first failure and again on each dunning retry that fails |
subscription.action_required | The bank challenged the charge (OTP, PIN or 3DS). The payload carries the URL the customer must complete |
subscription.attention_required | A 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.recovered | A previously failing subscription is paying again and is back to active |
subscription.grace_started | Retries are exhausted and the subscription entered its grace window |
subscription.suspended | Grace expired. Access should be revoked here |
subscription.paused | The subscription was paused |
subscription.resumed | A paused subscription is active again |
subscription.cancel_scheduled | Cancellation was requested for the end of the current period. Access continues until then |
subscription.cancelled | The subscription was cancelled |
subscription.expired | The subscription reached its terminal end |
addon.purchased | An add-on grant became active: a paid purchase completed, or a free grant was comped |
addon.renewed | A period-bound add-on grant (or a limit raise) renewed alongside its subscription's own charge |
addon.expired | A time-bound add-on grant reached its own expiry (the periodic expiry sweep, not a subscription cycle) |
addon.revoked | An 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.reinstated | A 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.attached | A 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.detached | A stored card was removed |
payment_method.expiring | A stored card expires this month or next, and the customer has been emailed about it |
invoice.paid | An invoice was paid. Fires for subscription renewals, first payments, reinstatements and add-on purchases |
coupon.redeemed | A coupon code was redeemed for a customer, on a subscription or an add-on purchase, and the discount is now active |
coupon.redemption_exhausted | A discount has run its course: its last discounted period was charged, or it reached its end date |
coupon.redemption_cancelled | A 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_superseded | A newer redemption replaced this one on the same subscription, so it no longer applies |
usage.rated | A usage period was rated after its settle delay. Fires for every rating, including a zero amount, so you can confirm a period closed |
usage.charged | A usage charge succeeded. The payload carries the invoice and transaction |
usage.charge_failed | A 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_required | The 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" }
| Event | Extra fields in data |
|---|---|
subscription.created | customerId, planVersionId, state, trialEndsAt (no from/to) |
subscription.activated | nextChargeAt, transactionId. Both are absent when nothing was paid (a free plan, or a 100% off coupon): there is no charge to name |
subscription.renewed | amountMinor, 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_required | actionUrl, the link the customer must complete |
subscription.grace_started | graceUntil |
subscription.cancel_scheduled | effectiveAt, requestedBy (no from/to) |
subscription.cancelled | refund, 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:
| Event | Extra fields in data |
|---|---|
addon.purchased | expiresAt (null for a permanent grant, or a consumable that has not run out) |
addon.renewed | expiresAt, 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.expired | expiredAt, the grant's own expiry timestamp |
addon.revoked | none 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:
| Event | Extra fields in data |
|---|---|
usage.rated | status: no_charge (the amount was zero, nothing will be charged) or due (a charge is about to be attempted) |
usage.charged | invoiceId, transactionId |
usage.charge_failed | invoiceId (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_required | transactionId, 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:
- Point an endpoint at a public tunnel to your local server (
ngrok,cloudflared). Remember the URL must be public HTTPS, solocalhostwill be rejected. - Create a customer and a subscription in your test project, then complete hosted checkout with a Paystack test card.
- You should see
subscription.createdand thensubscription.activatedland 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
| Symptom | Likely cause |
|---|---|
Registration rejected with invalid_url | Not HTTPS, credentials in the URL, or the hostname resolves to a private or loopback address |
| Signature never matches | Verifying against re-serialized JSON instead of the raw bytes, or signing body alone instead of <timestamp>.<body> |
| Signatures started failing right after a rotation | Your 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 status | Your handler is erroring, or a framework is returning 3xx (redirects count as failures and are not followed) |
| Deliveries time out | The handler is doing work inline. Acknowledge within 10 seconds and process asynchronously |
| The same event processed twice | Missing dedupe. Store x-mbx-delivery and skip ids you have seen |
| Nothing arrives at all | The 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.