Skip to content
JuicePay

JuicePay documentation

Create account

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#

bash
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.

ts
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.

ts
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.

ts
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.

bash
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#

bash
juicepay listen --forward-to localhost:3000/webhooks/juicepay

The CLI opens a tunnel, prints a signing secret, and can trigger any event type on demand:

bash
juicepay trigger payment_intent.succeeded --id pi_test_123

Checklist#

  • Raw body verified before parsing.
  • Event ID inserted with an ON CONFLICT DO NOTHING claim.
  • Handler returns 2xx within ten seconds.
  • State applied only when the event is newer than stored state.
  • Retired endpoints respond with 410.