KortiDocs

Webhooks

Korti sends a signed POST to your server when something happens to a pass, so you don’t have to keep asking.

Add an endpoint

In the dashboard, open Developers and choose Add endpoint. Enter an https:// address and pick the events you want. Korti shows the endpoint’s signing secret once. Save it with your other secrets.

Choose Send test event to check it works. Korti sends a test event straight away and shows what your server answered.

Events

EventSent when
pass.issuedA pass was created, from the API, the dashboard or a share link.
pass.addedA customer added the pass to Apple Wallet, or saved it to Google Wallet (Google confirms the save).
pass.removedA customer removed the pass from Apple Wallet or Google Wallet.
pass.scannedStaff scanned the pass at the counter.
pass.updatedStamps, points, balance or details changed.
pass.redeemedThe customer got their reward.
pass.voidedThe pass was voided and no longer works.

data.detail says exactly what happened, for example stamp_added, points_added or balance_changed for pass.updated, and apple_added or google_saved for pass.added, and apple_removed or google_removed for pass.removed. The same events are in GET /v1/events, in the same shape, if you’d rather poll.

What Korti sends

A POST with a JSON body. To fetch the whole pass, call GET /v1/passes/{passId}.

Request
POST /korti-webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Korti-Webhooks/1
Korti-Event: pass.updated
Korti-Delivery: 5d0b7a6e-2f3c-4f0e-9c1a-7b8e6d5c4a3b
Korti-Signature: t=1760091871,v1=4f1c9a…e83b

{
  "id": "evt_1042",
  "type": "pass.updated",
  "test": false,
  "createdAt": "2026-10-10T10:24:31.512Z",
  "data": {
    "passId": "b7d3f0a2-1c4e-4f5a-8b6d-9e0f1a2b3c4d",
    "cardId": "3e0c5d1a-8f55-4a62-9a63-2f0b1f6c7d21",
    "detail": "stamp_added",
    "stamps": 4
  }
}

test is true when the event is for a pass a test key (sk_test_…) issued, and false for every other event, so you can rely on the field. Test passes send the same events as any other pass. Skip them in your own records if you only want real customers. GET /v1/events carries the same field.

Check the signature

Anyone can POST to your endpoint, so check every request came from Korti. The Korti-Signature header has a Unix timestamp t and a signature v1: the hex HMAC-SHA256 of t, a full stop and the raw request body, keyed with your endpoint’s signing secret.

import { createHmac, timingSafeEqual } from "node:crypto";

/** True if the request came from Korti in the last 5 minutes. */
export function verifyKortiSignature(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const timestamp = Number(parts.t);
  if (!parts.v1 || !timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

// Next.js route handler. Read the body as text: re-serialising parsed JSON changes the bytes.
export async function POST(request: Request) {
  const body = await request.text();
  const ok = verifyKortiSignature(body, request.headers.get("korti-signature") ?? "", process.env.KORTI_WEBHOOK_SECRET!);
  if (!ok) return new Response("Bad signature", { status: 400 });

  const event = JSON.parse(body);
  if (event.type === "pass.updated") {
    // Update your own records. Reply quickly; do slow work later.
  }
  return new Response(null, { status: 204 });
}

Heads upReject requests whose timestamp is more than 5 minutes old. Otherwise someone who recorded one webhook could send it to you again later.

Answering and retries

Answer with any 2xx status within 10 seconds. Anything else, or no answer, counts as a failure, and Korti tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After the sixth failed attempt the delivery is marked failed. You can see every delivery and its response code in the dashboard.

  • Reply first and do slow work afterwards, so you stay inside the 10 seconds.
  • A retry can arrive after a later event. Use createdAt or fetch the pass when order matters.
  • You may get the same event twice. Use id to ignore repeats.
  • While an endpoint is off, Korti doesn’t queue events for it, so turning it back on doesn’t send what you missed. Use GET /v1/events to catch up.

Addresses Korti will send to

Endpoints must use https:// on the standard port (443); a URL with another port is refused. Korti won’t deliver to private network addresses, such as 10.0.0.0/8 or 192.168.0.0/16, even through a public host name. To try webhooks on your own computer, use a tunnel such as ngrok or Cloudflare Tunnel.