Webhooks without surprises
Retries, duplicates and events that arrive out of order are not edge cases; they are what at-least-once delivery means. How to build a consumer that verifies, acknowledges fast, processes once and reconciles.
Written by KrokoPay teamPublished 8 min read
Webhooks look like the easy part of a payments integration: an HTTP endpoint that receives some JSON. The surprises come later. An order marked as paid twice. A refund that shows up before the payment it refunds. A deploy that answered 500 for ten minutes, and nobody noticed which customers were affected.
None of this is exotic. It is simply what at-least-once delivery means: every event will arrive, possibly more than once, in no guaranteed order. This article is how we would design a consumer for KrokoPay's webhooks, following the contract proposal in the docs. That contract is provisional, so the header format and the retry schedule may still change; the design below does not depend on their details.
Every event uses the same envelope. The id (evt_…) identifies the event and stays the same on every delivery attempt. type says what happened, created_at says when, and data.object holds the whole object as the API would return it at that moment.
{ "id": "evt_6Ff7Gg8Hh9Ii0J", "object": "event", "type": "charge.paid", "api_version": "2026-11-01", "created_at": "2026-11-03T14:23:41.000Z", "data": { "object": { "id": "chg_4Fp9KzW1xQm7Rt", "object": "charge", "status": "paid", "amount": 49700, "currency": "BRL", "method": "pix" } }}The catalog is small: charge.created, charge.paid, charge.failed, charge.expired, charge.refunded, the subscription.* lifecycle (created, renewed, past_due, paused, resumed, canceled) and payout.requested / payout.paid. Subscribe each endpoint only to the types it handles. An event you never receive is an event you never have to ignore correctly.
Each delivery carries a KrokoPay-Signature header shaped like t=<unix seconds>,v1=<hex>. The v1 value is an HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint's signing secret (whsec_test_… in the test environment). Recompute it and compare before trusting a single field.
- Parsing before verifying.
JSON.parsefollowed byJSON.stringifyrarely gives back the same bytes. Verify the body exactly as it arrived. - Comparing with
===. UsetimingSafeEqual, so the comparison does not leak how many characters matched. - Ignoring the timestamp. Without a tolerance window, a captured request can be replayed forever. The proposal has not fixed the window yet; choose one and keep it in one place.
- Accepting a single
v1. The header may carry more than one while a secret is being rotated. Accept the delivery if any of them matches.
import { createHmac, timingSafeEqual } from "node:crypto";const TOLERANCE_SECONDS = 300; // not fixed by the proposal yet: pick yoursexport function verifySignature(rawBody: string, header: string | null, secret: string): boolean { if (!header || !secret) return false; let timestamp = Number.NaN; const received: string[] = []; for (const part of header.split(",")) { const [key, value] = part.trim().split("=", 2); if (key === "t") timestamp = Number(value); if (key === "v1" && value) received.push(value); } if (!Number.isFinite(timestamp) || received.length === 0) return false; if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false; const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest(); return received.some((hex) => { const signature = Buffer.from(hex, "hex"); return signature.length === expected.length && timingSafeEqual(signature, expected); });}A delivery is an HTTP request with a timeout. Every slow thing you do inside the handler, like sending an email, calling your ERP or rendering an invoice, increases the chance of answering too late. A late answer counts as a failure, a failure is retried, and a retry is a duplicate you now have to handle. The handler should do three things only: verify, store, acknowledge.
// A fetch-style route: Request in, Response out.export async function POST(request: Request): Promise<Response> { const rawBody = await request.text(); // the exact bytes, before any parsing const header = request.headers.get("KrokoPay-Signature"); if (!verifySignature(rawBody, header, process.env.KROKOPAY_WEBHOOK_SECRET ?? "")) { return new Response("Invalid signature", { status: 400 }); } const event = JSON.parse(rawBody) as { id: string; type: string; created_at: string }; // The inbox has a unique index on event_id: a repeated delivery inserts nothing. const inserted = await db.query( "INSERT INTO webhook_inbox (event_id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING", [event.id, event.type, rawBody], ); if (inserted.rowCount === 1) await queue.push("krokopay-events", event.id); return new Response(null, { status: 200 }); // stored: acknowledge right away}| Your endpoint answers | What it means for the delivery |
|---|---|
Any 2xx | Delivered. The body is ignored. |
| Any other status | Failed. Retried later with the same event id. |
| Nothing before the timeout | Failed and retried. The timeout is to be defined. |
| Connection refused or TLS error | Failed and retried. |
Answer 2xx for event types you do not handle, so they are not retried for nothing. Answer 400 for a bad signature: it will show up as a failed delivery in the dashboard, which is exactly where you want to notice a misconfigured secret.
The unique index on event_id catches the same event delivered twice. It does not catch two different paths that lead to the same effect, such as a webhook and a reconciliation job both deciding an order is paid. The fix belongs to the side effect itself: write each change as a conditional transition, so applying it a second time changes nothing.
// Runs in the worker. Safe to run twice for the same charge.async function onChargePaid(chargeId: string, paidAt: string): Promise<void> { const result = await db.query( "UPDATE orders SET status = 'paid', paid_at = $2 WHERE charge_id = $1 AND status = 'awaiting_payment'", [chargeId, paidAt], ); // Only the run that actually changed the row sends the email. if (result.rowCount === 1) await sendReceiptEmail(chargeId);}Information: The same idea, in the other direction
Idempotency-Key header so a retried request does not create a second charge. See Idempotency.A retried charge.paid can land after the charge.refunded that followed it. A state machine that moves one step per event, in the order events arrive, will eventually move an order backwards. Three rules keep it straight:
- Decide from the object, not the event name.
data.object.statusis the state at the time of the event. Treat the type as a hint about what changed. - Discard stale events. Store the
created_atof the last event you applied per object and skip anything older. - Only move forward. A charge goes from
pendingto one outcome (paid,failedorexpired), and only a paid charge becomespartially_refundedorrefunded. Write your transitions so a latepaidcan never undo arefunded.
When in doubt, ask the source. Fetching the charge with GET /v1/charges/{id} returns its current state and settles any argument between two events.
Retries cover short outages. They do not cover an endpoint that stayed down for longer than the retry schedule, a bug that answered 200 and then dropped the job, or a queue that was purged by mistake. Assume a gap will happen and plan the job that closes it.
- Every few minutes, select the orders that have been awaiting payment for longer than you expect a payer to take.
- For each one, read the charge from the API and apply its status through the same idempotent transition the worker uses.
- Log what reconciliation fixed. If it fixes things often, the bug is in the consumer, not in the job.
# For an order still awaiting payment, ask for the charge's current state.curl https://api.krokopay.app/v1/charges/chg_4Fp9KzW1xQm7Rt \ -H "Authorization: Bearer kp_test_sk_4f9a2c7e1b8d4a6f9c3e5d7b"Filters and pagination on the list endpoint are still to be defined in the proposal, which is one more reason to reconcile from your own pending orders rather than by scanning everything.
The happy path will work the first time. Spend the testing time on the cases above, deliberately:
- Send the same signed payload twice and check that the second one changes nothing.
- Send
charge.refundedbeforecharge.paidand check the order ends refunded. - Sign with the wrong secret and check you answer
400. - Make your endpoint answer
500once, then watch the retry arrive with the same eventidin Developers → Webhooks.
secret="whsec_test_2e4c6a8b0d1f3a5c7e9b"payload='{"id":"evt_test_local","object":"event","type":"charge.paid","api_version":"2026-11-01","created_at":"2026-11-03T14:23:41.000Z","data":{"object":{"id":"chg_test_local","object":"charge","status":"paid"}}}'t=$(date +%s)sig=$(printf '%s.%s' "$t" "$payload" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/^.* //')# Send it twice: the second delivery must change nothing.for i in 1 2; do curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/webhooks/krokopay \ -H "Content-Type: application/json" \ -H "KrokoPay-Signature: t=$t,v1=$sig" \ -d "$payload"doneEverything here runs against the test environment and your own machine: no request leaves it, and no real money moves.
- Verify the signature on the raw body, in constant time, with a timestamp window.
- Store the event and answer
2xxbefore doing any real work. - Deduplicate on the event
id, and make every side effect a conditional transition. - Decide from
data.object, discard stale events, and only move states forward. - Reconcile pending orders against the API on a schedule.
- Test duplicates, reordering, bad signatures and failures before launch.
The full contract, including the event catalog and a ready verification handler, is in the webhooks docs. For what the buyer sees while all of this happens, read Anatomy of a checkout that converts, or see the developer side of the product on the developers page.
