OfferKitDocs

Webhooks

Receive and verify signed OfferKit webhooks, handle duplicate deliveries safely, and recover failed events while accounting for operations with no event.

Webhooks notify your server after OfferKit records an event. Use them for downstream side effects such as messages, analytics, fulfillment, and reconciliation. Do not use a webhook as the only record of a checkout result; store the direct API outcome with the order.

Create an endpoint

Open Webhooks in the dashboard and create an endpoint, or call POST /api/v1/webhooks. Select * for every event or list the event types the handler understands.

The plaintext signing secret is shown once. Store it in your server’s secret manager.

Receive the raw request

Each delivery is a JSON POST:

{
  "id": "event-uuid",
  "type": "voucher.redeemed",
  "payload": {
    "redemptionId": "00000000-0000-4000-8000-000000000001",
    "voucherId": "00000000-0000-4000-8000-000000000002",
    "voucherCode": "WELCOME10",
    "customerId": null,
    "orderId": null,
    "externalOrderId": "order-42",
    "amount": 1000,
    "finalOrder": { "amount": 4000, "currency": "USD" }
  },
  "createdAt": "2026-05-08T22:25:00Z"
}

Headers include:

  • X-Offerkit-Event-Type
  • X-Offerkit-Event-Id
  • X-Offerkit-Signature: t=<unix>,v1=<hex>

Preserve the unparsed body bytes for signature verification.

Verify the signature

The signature is HMAC-SHA256(secret, "<t>.<rawBody>"). The TypeScript SDK provides constant-time verification and rejects old timestamps by default:

import { verifyWebhook } from "@offerkit/sdk";

export async function handleOfferKitWebhook(request: Request) {
  const rawBody = await request.text();
  const signature = request.headers.get("x-offerkit-signature") ?? "";

  if (!verifyWebhook(rawBody, signature, process.env.OFFERKIT_WEBHOOK_SECRET!)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(rawBody);
  // acceptOnce is your durable inbox: atomically insert event.id under a
  // unique constraint and enqueue the event in the same database transaction.
  await acceptOnce(event.id, event);
  return new Response(null, { status: 204 });
}

acceptOnce is application code, not an SDK helper. For example, insert an inbox row keyed by event ID with ON CONFLICT DO NOTHING, and create a pending-work row only if the insert succeeds, in the same transaction. A worker reads pending rows, performs the side effect with its own idempotency key, and marks the work complete. A process crash after sending an email but before marking completion can still resend it unless your delivery provider also deduplicates the event ID.

Never verify a re-serialized JSON object. Whitespace or key-order changes produce a different signature.

Make processing idempotent

Deliveries are at least once. Store the event ID in the same transaction as the side effect or enqueue it into an idempotent worker. If the event was already processed, return success without repeating the action.

Respond quickly after durable acceptance. Do slow email, analytics, or fulfillment work asynchronously rather than holding the HTTP request open.

Retries and replay

Non-2xx responses and transport errors retry on this schedule:

1 minute → 5 minutes → 30 minutes → 2 hours → 12 hours → 24 hours → dead

The webhook detail page shows response status, body, error, attempt count, and next retry. Replay creates a new attempt for the same event while preserving delivery history. Your event-ID deduplication must therefore work for manual replays too.

Current event catalog

TypeWhen it is emittedPayload fields
customer.createdCustomer created through create or upsertcustomerId, email, name, externalId
voucher.redeemedSuccessful single discount-voucher redemptionredemptionId, voucherId, voucherCode, customerId, orderId, externalOrderId, amount, finalOrder
voucher.stack.redeemedSuccessful discount stackbatchId, customerId, orderId, externalOrderId, amount, finalOrder, entries
voucher.redeemed.rolled_backInternal rollback functionrollbackRedemptionId, originalRedemptionId, voucherId, amount

Each stack entry contains voucherCode, voucherId, redemptionId, and amount. A stack emits its batch event rather than a separate single-redemption event per voucher. amount is integer minor units; finalOrder contains the remaining amount and currency. Customer profile fields and optional order/customer references can be null.

There is no public rollback endpoint, so a normal API client cannot trigger the rollback event. Gift-card spend also has no event in the current redemption path. There are no dedicated events for loyalty earning or spending, referral conversion, automatic-promotion qualification, or general configuration edits. Store their direct outcomes and trigger your own application work. Do not wait for a nonexistent referral webhook before delivering its rewards.

Other payload examples, shown without the common event envelope:

{
  "customerId": "00000000-0000-4000-8000-000000000003",
  "email": "maya@example.com",
  "name": "Maya",
  "externalId": "user-42"
}
{
  "batchId": "00000000-0000-4000-8000-000000000004",
  "customerId": null,
  "orderId": null,
  "externalOrderId": "order-43",
  "amount": 1000,
  "finalOrder": { "amount": 4000, "currency": "USD" },
  "entries": [
    {
      "voucherCode": "WELCOME10",
      "voucherId": "00000000-0000-4000-8000-000000000002",
      "redemptionId": "00000000-0000-4000-8000-000000000005",
      "amount": 1000
    }
  ]
}
{
  "rollbackRedemptionId": "00000000-0000-4000-8000-000000000006",
  "originalRedemptionId": "00000000-0000-4000-8000-000000000001",
  "voucherId": "00000000-0000-4000-8000-000000000002",
  "amount": 1000
}

Subscribe to * only if your handler safely ignores unknown future event types. Prefer an explicit default branch that records and acknowledges an unsupported type.

Test before launch

  • a valid signature;
  • a modified body;
  • an old timestamp;
  • the same event delivered twice;
  • a handler timeout followed by retry;
  • an unknown event type;
  • replay from the dashboard;
  • secret rotation in your deployment process.

Rotate a signing secret

There is no in-place signing-secret rotation API. Create a replacement subscription and store its new secret. During overlap, let the receiver verify against either secret and deduplicate by event ID across both subscriptions. Drain or reconcile pending deliveries on the old subscription, then disable it and remove its secret from the receiver.

The subscription signing secret belongs on your receiver. It is different from WEBHOOK_SECRET_ENCRYPTION_KEY, which web and worker use to decrypt stored signing secrets. Changing that encryption key without migrating ciphertext breaks existing subscriptions; it does not rotate their signing secrets.