Reliability
Webhooks
KrokoPay sends an HTTPS POST to your server when something happens to a charge, subscription or payout. Verify the signature, answer 2xx quickly and process the event once.
On this page
Webhooks are how your system learns about outcomes that happen outside your request: a Pix paid minutes later, a declined card, a renewal at night. Instead of polling the API, register an endpoint and KrokoPay delivers each event to it as JSON.
Register an endpoint
Add endpoints in Developers → Webhooks or through the API. Each endpoint has a public HTTPS URL, the list of event types it wants and its own signing secret (whsec_test_… in the test environment).
/v1/webhook_endpointsBody parameters
urlstringRequired- HTTPS URL that receives the events.
eventsstring[]Required- Event types to deliver. See the catalog.
descriptionstringOptional- A note for your team, e.g. which service owns it.
activebooleanOptional- Defaults to
true. Inactive endpoints receive nothing.
curl -X POST https://api.krokopay.app/v1/webhook_endpoints \ -H "Authorization: Bearer kp_test_sk_4f9a2c7e1b8d4a6f9c3e5d7b" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/krokopay", "description": "Orders service", "events": [ "charge.paid", "charge.failed", "charge.expired", "charge.refunded" ] }'Update an endpoint with POST /v1/webhook_endpoints/{id} and remove it with DELETE /v1/webhook_endpoints/{id}.
Warning: Keep the secret on the server
secret is returned when the endpoint is created and is visible in the dashboard. Store it as an environment variable; anyone holding it can forge events for that endpoint.Event catalog
| Event | Sent when |
|---|---|
charge.created | A charge was created (API, payment link or subscription period). |
charge.paid | The payment was confirmed. Fulfil the order here. |
charge.failed | A card attempt was declined. See failure_code. |
charge.expired | A Pix charge was not paid before expires_at. |
charge.refunded | A full or partial refund was made. Read refunded_amount and status. |
subscription.created | A subscription started. |
subscription.renewed | A new period was paid. |
subscription.past_due | A renewal charge failed. |
subscription.paused | The subscription was paused. |
subscription.resumed | A paused subscription is active again. |
subscription.canceled | The subscription ended. |
payout.requested | A payout of your available balance was requested. |
payout.paid | A payout reached the destination account. |
Subscribe only to what you handle. For most stores, charge.paid, charge.failed, charge.expired and charge.refunded are enough; add the subscription.* events if you sell subscriptions.
Payload envelope
Every event has the same envelope. data.object is the full object as the API returns it at the moment of the event, so you rarely need an extra request.
Envelope
idstring- Event id (
evt_…). The same id is used for every delivery attempt: deduplicate on it. objectstring- Always
event. typestring- One of the types in the catalog.
api_versionstring- API version used to render
data.object, e.g.2026-11-01. created_atstring- When the event happened (ISO 8601).
data.objectobject- The charge, subscription or payout.
{ "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", "amount": 49700, "currency": "BRL", "status": "paid", "method": "pix", "card": null, "description": "Curso Identidade Visual do Zero", "customer_id": "cus_8Hq2LmZr4TxN1a", "product_id": "prod_3Vb7QpK9sWd2Ee", "price_id": "price_5Kd1NcR8vYq3Hm", "link_id": "plink_2Wn6HsT9cVb4Lq", "subscription_id": null, "fee": null, "net": null, "refunded_amount": 0, "failure_code": null, "created_at": "2026-11-03T14:22:05.000Z", "paid_at": "2026-11-03T14:23:41.000Z", "expires_at": "2026-11-03T14:37:05.000Z", "available_at": null, "checkout_url": "https://krokopay.app/pay/chg_4Fp9KzW1xQm7Rt" } }}{ "id": "evt_4Zz5Aa6Bb7Cc8D", "object": "event", "type": "charge.refunded", "api_version": "2026-11-01", "created_at": "2026-11-05T09:10:00.000Z", "data": { "object": { "id": "chg_4Fp9KzW1xQm7Rt", "object": "charge", "amount": 49700, "currency": "BRL", "status": "partially_refunded", "method": "pix", "card": null, "description": "Curso Identidade Visual do Zero", "customer_id": "cus_8Hq2LmZr4TxN1a", "product_id": "prod_3Vb7QpK9sWd2Ee", "price_id": "price_5Kd1NcR8vYq3Hm", "link_id": "plink_2Wn6HsT9cVb4Lq", "subscription_id": null, "fee": null, "net": null, "refunded_amount": 9900, "failure_code": null, "created_at": "2026-11-03T14:22:05.000Z", "paid_at": "2026-11-03T14:23:41.000Z", "expires_at": "2026-11-03T14:37:05.000Z", "available_at": null, "checkout_url": "https://krokopay.app/pay/chg_4Fp9KzW1xQm7Rt" } }}Verify signatures
Each delivery carries a KrokoPay-Signature header. The proposed format is t=<unix seconds>,v1=<hex>, where v1 is an HMAC-SHA256 of <t>.<raw body> keyed with the endpoint secret. Recompute it and compare before trusting the payload.
POST /webhooks/krokopay HTTP/1.1Host: example.comContent-Type: application/jsonKrokoPay-Signature: t=1793715821,v1=9f2c4e1a7b3d5f8e0a6c2b4d9e1f3a5c7b9d0e2f4a6c8b1d3e5f7a9c0b2d4e6f{ "id": "evt_6Ff7Gg8Hh9Ii0J", "object": "event", "type": "charge.paid", ... }Step 1: Read the raw body
Use the exact bytes received. Parsing and re-serialising the JSON changes them and breaks the signature.
Step 2: Parse the header
Take
tand everyv1value. More than onev1may appear, for example while a secret is being rotated.Step 3: Check the timestamp
Reject signatures that are too old, to block replayed requests. The tolerance window is to be defined; pick one for your integration until then.
Step 4: Compare in constant time
Compute the HMAC and compare with
timingSafeEqual, never with===.
import { createHmac, timingSafeEqual } from "node:crypto";// The endpoint's signing secret (whsec_test_…), kept in your environment.const WEBHOOK_SECRET = process.env.KROKOPAY_WEBHOOK_SECRET ?? "";// How old a signature may be. The proposal does not fix this value yet:// choose one for your integration and revisit it when it is defined.const TOLERANCE_SECONDS = 300;/** Parses "t=<unix seconds>,v1=<hex>" into its parts. */function parseSignatureHeader(header: string): { timestamp: number; signatures: string[] } { let timestamp = Number.NaN; const signatures: string[] = []; for (const part of header.split(",")) { const [key, value] = part.trim().split("=", 2); if (key === "t") timestamp = Number(value); if (key === "v1" && value) signatures.push(value); } return { timestamp, signatures };}export function verifySignature(rawBody: string, header: string | null, secret: string): boolean { if (!header || !secret) return false; const { timestamp, signatures } = parseSignatureHeader(header); if (!Number.isFinite(timestamp) || signatures.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 signatures.some((signature) => { const received = Buffer.from(signature, "hex"); return received.length === expected.length && timingSafeEqual(received, expected); });}// Your own persistence and job queue.declare function alreadyProcessed(eventId: string): Promise<boolean>;declare function enqueue(event: KrokoPayEvent): Promise<void>;interface KrokoPayEvent { id: string; type: string; created_at: string; data: { object: { id: string; object: string } };}// A fetch-style handler: Request in, Response out.export async function POST(request: Request): Promise<Response> { // Verify the raw body exactly as received, before parsing it. const rawBody = await request.text(); const header = request.headers.get("KrokoPay-Signature"); if (!verifySignature(rawBody, header, WEBHOOK_SECRET)) { return new Response("Invalid signature", { status: 400 }); } const event = JSON.parse(rawBody) as KrokoPayEvent; // Deliveries can repeat: acknowledge duplicates without redoing the work. if (await alreadyProcessed(event.id)) return new Response(null, { status: 200 }); // Hand off slow work (emails, fulfilment) and answer right away. await enqueue(event); return new Response(null, { status: 200 });}Information: Proposed format
Respond with 2xx, quickly
- Answer any
2xxstatus as soon as the signature is verified and the event is stored. The body is ignored. - Do slow work (emails, fulfilment, calls to other services) in a background job, after responding.
- Any other status, a timeout or a connection error counts as a failed delivery. The response timeout is to be defined.
- Answer
2xxfor event types you ignore, so they are not retried.
// Runs in your worker, after the webhook was acknowledged.// Re-read the object from the API when you need its latest state.async function handle(event: KrokoPayEvent): Promise<void> { switch (event.type) { case "charge.paid": await markOrderPaid(event.data.object.id); break; case "charge.failed": case "charge.expired": await releaseReservedStock(event.data.object.id); break; case "charge.refunded": await recordRefund(event.data.object.id); break; case "subscription.past_due": await notifyBillingProblem(event.data.object.id); break; default: // Ignore types you did not subscribe to or do not handle yet. break; }}Retries
A failed delivery is retried with the same event id and an increasing attempt number, which you can follow per endpoint in the dashboard. The retry schedule (intervals and the maximum number of attempts) is to be defined. You can also resend a delivery manually from the dashboard.
Ordering and duplicates
- Duplicates happen. A retry after a timeout may deliver an event you already processed. Store processed event ids and skip repeats.
- Order is not guaranteed.
charge.paidmay arrive beforecharge.created. Do not build state machines that assume arrival order. - Use the object's state. Decide from
data.object.status, or fetch the latest version with GET /v1/charges/{id} when in doubt, and comparecreated_atto discard stale events. - Make side effects idempotent. Marking an order as paid twice should be a no-op.
Testing webhooks
Step 1: Send a test event
In Developers → Webhooks, choose an endpoint and an event type and send it. The delivery appears in the list with its status and attempt.
Step 2: Trigger real flows
Pay a test charge on the checkout with the test cards: each outcome emits its own event.
Step 3: Test locally
Sign a payload yourself with your test secret and post it to your local server, as below. No request leaves your machine.
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"}}}'t=$(date +%s)sig=$(printf '%s.%s' "$t" "$payload" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/^.* //')curl -X POST http://localhost:3000/webhooks/krokopay \ -H "Content-Type: application/json" \ -H "KrokoPay-Signature: t=$t,v1=$sig" \ -d "$payload"Related
- Idempotency: the same safety for your requests to KrokoPay.
- Checkout: which events a payment emits.
- Subscriptions: events per subscription transition.