Skip to content
KrokoPay
Back to the blog

Engineering

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.

Know what arrives

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.

charge.paid (trimmed)JSON
{  "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.

Verify the signature on the raw body

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.parse followed by JSON.stringify rarely gives back the same bytes. Verify the body exactly as it arrived.
  • Comparing with ===. Use timingSafeEqual, 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.
verify-signature.ts (Node standard library only)TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";​const TOLERANCE_SECONDS = 300; // not fixed by the proposal yet: pick yours​export 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);  });}

Answer in milliseconds, work in the background

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.

route.tsTypeScript
// 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 answersWhat it means for the delivery
Any 2xxDelivered. The body is ignored.
Any other statusFailed. Retried later with the same event id.
Nothing before the timeoutFailed and retried. The timeout is to be defined.
Connection refused or TLS errorFailed 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.

Make every side effect idempotent

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.

worker.tsTypeScript
// 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

When your server creates charges, send an Idempotency-Key header so a retried request does not create a second charge. See Idempotency.

Do not trust arrival order

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:

  1. Decide from the object, not the event name. data.object.status is the state at the time of the event. Treat the type as a hint about what changed.
  2. Discard stale events. Store the created_at of the last event you applied per object and skip anything older.
  3. Only move forward. A charge goes from pending to one outcome (paid, failed or expired), and only a paid charge becomes partially_refunded or refunded. Write your transitions so a late paid can never undo a refunded.

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.

Reconcile, because something will be missed

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.
Read one charge (test key)bash
# 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.

Test the ugly cases on purpose

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.refunded before charge.paid and check the order ends refunded.
  • Sign with the wrong secret and check you answer 400.
  • Make your endpoint answer 500 once, then watch the retry arrive with the same event id in Developers → Webhooks.
Sign locally and deliver twicebash
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"done

Everything here runs against the test environment and your own machine: no request leaves it, and no real money moves.

The short version

  1. Verify the signature on the raw body, in constant time, with a timestamp window.
  2. Store the event and answer 2xx before doing any real work.
  3. Deduplicate on the event id, and make every side effect a conditional transition.
  4. Decide from data.object, discard stale events, and only move states forward.
  5. Reconcile pending orders against the API on a schedule.
  6. 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.

  • Product7 min read

    Anatomy of a checkout that converts

    Few fields, a total that never moves, one clear method choice, declines explained in plain words and a state for every outcome. A walk through what a payment page must get right, with our own checkout as the example.

  • Product7 min read

    Designing subscription billing states

    Recurring billing is not a boolean. It is a small state machine with five states, a handful of events and a lot of product decisions. Here is how to model it, what to do in each state and the mistakes that cost the most.

  • Payments7 min read

    Pix explained for global teams

    Never shipped Pix before? Here is how it works from the payer's side, why it turns your checkout into a waiting room, which states and events to handle, and how to test the whole flow without moving money.

Try it in the test environment

Create an account to get test keys, open the hosted checkout and send yourself a signed webhook. Nothing real is charged.

Commercial terms are to be announced. See what pricing covers so far