ZapAPI Docs
Get StartedConceptsAI GatewayGuidesPayment GatewaySDKAPI ReferenceResources
Concepts

Webhooks

Signed event delivery with retries, replay and a delivery log — how to consume ZapAPI webhooks reliably.

Webhooks are how ZapAPI tells your backend that something happened — an invoice was paid, a zap was received, a subscription renewed. They are signed so you can verify authenticity, retried with exponential backoff, and replayable from the dashboard.

The lifecycle

Subscribe

Create a webhook endpoint with the events you care about. The signing secret is shown once — store it immediately.

Receive

ZapAPI POSTs a signed JSON payload to your endpoint when a subscribed event occurs.

Verify

Verify the X-ZapAPI-Signature header against the raw request body using your signing secret. Reject unsigned or stale requests.

Respond

Return a 2xx within 10 seconds. Anything else triggers the retry ladder.

Signature verification

Every delivery is signed with a Stripe-style t=,v1= scheme. Verify using the raw request body (not the parsed JSON) and the signing secret shown once when you created the endpoint:

Express webhook handler
import express from 'express';
import { ZapAPI } from '@zapapi/sdk';

const app = express();

app.post(
  '/webhooks/zapapi',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = ZapAPI.webhooks.verify(
      req.body.toString(),
      req.headers['x-zapapi-signature'] as string,
      process.env.ZAPAPI_WEBHOOK_SECRET!,
    );
    if (!ok) return res.sendStatus(400);

    const event = JSON.parse(req.body.toString());
    switch (event.type) {
      case 'invoice.paid':
        // fulfill the order — event.data.paymentHash, event.data.preimage
        break;
      case 'zap.received':
        // someone zapped you — event.data.amountSats, event.data.senderPubkey
        break;
    }
    res.json({ received: true });
  },
);

Replay protection

Events older than 5 minutes are rejected by default. Tune with the fourth parameter: ZapAPI.webhooks.verify(raw, header, secret, toleranceSeconds).

Other frameworks — just make sure you pass the raw body:

FrameworkRaw body setup
Expressexpress.raw({ type: 'application/json' }) on the route
Fastifyfastify.addContentTypeParser('application/json', { parseAs: 'string' }, …)
Next.js (route handlers)await req.text() — already raw
Honoawait c.req.text() — already raw

The event envelope

{
  "id": "evt_8f3…",
  "type": "invoice.paid",
  "livemode": true,
  "createdAt": "2026-10-25T00:00:00.000Z",
  "data": {
    "id": "inv_8f3…",
    "status": "paid",
    "paymentHash": "a41c…",
    "preimage": "9f2b…",
    "amountSats": 21000
  }
}
  • id — unique event id, stable across retries and replays. Use it to dedupe.
  • type — the event type (see the catalog below).
  • livemode — false for test-mode events. Always branch on this.
  • data — the event payload (shape depends on type).

Event catalog

EventWhen it firesPayload highlights
invoice.createdAn invoice is createdInvoice object
invoice.paidAn invoice settlesInvoice + preimage, paymentHash, paidAt
invoice.expiredAn unpaid invoice passes its expiryInvoice object
payment_link.paidA payment-link invoice settlesLink id, payer note, invoice
zap.receivedA NIP-57 zap settlesZap request JSON, amount, sender pubkey
subscription.createdA subscription startsSubscription + plan + customer
subscription.renewedA recurring payment succeedsSubscription + plan + customer
subscription.past_dueA recurring payment failsSubscription + plan + customer
subscription.canceledA subscription is canceledSubscription + plan + customer
withdrawal.completedA withdrawal settlesWithdrawal object
withdrawal.failedA withdrawal failsWithdrawal object + error
refund.completedA refund settlesRefund + original invoice
refund.failedA refund failsRefund + original invoice + error
testA test delivery from the dashboardSynthetic payload

Retries

Failed deliveries are retried automatically — 5 attempts with exponential backoff (1s → 2s → 4s → 8s → 16s). Only 2xx responses count as success; timeouts (10s) and non-2xx statuses trigger the retry ladder.

Delivery history, manual retry & replay

Every delivery attempt is recorded with its HTTP outcome, error message and duration. Query them, retry a single delivery, or replay a whole event:

GET /webhooks/deliveries?webhookId=…&event=invoice.paid&status=failed&page=1&limit=20
GET /webhooks/deliveries/stats
GET /webhooks/deliveries/:id

POST /webhooks/deliveries/:id/retry        → re-deliver to the original endpoint
POST /webhooks/events/:eventId/replay      → re-deliver to ALL endpoints that
                                             received the event originally
  • Replays deliver the stored payload byte-identically (same evt_… id) — consumers can dedupe on the event id (at-least-once semantics, like Stripe).
  • Endpoints deleted since the original delivery are skipped.
  • Manual retries and replays are flagged manual: true in the delivery log.

Best practices

  • Dedupe on event.id. At-least-once delivery means the same event can arrive more than once.
  • Respond fast, process async. Return 2xx immediately and process the event on a queue — slow handlers trigger retries.
  • Branch on livemode. Never fulfill a real order from a test event.
  • Verify before you trust. Always verify the signature before acting on the payload.
  • Use the test endpoint. POST /webhooks/:id/test sends a real test event through the full pipeline — use it to validate your handler end-to-end.

Testing locally

Use a tunnel (ngrok, cloudflared, localtunnel) to expose your local handler:

ngrok http 3000
# → https://abc123.ngrok.io → register as the webhook URL in test mode

Then trigger a test event from the dashboard and watch it arrive locally.

On this page