OI Payments Docs
Guides

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-https URL fails immediately with 400 / 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 https tunnel (ngrok, Cloudflare Tunnel and similar all issue one) rather than at http://localhost. Self-hosted or sandbox deployments can relax the rule — see the admin guide — but relaxing it never makes LIVE delivery over plaintext work. Nothing does.
  • If a plaintext URL is already stored (saved before this rule, or written directly by an operator), LIVE events for that app are parked on their first attempt with lastError: "LIVE_REQUIRES_HTTPS". They are not retried — no number of retries turns an http:// URL into an https:// 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" }
}
FieldTypeRequiredNotes
idnumberYesUnique event id. Dedupe on this — delivery is at-least-once.
typestringYesOne of the catalogued dotted types below, e.g. payment.succeeded.
created_atstringYesUTC ISO-8601, set at event creation (never at delivery). Authoritative for ordering; ignore an event older than your known state.
modestringYesTEST or LIVE — test and live events never mix. See Modes.
app_idnumberYesThe owning application. See App isolation.
schema_versionnumberYesPayload schema version. v1 = 1.
dataobjectYesThe 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:

HeaderNotes
X-Webhook-SignatureLowercase-hex HMAC-SHA256 of the raw body, keyed by your signing secret — verify this.
X-Webhook-IdThe event id, stable across every retry and operator re-send — dedupe on this.
X-Webhook-EventThe dotted type, e.g. payment.succeeded.
X-Webhook-TimestampUnix seconds at which this attempt was signed. Differs on every attempt of the same event.
X-Webhook-Signature-V2Timestamped 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:

GuaranteeWhat your handler must do
At-least-onceThe same event can arrive more than once. Dedupe on id.
UnorderedEvents can arrive in any order relative to each other. Order by created_at.
Stable idEvery attempt and every operator re-send of one event carries the same id.
Late deliveryAn event can arrive up to roughly 6 hours after its created_at.
Mode-scopedBranch on mode before you fulfil anything.

The rules that follow from that:

  • Dedupe on id (equivalently X-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 by X-Webhook-Timestamp. Keep a per-resource high-water mark and compare against created_at.
  • Branch on mode before fulfilling. TEST and LIVE events arrive at the same URL and are signed with the same secret — nothing else distinguishes them. A TEST event 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.succeeded can arrive before the payment.succeeded it refunds, and an invoice.paid before its payment.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-2xx only 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 — return 2xx and do nothing. Rejecting a stale event turns it into a retry loop for the full 6-hour horizon.
  • Acknowledge fast. Return any 2xx quickly and do slow work asynchronously; anything other than a 2xx (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.

GroupTypes
Paymentspayment.succeeded, payment.failed, payment.expired
Refundsrefund.pending, refund.succeeded, refund.failed
Invoicesinvoice.issued, invoice.partially_paid, invoice.paid, invoice.voided
Subscriptionssubscription.created, subscription.activated, subscription.trial_will_end, subscription.renewed, subscription.payment_succeeded, subscription.payment_failed, subscription.past_due, subscription.canceled, subscription.paused, subscription.resumed
Entitlementssubscription.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

TypeWhen
payment.succeededA payment was captured.
payment.failedThe gateway rejected the payment.
payment.expiredA pending payment timed out.

data:

FieldTypeRequiredNotes
payment_idnumberYesInternal payment id.
referencestringYesPublic payment reference (the gateway tran_id).
amount_minornumberYesMinor units (paisa).
currencystringYesBDT.
invoice_idnumber | nullYesThe settled invoice, or null for a standalone payment.
statusstringYessucceeded | failed | expired.
metadataobjectNoPresent 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

TypeWhen
refund.pendingThe gateway accepted the refund (settling async).
refund.succeededThe gateway confirmed the refund.
refund.failedThe gateway rejected/failed the refund.

data:

FieldTypeRequiredNotes
refund_idnumberYesInternal refund id.
payment_idnumberYesThe payment being refunded.
invoice_idnumber | nullYesThe related invoice, or null for a standalone payment.
amount_minornumberYesMinor units (paisa).
currencystringYesBDT.
statusstringYespending | 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

TypeWhen
invoice.issuedAn invoice was issued.
invoice.partially_paidAn installment settled part of the balance.
invoice.paidThe invoice was fully settled.
invoice.voidedThe invoice was voided before settlement.

data:

FieldTypeRequiredNotes
invoice_idnumberYesInternal invoice id.
numberstringYesPer-app invoice number, e.g. INV-000003.
total_minornumberYesInvoice total, minor units.
amount_paid_minornumberYesRunning amount settled after this transition.
amount_due_minornumberYestotal_minor − amount_paid_minor.
currencystringYesBDT.
subscription_idnumberNoPresent only on a subscription cycle invoice; absent for an ad-hoc invoice.
statusstringYesissued | 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).

TypeWhen
subscription.createdA subscription was created.
subscription.activatedIt became ACTIVE (first activation).
subscription.past_dueA renewal payment failed and it went PAST_DUE.
subscription.pausedIt was paused.
subscription.resumedIt returned to ACTIVE from PAUSED.
subscription.canceledIt was canceled.

Lifecycle data:

FieldTypeRequiredNotes
subscription_idnumberYesInternal subscription id.
product_idnumberYesThe subscribed product.
price_idnumberYesThe recurring price it bills on.
statusstringYescreated | 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.

TypeWhen
subscription.payment_succeededA cycle's payment succeeded.
subscription.payment_failedA cycle's payment failed.
subscription.renewedA cycle renewed and the next period started.
subscription.trial_will_endThe trial is about to end (reminder).

Notification data:

FieldTypeRequiredNotes
subscription_idnumberYesInternal subscription id.
product_idnumberYesThe subscribed product.
price_idnumberYesThe recurring price it bills on.
invoice_idnumberNoThe cycle invoice behind the notification; absent for trial_will_end (a reminder has no invoice).
statusstringYespayment_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

TypeWhen
subscription.entitlements_updatedA 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 boundaryTRIALING/ACTIVE ↔ anything else (both TRIALING and ACTIVE entitle, so a TRIALING → ACTIVE move 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):

FieldTypeRequiredNotes
customer_referencestringYesThe app's external customer id.
entitlementsstring[]YesThe 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:

SettingValueMeaning
Max attempts14Total delivery attempts before the event is parked FAILED.
Backoffmin(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~6hElapsed time from capture to parking, across all 14 attempts.
Connect timeout3sA delivery POST that cannot connect fails fast.
Response timeout8sA stalled consumer cannot pin the delivery thread.
Request timeout15sAbsolute 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_url parks the event immediately with an explanatory error, rather than burning the retry budget against a missing endpoint.
  • A LIVE event whose endpoint is not https parks immediately too, with lastError: "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.

On this page