Skip to Content
Devnet open · mainnet by application — these docs ship with the pre-alpha release.
IntegrationsWebhooks

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 by Webhook.secret
  • Familiar shape: the signature-header format follows the established Stripe-Signature convention, 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:

  1. Read the raw request body (do not parse JSON before verifying).
  2. Pull X-NinjaPay-Signature; split on , and parse t=...,v1=....
  3. Compute HMAC-SHA256(secret, "<t>.<rawBody>").
  4. Compare with v1 using a constant-time check.
  5. 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 → typed WebhookEvent<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.

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; prefix evt_. 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.
  • livemodefalse for events from nk_test_ integrations.
  • data.object — the resource snapshot at event time.
  • request.idempotency_key — when the event was triggered by an API call that carried an Idempotency-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.

ResourceEvent typesPrimary “completed” hook
Refundrefund.created, refund.updated, refund.succeeded, refund.failed, refund.canceledrefund.succeeded
Connected account (Connect)account.updatedaccount.updated
Transfer (Connect)transfer.created, transfer.paid, transfer.failed, transfer.reversedtransfer.paid
Application fee (Connect)application_fee.created, application_fee.refundedapplication_fee.created
Invoiceinvoice.created, invoice.finalized, invoice.paid, invoice.payment_failed, invoice.voided, invoice.marked_uncollectibleinvoice.paid
Subscriptioncustomer.subscription.created, customer.subscription.updated, customer.subscription.deleteddepends 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:

AttemptDelay before next
1 → 230 seconds
2 → 31 minute
3 → 45 minutes
4 → 530 minutes
5 → 61 hour
6 → 72 hours
7 → 84 hours
8 (final)DEAD — no further auto-retry

After DEAD, the merchant has two recovery paths:

  1. 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.
  2. Operator escalation: ops can replay via POST /v1/admin/webhooks/dlq/:id/replay with 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).