Documentation
Handle webhooks reliably
Verify signatures, deduplicate deliveries, and replay safely.
Webhooks are the only reliable way to learn what happened. This guide covers the consumer contract: verifying, deduplicating, ordering, and recovering.
Register an endpoint#
curl -s -X POST "$JUICEPAY_URL/v1/webhook_endpoints" \
-H "Authorization: Bearer $JUICEPAY_KEY" \
-d '{
"url": "https://api.example.com/webhooks/juicepay",
"enabled_events": [
"payment_intent.succeeded",
"payment_intent.expired",
"payout.settled",
"payout.failed",
"swap.settled"
]
}'Subscribe narrowly. An endpoint receiving every event type will eventually fall behind during a large batch run, and the events it drops will be the ones that mattered.
Verify the signature#
Signatures cover the raw request body. If your framework parses JSON before you verify, verification fails — always read the body as text first.
export async function POST(request: Request) {
const raw = await request.text();
const signature = request.headers.get("juicepay-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: JuicePayEvent;
try {
event = juicepay.webhooks.constructEvent(
raw,
signature,
process.env.JUICEPAY_WEBHOOK_SECRET!,
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
await enqueue(event);
return Response.json({ received: true });
}The header has the form t=1755594842,v1=5f8a.... constructEvent compares against v1 and rejects timestamps older than five minutes, which bounds replay damage.
Deduplicate on event ID#
Deliveries are at-least-once. Expect duplicates, especially after a deploy or a network blip.
async function process(event: JuicePayEvent) {
const claimed = await db.query(
`INSERT INTO processed_events (id, type, received_at)
VALUES ($1, $2, now())
ON CONFLICT (id) DO NOTHING
RETURNING id`,
[event.id, event.type],
);
if (claimed.rowCount === 0) {
// Already handled. Ack without doing anything.
return;
}
await handle(event);
}Use the insert's result as the lock. A read-then-write check races when two deliveries arrive simultaneously — which is precisely when duplicates are most likely.
Do not rely on ordering#
Events are delivered in the order they are generated, but retries can reorder them. Treat every event as self-describing and apply it only if it is newer than what you have stored.
await db.query(
`UPDATE payments
SET status = $1, updated_at = $2
WHERE intent_id = $3
AND updated_at < $2`,
[nextStatus, event.created, event.data.object.id],
);That updated_at < $2 guard is what stops a retried older event from clobbering newer state.
Respond quickly#
Return 2xx within ten seconds. Do the work asynchronously: verify, insert, enqueue, respond. A handler that awaits a slow downstream call will time out and earn itself a retry storm.
Anything other than 2xx is treated as a failure and retried with exponential backoff for up to 24 hours. A 410 Gone disables the endpoint permanently, which is the correct response for an endpoint you have deliberately retired.
Recover from a gap#
Every endpoint keeps a 7-day delivery log. If your consumer was down, replay the window.
curl -s -X POST "$JUICEPAY_URL/v1/webhook_endpoints/we_5mK2/replay" \
-H "Authorization: Bearer $JUICEPAY_KEY" \
-d '{ "from": "2026-08-18T00:00:00Z", "to": "2026-08-19T00:00:00Z" }'Replays are delivered as duplicates of the original events, with the same event IDs — which is exactly why deduplication on event ID is not optional.
Local development#
juicepay listen --forward-to localhost:3000/webhooks/juicepayThe CLI opens a tunnel, prints a signing secret, and can trigger any event type on demand:
juicepay trigger payment_intent.succeeded --id pi_test_123Checklist#
- Raw body verified before parsing.
- Event ID inserted with an
ON CONFLICT DO NOTHINGclaim. - Handler returns
2xxwithin ten seconds. - State applied only when the event is newer than stored state.
- Retired endpoints respond with
410.