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. You configure webhook endpoints — up to five per mode — on your app's Webhooks screen in the dashboard. Every event is delivered to every enabled endpoint of its own mode, independently.
Endpoints are per mode
TEST and LIVE endpoints are entirely separate rows. Nothing links them: different URLs,
different signing secrets, different enabled states.
That is the point. Previously an app had one webhook URL shared by both modes, so
verifying a test payment meant repointing the destination your production traffic was
using — which, for a live app, is not something anyone can actually do. Now you point a
TEST endpoint at a local tunnel and your LIVE endpoints never notice.
Each endpoint also carries:
- Its own signing secret, issued once when you create the endpoint (and again if you rotate it). A secret verifies deliveries to that endpoint only, so one can be rotated or leaked without implicating any other consumer — and a test consumer's key can never verify live traffic.
- Its own event subscription — all catalogued events, or an explicit list. "All events" keeps including types added to the catalogue later; an explicit list does not.
- Its own enabled state. Disabling stops delivery at capture without deleting the endpoint, its secret or its history — the safe way to mute a consumer that is misbehaving.
Failures are per endpoint. If one of your consumers starts returning 500, only its
own deliveries retry and eventually park. Its siblings are delivered normally and are
never charged for its failure.
LIVE endpoints must use HTTPS
A LIVE endpoint must be https. Saving an http:// one is rejected with 400 and the
error code WEBHOOK_URL_REQUIRES_TLS, whatever the deployment is configured to allow.
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.
TEST endpoints answer to the deployment's scheme allowlist instead, whose shipped default
is also https alone. A self-hosted or sandbox deployment can widen it to permit
plaintext test consumers — see the
admin guide. Widening it never
makes LIVE delivery over plaintext work. Nothing does.
What this means in practice:
- You find out at the form, not from an absence of traffic hours later. The rejection happens when you save the endpoint.
- Local development: on the shipped configuration, point a
TESTendpoint at anhttpstunnel (ngrok, Cloudflare Tunnel and similar all issue one). You no longer need to touch yourLIVEendpoints to do it. - Having no endpoint for a mode is allowed. Events in that mode are still captured and recorded, marked undeliverable with an explanatory error, so the misconfiguration is visible in the delivery log rather than silent.
- If a plaintext
LIVEURL is already stored — carried over from the old single-URL field before endpoints existed — its events 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 this endpoint's signing secret — verify this. |
X-Webhook-Id | The event id, stable across every retry and operator re-send — dedupe on this. Each endpoint receives its own id for the same logical event, so it is unique within your consumer's own stream. |
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.
Use the secret belonging to the endpoint that received the delivery. Each endpoint has its own, shown once when you create it or rotate it. If one service backs several endpoints, key your secrets by the route the request arrived on — a signature computed with a sibling endpoint's secret will not match, and that is deliberate.
Rotation has no grace window: the previous secret stops verifying the moment you rotate, because a delivery carries exactly one signature and there is no "both keys valid" state to offer. Deploy the new secret before the next event fires.
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 — even though you can now separate the modes at the endpoint. Configuring aTESTendpoint and aLIVEendpoint at different URLs makes it unlikely that a test event reaches a fulfilment path; checkingmodemakes it impossible, and costs one line. If both modes do point at the same service — which is a legitimate setup, and the one the endpoints migrated from the old shared field produce — thenmodeis again the only thing distinguishing 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 — with one exception. 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. The exception ispayment.succeededafterpayment.expired: expiry is not a terminal state, and applying that rule literally there leaves a customer charged and unfulfilled. See Late settlement after expiry. - 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.
Late settlement after expiry
We close a checkout when its expires_at passes, but the gateway's session can outlive ours —
we cannot cancel a session at SSLCOMMERZ. A customer already on the gateway's payment page can
complete the payment after our deadline, and that money is real. We revive the payment to
SUCCEEDED and emit payment.succeeded after having already emitted payment.expired.
For one payment_id you can therefore legitimately receive:
{ "id": 90114, "type": "payment.expired", "created_at": "2026-06-29T09:44:03Z",
"data": { "payment_id": 4733, "status": "expired", "previous_status": "pending" } }
{ "id": 90271, "type": "payment.succeeded", "created_at": "2026-06-29T09:46:18Z",
"data": { "payment_id": 4733, "status": "succeeded", "previous_status": "expired" } }succeeded outranks expired regardless of arrival order. Rank the statuses in your handler
rather than trusting arrival order or "have I advanced past this" — the two events are two minutes
apart here, but the unordered contract permits them in either order:
| You have stored | Event arrives | Do |
|---|---|---|
expired | payment.succeeded | Fulfil. Undo whatever you did on expiry. |
succeeded | payment.expired | Nothing. A stale expiry for a payment that settled. |
data.previous_status tells the two settlements apart without keeping state: "pending" is an
ordinary settlement, "expired" is a revival — the one where you may have already released a cart,
notified the customer, or cancelled an order that now needs unwinding.
This is the one case where payment.expired is not final. Everything you do on
payment.expired must be reversible, or deferred past the window in which a late settlement can
still arrive.
Event catalogue
These 22 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.cancelled, 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.cancelled | The customer cancelled at the gateway. |
payment.expired | The payment passed its expires_at deadline before completing. |
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 | cancelled | expired. |
expires_at | string | null | Yes | The checkout deadline this payment was created with. null for a payment created before deadlines were stored. |
previous_status | string | null | Yes | The status the payment left. "expired" on a payment.succeeded marks a late settlement. |
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",
"expires_at": "2026-06-03T12:30:00",
"previous_status": "pending",
"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 refunded payment is not announced as a payment.* event — a refund rides the refund.*
events below, so payment.succeeded remains the single "this payment was captured" signal for a
given payment_id regardless of what is refunded against it afterwards.
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:
- One row per endpoint. An event with three matching endpoints produces three delivery rows, each with its own status, attempt count and retry schedule. They succeed and fail independently — the table above describes one row, not one event.
- No enabled endpoint for the event's mode still records a row, marked
FAILEDat once with an explanatory error, so the misconfiguration is visible in the delivery log rather than being an absence of anything. - An endpoint deleted or disabled after capture parks its queued rows rather than redirecting them to a sibling. Where an event was addressed is decided when it is captured, not when it is delivered.
- 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 LIVE endpoints must use HTTPS. - After 14 failed attempts the row is parked (
FAILED) and waits for a manual re-send. - If one endpoint fails persistently, that endpoint's pending rows 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. Your other endpoints are not deferred with it.
- 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.