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:
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:
| Framework | Raw body setup |
|---|---|
| Express | express.raw({ type: 'application/json' }) on the route |
| Fastify | fastify.addContentTypeParser('application/json', { parseAs: 'string' }, …) |
| Next.js (route handlers) | await req.text() — already raw |
| Hono | await 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—falsefor test-mode events. Always branch on this.data— the event payload (shape depends ontype).
Event catalog
| Event | When it fires | Payload highlights |
|---|---|---|
invoice.created | An invoice is created | Invoice object |
invoice.paid | An invoice settles | Invoice + preimage, paymentHash, paidAt |
invoice.expired | An unpaid invoice passes its expiry | Invoice object |
payment_link.paid | A payment-link invoice settles | Link id, payer note, invoice |
zap.received | A NIP-57 zap settles | Zap request JSON, amount, sender pubkey |
subscription.created | A subscription starts | Subscription + plan + customer |
subscription.renewed | A recurring payment succeeds | Subscription + plan + customer |
subscription.past_due | A recurring payment fails | Subscription + plan + customer |
subscription.canceled | A subscription is canceled | Subscription + plan + customer |
withdrawal.completed | A withdrawal settles | Withdrawal object |
withdrawal.failed | A withdrawal fails | Withdrawal object + error |
refund.completed | A refund settles | Refund + original invoice |
refund.failed | A refund fails | Refund + original invoice + error |
test | A test delivery from the dashboard | Synthetic 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: truein 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
2xximmediately 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/testsends a realtestevent 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 modeThen trigger a test event from the dashboard and watch it arrive locally.