Webhooks
The complete 21-event catalogue, the signed canonical envelope, how to verify signatures and dedupe, and the exact delivery, retry and re-send contract.
Webhooks push outbound events to your app so you react to confirmed state without
polling. Set your receiving URL when your app is onboarded, or later via app settings in
the dashboard. One URL receives every event for the app; the event's
mode field tells you whether it is TEST or LIVE.
Your webhook URL must use https. Saving an http:// URL is rejected with 400
and the error code WEBHOOK_URL_REQUIRES_TLS. See
Your endpoint must use HTTPS — the reason is not
boilerplate, and the consequence of getting it wrong is unusual.
Your endpoint must use HTTPS
Your app has one webhook URL, shared by TEST and LIVE. It must be https.
The signature on a delivery proves the payload was not altered. It does not keep the
payload private — anyone positioned on the network path can read a plaintext delivery
in full, including payment amounts, customer references and event types. So LIVE events
are never transmitted over an unencrypted connection, under any configuration.
Because the URL is shared between modes, that rule has a consequence worth stating plainly:
There is no separate "test URL" and "live URL". A single http:// endpoint would leave
you receiving TEST webhooks perfectly normally while every LIVE webhook stopped —
a failure that looks like nothing at all from your side. That split is exactly why the
URL is rejected when you save it rather than at delivery time.
What this means in practice:
- Saving a non-
httpsURL fails immediately with400/WEBHOOK_URL_REQUIRES_TLS, at both app registration and app settings. You find out at the form, not from an absence of traffic hours later. - Clearing the URL is always allowed. An empty value means "no endpoint"; events then park with an explanatory error rather than being retried into the void.
- Local development: point the URL at an
httpstunnel (ngrok, Cloudflare Tunnel and similar all issue one) rather than athttp://localhost. Self-hosted or sandbox deployments can relax the rule — see the admin guide — but relaxing it never makesLIVEdelivery over plaintext work. Nothing does. - If a plaintext URL is already stored (saved before this rule, or written directly by
an operator),
LIVEevents for that app are parked on their first attempt withlastError: "LIVE_REQUIRES_HTTPS". They are not retried — no number of retries turns anhttp://URL into anhttps://one. Fix the URL, then re-send the parked events.
Every event is one signed JSON envelope delivered by POST. The catalogue below is the
complete and only set of types the service emits — it is enforced in code by an enum,
so an undocumented type is impossible.
The envelope
Every event is one canonical JSON object. The keys are always emitted in this fixed order so the bytes are stable and signable:
{
"id": 1024,
"type": "payment.succeeded",
"created_at": "2026-06-03T12:00:00Z",
"mode": "TEST",
"app_id": 42,
"schema_version": 1,
"data": { "...": "type-specific, see the catalogue below" }
}| Field | Type | Required | Notes |
|---|---|---|---|
id | number | Yes | Unique event id. Dedupe on this — delivery is at-least-once. |
type | string | Yes | One of the catalogued dotted types below, e.g. payment.succeeded. |
created_at | string | Yes | UTC ISO-8601, set at event creation (never at delivery). Authoritative for ordering; ignore an event older than your known state. |
mode | string | Yes | TEST or LIVE — test and live events never mix. See Modes. |
app_id | number | Yes | The owning application. See App isolation. |
schema_version | number | Yes | Payload schema version. v1 = 1. |
data | object | Yes | The type-specific payload (per-type tables below). |
The event's mode is inherited from the record that produced it. The mode (TEST or
LIVE) is derived from the API credential that authenticated the request — never from the
request body.
Five headers accompany each delivery:
| Header | Notes |
|---|---|
X-Webhook-Signature | Lowercase-hex HMAC-SHA256 of the raw body, keyed by your signing secret — verify this. |
X-Webhook-Id | The event id, stable across every retry and operator re-send — dedupe on this. |
X-Webhook-Event | The dotted type, e.g. payment.succeeded. |
X-Webhook-Timestamp | Unix seconds at which this attempt was signed. Differs on every attempt of the same event. |
X-Webhook-Signature-V2 | Timestamped signature, t=<unix>,v1=<hex>. Opt-in now, required later — see below. |
X-Webhook-Timestamp and X-Webhook-Signature-V2 are additive. X-Webhook-Signature
is unchanged — same header name, same value, same algorithm — and remains valid. Nothing
you have deployed today needs to change.
This is the request OI Payments makes to your endpoint (the body is the exact canonical envelope above):
curl -X POST https://your-app.example.com/webhooks/oi \
-H "Content-Type: application/json" \
-H "X-Webhook-Event: payment.succeeded" \
-H "X-Webhook-Id: 1024" \
-H "X-Webhook-Signature: 5f2c9b8e7a1d4c0e9f3b6a2d8c1e7f40b3a9d2c6e1f8a4b7d0c3e6f9a2b5d8c1e" \
-H "X-Webhook-Timestamp: 1785715200" \
-H "X-Webhook-Signature-V2: t=1785715200,v1=9d2e0c7f4b1a86d35e0f7c2b9a4d81e6c3f70b5a2d9e8c1f4b7a0d3e6c9f2b5d" \
-d '{"id":1024,"type":"payment.succeeded","created_at":"2026-06-03T12:00:00Z","mode":"TEST","app_id":42,"schema_version":1,"data":{ "...": "..." }}'Verify the signature
The signature is HMAC-SHA256(raw_body_bytes, signing_secret) in lowercase hex. Recompute
it over the exact bytes you received and compare in constant time.
import crypto from "node:crypto";
function verify(rawBody: Buffer, signature: string, signingSecret: string): boolean {
const expected = crypto
.createHmac("sha256", signingSecret)
.update(rawBody)
.digest("hex");
// Constant-time compare to avoid timing leaks.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Verify against the raw request body, before any JSON parsing or re-serialization. Parsing and re-stringifying changes the bytes and the signature will not match. A mismatch means the payload is not authentic — reject it.
Signature v2 — opt-in now, required later
v1 signs the body only, so a captured delivery stays replayable forever. v2 binds the signature to the delivery time, which lets you reject replays. Both signatures are sent on every delivery today; verifying either is sufficient, and verifying v2 is recommended.
X-Webhook-Signature-V2 has the form t=<unix>,v1=<hex>, where v1 is
HMAC-SHA256(timestamp + "." + raw_body, signing_secret) in lowercase hex — the timestamp in
decimal ASCII, a literal ., then the exact bytes you received.
import crypto from "node:crypto";
function verifyV2(
rawBody: Buffer,
headerV2: string, // "t=1785715200,v1=9d2e0c7f..."
signingSecret: string,
toleranceSeconds = 300,
): boolean {
const parts = new Map(headerV2.split(",").map((p) => p.split("=", 2) as [string, string]));
const t = parts.get("t");
const v1 = parts.get("v1");
if (!t || !v1) return false;
// Replay guard: reject a signature signed too far from your own clock.
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
// Signed string is the timestamp, a literal dot, then the RAW body bytes.
const expected = crypto
.createHmac("sha256", signingSecret)
.update(Buffer.concat([Buffer.from(`${t}.`), rawBody]))
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}t is the moment this attempt was signed — not the event's created_at. A retry of a
six-hour-old event carries a fresh t, so a short replay tolerance is safe and will not
reject legitimately late deliveries.
X-Webhook-Signature will eventually be retired, but not soon and not silently: no earlier
than 2026-11-29 (120 days after the 2026-08-01 launch), and never without merchant
communication confirming the exact date. Until then it is emitted unchanged on every delivery.
Make your handler idempotent
Delivery is at-least-once and unordered. A handler that assumes exactly-once or in-order delivery will corrupt its own state. These are the guarantees you get:
| Guarantee | What your handler must do |
|---|---|
| At-least-once | The same event can arrive more than once. Dedupe on id. |
| Unordered | Events can arrive in any order relative to each other. Order by created_at. |
Stable id | Every attempt and every operator re-send of one event carries the same id. |
| Late delivery | An event can arrive up to roughly 6 hours after its created_at. |
| Mode-scoped | Branch on mode before you fulfil anything. |
The rules that follow from that:
- Dedupe on
id(equivalentlyX-Webhook-Id) — record processed ids and skip repeats. The id is stable across every retry attempt and across every operator re-send: a re-send is the same event delivered again, never a new one. - Order by
created_at, not by arrival time and not byX-Webhook-Timestamp. Keep a per-resource high-water mark and compare againstcreated_at. - Branch on
modebefore fulfilling.TESTandLIVEevents arrive at the same URL and are signed with the same secret — nothing else distinguishes them. ATESTevent must never ship goods, charge a customer, or grant entitlements. See Modes. - Tolerate an unknown parent; never drop it. Because delivery is unordered, a
refund.succeededcan arrive before thepayment.succeededit refunds, and aninvoice.paidbefore itspayment.succeeded. If the parent record is one you have not seen, queue the event, reconcile it, or create the parent lazily — do not reject it as invalid. A non-2xxonly causes redelivery, and the parent will not necessarily arrive first next time. - Make a superseded event a no-op. If your stored state is at or beyond what the event
describes — an older
created_at, or a status you have already advanced past — return2xxand do nothing. Rejecting a stale event turns it into a retry loop for the full 6-hour horizon. - Acknowledge fast. Return any
2xxquickly and do slow work asynchronously; anything other than a2xx(or a timeout) is treated as a failed attempt and retried.
Ordering is least reliable exactly when you are recovering from an outage: a backlog built up while your endpoint was failing is delivered in a burst, and a persistently failing endpoint has its whole app's pending events rescheduled together, which can place them behind events captured later. Both are within the unordered contract.
This is the consumer-side counterpart of the request Idempotency you use when calling the API.
Event catalogue
These 21 types are the complete set the service ever emits — the catalogue is enforced
in code, so an undocumented type cannot occur. Each type's data carries a status string
equal to the wire-name suffix after the first dot (payment.succeeded → "succeeded"),
with one deliberate exception: subscription.entitlements_updated carries no status.
| Group | Types |
|---|---|
| Payments | payment.succeeded, payment.failed, payment.expired |
| Refunds | refund.pending, refund.succeeded, refund.failed |
| Invoices | invoice.issued, invoice.partially_paid, invoice.paid, invoice.voided |
| Subscriptions | subscription.created, subscription.activated, subscription.trial_will_end, subscription.renewed, subscription.payment_succeeded, subscription.payment_failed, subscription.past_due, subscription.canceled, subscription.paused, subscription.resumed |
| Entitlements | subscription.entitlements_updated |
Money is always integer minor units (paisa): amount_minor: 150000 means 1,500.00 BDT.
Currency is BDT-only in v1. See Money.
Payments
| Type | When |
|---|---|
payment.succeeded | A payment was captured. |
payment.failed | The gateway rejected the payment. |
payment.expired | A pending payment timed out. |
data:
| Field | Type | Required | Notes |
|---|---|---|---|
payment_id | number | Yes | Internal payment id. |
reference | string | Yes | Public payment reference (the gateway tran_id). |
amount_minor | number | Yes | Minor units (paisa). |
currency | string | Yes | BDT. |
invoice_id | number | null | Yes | The settled invoice, or null for a standalone payment. |
status | string | Yes | succeeded | failed | expired. |
metadata | object | No | Present only when the payment carried metadata. |
{
"id": 1024,
"type": "payment.succeeded",
"created_at": "2026-06-03T12:00:00Z",
"mode": "TEST",
"app_id": 42,
"schema_version": 1,
"data": {
"payment_id": 100,
"reference": "pay_3xK9aQ",
"amount_minor": 150000,
"currency": "BDT",
"invoice_id": 3,
"status": "succeeded",
"metadata": { "order_id": "ORD-7741", "channel": "mobile" }
}
}metadata is an optional JSON object stored as-is and echoed back unchanged on the
settlement webhook; it is never interpreted or merged. It appears as a real nested JSON
object (not an escaped string) and is omitted entirely when the payment carried none.
A cancelled or refunded payment is not catalogued and is not pushed as a
payment.* event (a refund rides the refund.* events below).
Refunds
| Type | When |
|---|---|
refund.pending | The gateway accepted the refund (settling async). |
refund.succeeded | The gateway confirmed the refund. |
refund.failed | The gateway rejected/failed the refund. |
data:
| Field | Type | Required | Notes |
|---|---|---|---|
refund_id | number | Yes | Internal refund id. |
payment_id | number | Yes | The payment being refunded. |
invoice_id | number | null | Yes | The related invoice, or null for a standalone payment. |
amount_minor | number | Yes | Minor units (paisa). |
currency | string | Yes | BDT. |
status | string | Yes | pending | succeeded | failed. |
{
"refund_id": 9,
"payment_id": 100,
"invoice_id": 3,
"amount_minor": 25000,
"currency": "BDT",
"status": "pending"
}A refund parked in AWAITING_APPROVAL is not pushed — nothing is delivered until the
gateway accepts it (refund.pending). See Refunds.
Invoices
| Type | When |
|---|---|
invoice.issued | An invoice was issued. |
invoice.partially_paid | An installment settled part of the balance. |
invoice.paid | The invoice was fully settled. |
invoice.voided | The invoice was voided before settlement. |
data:
| Field | Type | Required | Notes |
|---|---|---|---|
invoice_id | number | Yes | Internal invoice id. |
number | string | Yes | Per-app invoice number, e.g. INV-000003. |
total_minor | number | Yes | Invoice total, minor units. |
amount_paid_minor | number | Yes | Running amount settled after this transition. |
amount_due_minor | number | Yes | total_minor − amount_paid_minor. |
currency | string | Yes | BDT. |
subscription_id | number | No | Present only on a subscription cycle invoice; absent for an ad-hoc invoice. |
status | string | Yes | issued | partially_paid | paid | voided. |
{
"invoice_id": 3,
"number": "INV-000003",
"total_minor": 150000,
"amount_paid_minor": 60000,
"amount_due_minor": 90000,
"currency": "BDT",
"subscription_id": 88,
"status": "partially_paid"
}An invoice in DRAFT or OVERDUE is not catalogued — there is no invoice.overdue
event. See Invoicing.
Subscriptions
Subscriptions emit two kinds of event. Lifecycle events track the status machine; a
null previous status means a creation, and a return to ACTIVE from PAUSED is a
resumed (vs a plain activated).
| Type | When |
|---|---|
subscription.created | A subscription was created. |
subscription.activated | It became ACTIVE (first activation). |
subscription.past_due | A renewal payment failed and it went PAST_DUE. |
subscription.paused | It was paused. |
subscription.resumed | It returned to ACTIVE from PAUSED. |
subscription.canceled | It was canceled. |
Lifecycle data:
| Field | Type | Required | Notes |
|---|---|---|---|
subscription_id | number | Yes | Internal subscription id. |
product_id | number | Yes | The subscribed product. |
price_id | number | Yes | The recurring price it bills on. |
status | string | Yes | created | activated | past_due | paused | resumed | canceled. |
{
"subscription_id": 88,
"product_id": 12,
"price_id": 31,
"status": "created"
}Notification events are facts about a subscription that carry no status transition — a billing-cycle payment outcome, a confirmed renewal, or a trial-ending reminder.
| Type | When |
|---|---|
subscription.payment_succeeded | A cycle's payment succeeded. |
subscription.payment_failed | A cycle's payment failed. |
subscription.renewed | A cycle renewed and the next period started. |
subscription.trial_will_end | The trial is about to end (reminder). |
Notification data:
| Field | Type | Required | Notes |
|---|---|---|---|
subscription_id | number | Yes | Internal subscription id. |
product_id | number | Yes | The subscribed product. |
price_id | number | Yes | The recurring price it bills on. |
invoice_id | number | No | The cycle invoice behind the notification; absent for trial_will_end (a reminder has no invoice). |
status | string | Yes | payment_succeeded | payment_failed | renewed | trial_will_end. |
A payment-failed notification (carrying its cycle invoice_id):
{
"subscription_id": 88,
"product_id": 12,
"price_id": 31,
"invoice_id": 204,
"status": "payment_failed"
}A trial-ending reminder (no invoice_id):
{
"subscription_id": 88,
"product_id": 12,
"price_id": 31,
"status": "trial_will_end"
}A subscription in TRIALING is not a catalogued outbound transition — there is no
subscription.trialing event. See Subscriptions.
Entitlements
| Type | When |
|---|---|
subscription.entitlements_updated | A customer's derived active entitlement set actually changed. |
This event fires in exactly two situations, and only when the active set really changes:
- a subscription crosses the entitling boundary —
TRIALING/ACTIVE↔ anything else (bothTRIALINGandACTIVEentitle, so aTRIALING → ACTIVEmove does not fire it); - a one-time-purchase invoice is paid, granting durable entitlements.
data (note: no status field — this is the one exception to the suffix rule):
| Field | Type | Required | Notes |
|---|---|---|---|
customer_reference | string | Yes | The app's external customer id. |
entitlements | string[] | Yes | The customer's current active entitlement lookup keys (deduplicated). |
{
"customer_reference": "cust_8842",
"entitlements": ["pro", "seats"]
}Treat this event as the authoritative signal to re-sync a customer's access; the array is the full current set, not a delta. See Entitlements and Catalog and subscriptions.
One-time purchases and subscription billing reuse the existing events. A one-time
purchase and the auto-generated subscription cycle invoices fire the same payment.* and
invoice.* events above — there is no purchase.* event type. A paid one-time
purchase additionally fires subscription.entitlements_updated.
Versioning
schema_version is 1 for the v1 catalogue and is bumped only for a breaking change to
a data schema. New optional fields are additive and keep the same version, so a
consumer pinned to v1 never breaks on a new field. Always treat unknown data fields as
ignorable.
Delivery, retries & re-sends
Events are captured in an outbox in the same transaction as the domain change, so an
event is never lost if delivery is slow and never appears for a rolled-back change. A sweep
claims a batch of due events and POSTs each signed envelope. Anything other than a 2xx
(or a timeout) is a failed attempt, retried with bounded, jittered exponential backoff over a
retry horizon of roughly 6 hours.
The delivery row moves through three states:
The exact tunables that govern delivery:
| Setting | Value | Meaning |
|---|---|---|
| Max attempts | 14 | Total delivery attempts before the event is parked FAILED. |
| Backoff | min(1h, 10s · 2ⁿ) ±20% | Wait before the n-th retry (0-based): 10s, 20s, 40s, 80s, … capped at 1 hour, each with ±20% jitter. |
| Retry horizon | ~6h | Elapsed time from capture to parking, across all 14 attempts. |
| Connect timeout | 3s | A delivery POST that cannot connect fails fast. |
| Response timeout | 8s | A stalled consumer cannot pin the delivery thread. |
| Request timeout | 15s | Absolute ceiling on one delivery attempt. |
Jitter means retry times are spread, not exact. Do not build anything that expects an event at a precise second, and treat the 6-hour horizon as approximate.
A few consequences worth knowing:
- No configured
webhook_urlparks the event immediately with an explanatory error, rather than burning the retry budget against a missing endpoint. - A
LIVEevent whose endpoint is nothttpsparks immediately too, withlastError: "LIVE_REQUIRES_HTTPS". Same reasoning: this is a configuration problem, not a transient one, and 14 attempts over 6 hours cannot fix it — they only delay your seeing it. See Your endpoint must use HTTPS. - After 14 failed attempts the event is parked (
FAILED) and waits for a manual re-send. - If your endpoint fails persistently, the whole app's pending events may be deferred as a group and retried later, which both protects you from a thundering retry herd and reorders those events relative to ones captured after them.
- An operator can re-send a parked (or pending) event from the
dashboard. A re-send resets the attempt budget — the
event gets a full fresh set of attempts, not one more — and keeps the same
X-Webhook-Id, which is exactly why your handler must dedupe.
Operator re-send (admin)
A re-send is an admin (session-authenticated) action, gated by the app:manage authority
and scoped to the event's app. It re-queues the event rather than delivering it inline:
the response returns immediately with the event back in PENDING and its attempt budget
reset, and the next sweep performs the actual POST. Every response uses the standard
envelope — { "data": ..., "meta": { "success": true, ... } }:
curl -X POST http://localhost:8080/api/v1/admin/webhooks/1024/resend \
-H "Authorization: Bearer SESSION_TOKEN"{
"data": {
"id": 1024,
"applicationId": 42,
"applicationName": "Acme Storefront",
"mode": "TEST",
"type": "payment.succeeded",
"schemaVersion": 1,
"payload": "{\"payment_id\":100,\"reference\":\"pay_3xK9aQ\",\"amount_minor\":150000,\"currency\":\"BDT\",\"invoice_id\":3,\"status\":\"succeeded\"}",
"deliveryStatus": "PENDING",
"attempts": 0,
"lastResponseStatus": 503,
"lastError": "HTTP 503",
"lastAttemptAt": "2026-06-03T17:44:10",
"createdAt": "2026-06-03T12:00:00"
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:05:00Z" },
"pagination": null
}A re-send resets deliveryStatus, attempts and deliveredAt. It does not clear
lastError, lastResponseStatus or lastAttemptAt — those still describe the
previous attempt, which is what makes the re-queued row diagnosable at a glance.
They are overwritten when the next attempt lands. Note also that null fields are
omitted from the JSON rather than emitted as null, so deliveredAt simply does not
appear above.
An event that is being delivered at that moment cannot be re-sent — the request is
rejected with 409 and can be retried a few seconds later. The full cross-app delivery log,
filters, and per-event diagnostics live in the admin guide.
Related
Accept a payment
Create a payment and receive payment.succeeded.
Refunds
Issue refunds and react to refund.* events.
Subscriptions
Recurring billing and the subscription.* events.
Entitlements
Derive customer access from subscriptions and purchases.
Admin: webhooks & logs
Cross-app delivery log and manual re-send.
Idempotency
At-least-once delivery and safe re-processing.