Skip to content
Dashboard

Warning: Provisional contract proposal

These endpoints are not operational and no SDK is published. Examples use test identifiers only and nothing is sent anywhere.

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.

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

Endpoint: POST/v1/webhook_endpoints

Body 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

The 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

EventSent when
charge.createdA charge was created (API, payment link or subscription period).
charge.paidThe payment was confirmed. Fulfil the order here.
charge.failedA card attempt was declined. See failure_code.
charge.expiredA Pix charge was not paid before expires_at.
charge.refundedA full or partial refund was made. Read refunded_amount and status.
subscription.createdA subscription started.
subscription.renewedA new period was paid.
subscription.past_dueA renewal charge failed.
subscription.pausedThe subscription was paused.
subscription.resumedA paused subscription is active again.
subscription.canceledThe subscription ended.
payout.requestedA payout of your available balance was requested.
payout.paidA 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.
charge.paidJSON
{  "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"    }  }}
charge.refundedJSON
{  "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.

A deliveryHTTP
POST /webhooks/krokopay HTTP/1.1Host: example.comContent-Type: application/jsonKrokoPay-Signature: t=1793715821,v1=9f2c4e1a7b3d5f8e0a6c2b4d9e1f3a5c7b9d0e2f4a6c8b1d3e5f7a9c0b2d4e6f​{ "id": "evt_6Ff7Gg8Hh9Ii0J", "object": "event", "type": "charge.paid", ... }
  1. Step 1: Read the raw body

    Use the exact bytes received. Parsing and re-serialising the JSON changes them and breaks the signature.

  2. Step 2: Parse the header

    Take t and every v1 value. More than one v1 may appear, for example while a secret is being rotated.

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

  4. Step 4: Compare in constant time

    Compute the HMAC and compare with timingSafeEqual, never with ===.

Verify and acknowledge (Node standard library only)TypeScript
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

The header format above is part of the provisional contract and may change before it is final. Keep the verification in one function so it is easy to update.

Respond with 2xx, quickly

  • Answer any 2xx status 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 2xx for event types you ignore, so they are not retried.
Route events in your workerTypeScript
// 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.paid may arrive before charge.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 compare created_at to discard stale events.
  • Make side effects idempotent. Marking an order as paid twice should be a no-op.

Testing webhooks

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

  2. Step 2: Trigger real flows

    Pay a test charge on the checkout with the test cards: each outcome emits its own event.

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

Sign and send a local test eventbash
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"