Webhook integration guide
Audience. Merchants integrating NinjaPay webhooks. Covers signature verification, sample payloads for every event type, retry semantics, and the redelivery API.
TL;DR
- Signature header:
X-NinjaPay-Signature: t=<unix-secs>,v1=<hex-hmac> - HMAC scheme: SHA-256 over
<unix-secs>.<raw request body>keyed byWebhook.secret - Familiar shape: the signature-header format follows the established
Stripe-Signatureconvention, so existing verification libraries work with a header-name swap. - Retry policy: exponential backoff up to 8 attempts before DEAD; merchant-side replay via
POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver. - Recommended verify tolerance: ±5 minutes timestamp drift.
Signature verification
The delivery worker signs every payload with HMAC-SHA256 over <unix-secs>.<raw request body> keyed by your Webhook.secret. On the receiver side:
- Read the raw request body (do not parse JSON before verifying).
- Pull
X-NinjaPay-Signature; split on,and parset=...,v1=.... - Compute
HMAC-SHA256(secret, "<t>.<rawBody>"). - Compare with
v1using a constant-time check. - Reject if
|now - t| > 5 minutes.
The SDK exports three composable helpers so you don’t have to reimplement these steps:
parseWebhookEvent— one-shot verify + JSON.parse → typedWebhookEvent<T>. Drop-in ergonomics; recommended for new integrations.verifyWebhookSignature— fine-grained verifier returning a discriminated{ ok, reason? }union.signWebhookPayload— test utility for forging valid signed payloads against your own handler.
Recommended: parseWebhookEvent from @ninjapay/sdk
One-shot verify + parse mirroring the established webhooks.constructEvent ergonomics from common payment SDKs. Throws WebhookSignatureError carrying the verifier’s reason as a typed reason discriminant on bad signatures; returns the parsed WebhookEvent<T> envelope on success.
import {
parseWebhookEvent,
WebhookSignatureError,
WEBHOOK_SIGNATURE_HEADER,
} from '@ninjapay/sdk/webhooks';
import type { Refund } from '@ninjapay/sdk';
import express from 'express';
const app = express();
app.use(
'/webhooks/ninjapay',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const event = parseWebhookEvent<Refund>({
secret: process.env.NINJAPAY_WEBHOOK_SECRET!,
rawBody: req.body.toString('utf8'),
headerValue: req.get(WEBHOOK_SIGNATURE_HEADER),
});
handle(event);
res.status(200).end();
} catch (e) {
if (e instanceof WebhookSignatureError) {
// e.reason ∈ { 'malformed_header' | 'no_v1_signature' |
// 'timestamp_too_old' | 'timestamp_too_new' |
// 'invalid_signature' }
return res.status(401).json({ error: e.reason });
}
throw e; // SyntaxError on a malformed (but signed) body, etc.
}
},
);Lower-level: verifyWebhookSignature from @ninjapay/sdk
The SDK ships the canonical verifier. Same primitive as the delivery worker — sender and receiver in lockstep, with constant-time signature comparison and discriminated-union result codes for clean UX branching.
import { verifyWebhookSignature, WEBHOOK_SIGNATURE_HEADER } from '@ninjapay/sdk/webhooks';
import express from 'express';
const app = express();
app.use(
'/webhooks/ninjapay',
// IMPORTANT: capture the raw body BEFORE express.json() consumes it.
express.raw({ type: 'application/json' }),
(req, res) => {
const result = verifyWebhookSignature({
secret: process.env.NINJAPAY_WEBHOOK_SECRET!,
rawBody: req.body.toString('utf8'),
headerValue: req.get(WEBHOOK_SIGNATURE_HEADER),
});
if (!result.ok) {
// result.reason ∈ { 'malformed_header' | 'no_v1_signature' |
// 'timestamp_too_old' | 'timestamp_too_new' |
// 'invalid_signature' }
return res.status(401).json({ error: result.reason });
}
const event = JSON.parse(req.body.toString('utf8'));
handle(event); // see "Typed handlers" in the SDK README
res.status(200).end();
},
);Default tolerance is ±5 minutes. Pass toleranceSeconds to
override. Use the WEBHOOK_SIGNATURE_HEADER constant so the
header name doesn’t typo into your code.
Hand-rolled reference (other languages / minimal-deps)
If you can’t add the SDK as a dependency (e.g. you’re on a
non-Node runtime, or running in a serverless context with strict
bundle limits), the verification primitive is straightforward
HMAC-SHA256. Reason codes here intentionally match the SDK’s
VerifyWebhookSignatureFailReason union so you can move between
implementations without rewriting your error handling.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verify(opts: {
readonly secret: string;
readonly rawBody: string;
readonly headerValue: string;
readonly nowSeconds?: number;
}):
| { ok: true }
| {
ok: false;
reason:
| 'malformed_header'
| 'no_v1_signature'
| 'timestamp_too_old'
| 'timestamp_too_new'
| 'invalid_signature';
} {
const parts: Record<string, string> = {};
for (const seg of opts.headerValue.split(',')) {
const [k, v] = seg.split('=');
if (k && v) parts[k.trim()] = v.trim();
}
const t = Number(parts.t);
if (!Number.isInteger(t) || t < 0) {
return { ok: false, reason: 'malformed_header' };
}
const v1 = parts.v1;
if (typeof v1 !== 'string') {
return { ok: false, reason: 'no_v1_signature' };
}
const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);
if (t < now - TOLERANCE_SECONDS) return { ok: false, reason: 'timestamp_too_old' };
if (t > now + TOLERANCE_SECONDS) return { ok: false, reason: 'timestamp_too_new' };
const expected = createHmac('sha256', opts.secret)
.update(`${t}.${opts.rawBody}`)
.digest();
const got = Buffer.from(v1, 'hex');
if (got.length !== expected.length || !timingSafeEqual(expected, got)) {
return { ok: false, reason: 'invalid_signature' };
}
return { ok: true };
}Port this to any language with HMAC-SHA256 + constant-time compare. The shape is industry-standard; existing verifier libraries work with a header-name swap.
Event envelope
Every event ships in this shape:
{
"id": "evt_2c4e8f1a9b3d4567890abcde",
"type": "payment_intent.paid",
"api_version": "2026-04-01",
"created": 1746230400,
"livemode": true,
"data": {
"object": { /* per-event object — see Sample payloads below */ }
},
"request": {
"id": "req_abc123",
"idempotency_key": "order_12345"
}
}id— unique event id; prefixevt_. Use this as your idempotency key for handlers — NinjaPay will redeliver this exact id on retry.type— one of the 36 event types: 12 PaymentIntent / dispute lifecycle below, plus standard<resource>.<verb>events for refunds, transfers, application fees, invoices, and subscriptions (see Resource events).api_version— frozen at the time of the subscribed merchant’s webhook config.created— Unix-seconds when the event was generated server-side. Distinct from the signature timestamp.livemode—falsefor events fromnk_test_integrations.data.object— the resource snapshot at event time.request.idempotency_key— when the event was triggered by an API call that carried anIdempotency-Key, that value flows through here.
Event types
Twelve event types ship today (packages/types/src/webhook.ts):
payment_intent.created
Fires when a PaymentIntent transitions from non-existent → CREATED.
{
"id": "evt_pi_created_001",
"type": "payment_intent.created",
"api_version": "2026-04-01",
"created": 1746230400,
"livemode": true,
"data": {
"object": {
"id": "pi_2c4e8f1a9b3d456",
"amount": "5.000000",
"currency": "USDC",
"mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"status": "CREATED",
"metadata": { "order_id": "ord_42" },
"created_at": "2026-05-03T00:00:00.000Z"
}
}
}payment_intent.awaiting_payment
The intent has been registered server-side and the hosted-checkout link is live. State machine: CREATED → AWAITING_PAYMENT.
payment_intent.utxo_observed
The scanner observed the shielded UTXO on-chain. State machine: AWAITING_PAYMENT → UTXO_OBSERVED. The data.object carries the umbra_commitment + umbra_derived_receiver so you can reconcile against the indexer if needed.
payment_intent.settlement_confirmed
The router-program tx confirmed; merchant funds are reconciled but not yet claimed. State machine: UTXO_OBSERVED → SETTLEMENT_CONFIRMED. data.object.settlement_tx_sig carries the signature.
payment_intent.succeeded
Composite event fired alongside the terminal happy-path transition (SETTLEMENT_CONFIRMED → CLAIMED for self-claim flows, or any transition to PAID). Useful for “payment is done — fulfil the order” handlers that don’t care about the settlement-vs-claim distinction.
payment_intent.paid
The merchant has the funds claimable on their wallet. Terminal happy-path. Most merchant integrations only need this event.
{
"id": "evt_pi_paid_001",
"type": "payment_intent.paid",
"api_version": "2026-04-01",
"created": 1746230460,
"livemode": true,
"data": {
"object": {
"id": "pi_2c4e8f1a9b3d456",
"amount": "5.000000",
"currency": "USDC",
"status": "PAID",
"settlement_tx_sig": "5kQy…",
"claim_tx_sig": "3aRt…",
"paid_at": "2026-05-03T00:01:00.000Z",
"metadata": { "order_id": "ord_42" }
}
}
}payment_intent.expired
The intent’s expires_at elapsed without a payer-side payment. Terminal failure.
payment_intent.canceled
Merchant cancelled the intent via POST /v1/intents/:id/cancel. Terminal failure.
payment_intent.settlement_failed
The router-program tx failed to confirm. Terminal failure (recoverable only via operator intervention).
payment_intent.refunded
A refund was issued against this intent. The intent itself stays in its terminal status; check data.object.amount_refunded and data.object.status to distinguish full vs partial refund.
charge.dispute.created
A new Dispute row was created. The data.object carries id, payment_intent, customer, amount, currency, reason, status (always NEEDS_RESPONSE on create), due_by (Unix-seconds), evidence (free-form). See the disputes integration guide for the full lifecycle.
charge.dispute.updated
Evidence was submitted via POST /v1/disputes/:id/submit-evidence. Status flips NEEDS_RESPONSE → UNDER_REVIEW.
charge.dispute.closed
A terminal transition fired (adjudicate, close-as-warning, or close-as-refunded). Always pairs with one of funds_reinstated or funds_withdrawn.
charge.dispute.funds_reinstated
The merchant kept the funds — outcome was WON or WARNING_CLOSED. Always paired with closed.
charge.dispute.funds_withdrawn
The merchant lost the funds — outcome was LOST or CHARGE_REFUNDED. The latter case also emits a separate payment_intent.refunded since a Refund row was created alongside. Always paired with closed.
Resource events
Beyond the PaymentIntent + dispute lifecycle above, NinjaPay emits standard <resource>.<verb> events for the rest of the merchant API surface. The shape is the same — data.object carries the resource snapshot at event time, with industry-standard naming so existing handlers port cleanly.
| Resource | Event types | Primary “completed” hook |
|---|---|---|
| Refund | refund.created, refund.updated, refund.succeeded, refund.failed, refund.canceled | refund.succeeded |
| Connected account (Connect) | account.updated | account.updated |
| Transfer (Connect) | transfer.created, transfer.paid, transfer.failed, transfer.reversed | transfer.paid |
| Application fee (Connect) | application_fee.created, application_fee.refunded | application_fee.created |
| Invoice | invoice.created, invoice.finalized, invoice.paid, invoice.payment_failed, invoice.voided, invoice.marked_uncollectible | invoice.paid |
| Subscription | customer.subscription.created, customer.subscription.updated, customer.subscription.deleted | depends on integration |
invoice.payment_failed is emitted by the dunning state machine
once per scheduled retry attempt (exponential backoff per the
org’s dunningRetryHours). Subscribe alongside invoice.paid if
you want a fully-driven payment-retry-status feed.
account.updated fires on every connected-account mutation —
status transitions, capability changes, metadata patches.
Onboarding-flow dashboards use it to drive UI state in real time
without polling.
Subscribe by listing the exact event names in the events array on POST /v1/webhooks (empty array = all events). The “primary completed hook” column is the one most merchants should subscribe to if they only want one event per resource lifecycle.
x402.attestation_recorded
The x402 facilitator issued an attestation against a UTXO commitment. Sub-pillar event; merchants rarely subscribe.
webhook.circuit_tripped
Self-referential event — fires when this webhook subscription’s circuit-breaker opens (sustained failures crossed the threshold). Consumer-of-last-resort: a separate ops-side hook that pages on-call when their primary integration is failing.
Retry semantics
Failed deliveries (non-2xx response, network error, timeout) retry with exponential backoff:
| Attempt | Delay before next |
|---|---|
| 1 → 2 | 30 seconds |
| 2 → 3 | 1 minute |
| 3 → 4 | 5 minutes |
| 4 → 5 | 30 minutes |
| 5 → 6 | 1 hour |
| 6 → 7 | 2 hours |
| 7 → 8 | 4 hours |
| 8 (final) | DEAD — no further auto-retry |
After DEAD, the merchant has two recovery paths:
- Self-serve redeliver:
POST /v1/webhooks/:webhookId/deliveries/:deliveryId/redeliver(Phase 7.6.λ.3). Flips the row to PENDING; the worker picks it up on the next tick. Available to OWNER / ADMIN / FINANCE roles. - Operator escalation: ops can replay via
POST /v1/admin/webhooks/dlq/:id/replaywith a written reason captured in the audit-of-audits stream.
Circuit-breaker
If the merchant endpoint fails persistently across many delivery attempts, the subscription’s circuit opens (Webhook.circuitState = OPEN). New events stop hitting that endpoint until the merchant either rotates the secret (resets the breaker) or the half-open probe succeeds.
A webhook.circuit_tripped event fires when this happens — subscribe an out-of-band channel (PagerDuty / on-call email / a separate webhook to a different integration) to catch it.
Idempotency
NinjaPay redelivers the same id on retries. Your handler MUST be idempotent against (eventId, eventType):
const seen = await db.eventLog.findUnique({ where: { eventId: event.id } });
if (seen !== null) {
// Already processed; ack and exit.
return res.status(200).end();
}
await db.$transaction([
db.eventLog.create({ data: { eventId: event.id, eventType: event.type } }),
// ... your business-side mutation
]);
res.status(200).end();Most event types are non-overlapping in their state-machine semantics, so you can dispatch by event.type without worrying about the “wait, what if I get paid but my handler ran for succeeded already?” race — the events are siblings, not stacked.
Exception: dispute closure. A single dispute terminal transition emits two events (e.g. funds_reinstated + closed, or funds_withdrawn + closed). Build your dispute handler to be order-agnostic — webhook delivery is per-subscription, not strictly ordered per-event. Pattern: dispatch on whichever event arrives first, ignore the duplicate via the idempotencyKey check below.
Testing locally
The dashboard’s webhook detail page (/dashboard/webhooks/[id]) ships a delivery inspector that shows the request body + response code + last error. The same data is available via SDK: client.webhooks.listDeliveries(webhookId, { status: 'FAILED' }).
Forge signed payloads in your own tests
The SDK exports signWebhookPayload — the inverse of verifyWebhookSignature — so your handler tests don’t need a live NinjaPay subscription firing real events:
import { signWebhookPayload, WEBHOOK_SIGNATURE_HEADER } from '@ninjapay/sdk/webhooks';
const body = JSON.stringify({
id: 'evt_test_001',
type: 'payment_intent.paid',
data: { object: { /* ... */ } },
});
const { headerValue } = signWebhookPayload({ secret: 'whsec_...', rawBody: body });
const res = await fetch('http://localhost:3000/webhooks/ninjapay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[WEBHOOK_SIGNATURE_HEADER]: headerValue,
},
body,
});
expect(res.status).toBe(200);Pass an explicit timestamp to exercise the verifier’s stale-payload + future-payload branches:
// Replay attempt — a payload signed 10 minutes ago.
const stale = signWebhookPayload({
secret: 'whsec_...',
rawBody: body,
timestamp: Math.floor(Date.now() / 1000) - 600,
});
// The merchant handler should reject this with the verifier's
// `timestamp_too_old` reason (default ±5min tolerance).Related
packages/webhook-delivery/src/signature.ts— sign + verify primitives.packages/types/src/webhook.ts— event type catalog + envelope schema.- ADR-0013 — webhook worker partitioning + SSRF protection.
apps/dashboard/src/app/dashboard/webhooks/[id]/page.tsx— merchant-side delivery inspector.