VetoVetoDocs
Guides

Webhooks

Receive and verify HMAC-SHA256 signed event deliveries from the hosted control plane with the SDK's verifyWebhook helper — the Veto-Signature scheme, replay window, retry schedule, idempotency, and secret rotation.

When you run on the hosted control plane, Veto POSTs an event to your endpoint whenever a checkout crosses a state boundary — an order is accepted, settled, rejected, or held. Each delivery is signed with HMAC-SHA256 against your endpoint's per-endpoint secret, and the SDK ships a verifier so you never hand-roll the comparison.

Webhooks are a hosted-mode feature. If you self-host the SDK there's no control plane to deliver them — use the in-process onEvent hook instead, which fires on the same terminal outcomes.

The delivery shape

Every delivery is a POST with a JSON body (the event envelope) and these headers:

POST /your/webhook/path HTTP/1.1
Content-Type: application/json
Veto-Signature: t=1750000000,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
Veto-Event-Id: evt_a1b2c3
Veto-Delivery-Id: wha_d4e5f6
User-Agent: Veto-Checkout-Webhooks/1

The body is the event envelope; data.object is the resource snapshot (an order/session/ receipt projection):

event envelope
{
  "id": "evt_a1b2c3",
  "type": "order.accepted",
  "created": 1750000000,
  "livemode": true,
  "api_version": "v1",
  "data": { "object": { "id": "ord_…", "decision": "accept", "total": { "currency": "USD", "total": "25.00" } } }
}

Event type values: order.accepted, order.settled, order.rejected, order.held. An endpoint subscribes to specific types or to * (all).

Verify a delivery

verifyWebhook(rawBody, signatureHeader, secret, opts?) takes positional arguments and returns { ok, reason?, timestamp? }. The header is Veto-Signature: t=…,v1=…, where v1 = HMAC-SHA256(secret, "<t>.<rawBody>") in hex. It never throws on bad input — a missing or garbled header surfaces as ok: false with a stable reason.

webhook-handler.ts
import { verifyWebhook } from '@veto-protocol/checkout';

export async function POST(req: Request) {
  const raw = await req.text(); // verify against the RAW body, not the parsed JSON
  const signature = req.headers.get('veto-signature'); // "t=…,v1=…"

  const result = verifyWebhook(raw, signature, process.env.VETO_WEBHOOK_SECRET!);

  if (!result.ok) {
    // result.reason ∈ SIGNATURE_HEADER_MISSING | SIGNATURE_HEADER_MALFORMED
    //   | TIMESTAMP_OUT_OF_TOLERANCE | SIGNATURE_MISMATCH | SECRET_MISSING
    return new Response(`invalid signature: ${result.reason}`, { status: 400 });
  }

  const event = JSON.parse(raw); // result.timestamp is the verified delivery time
  switch (event.type) {
    case 'order.accepted':
    case 'order.settled':
      await fulfil(event.data.object); // the order snapshot
      break;
    case 'order.rejected':
    case 'order.held':
      // log / notify
      break;
  }
  // Return 2xx to acknowledge. Anything else (or a >10s delay) triggers a retry.
  return new Response('ok', { status: 200 });
}

Verify against the raw request body.

verifyWebhook hashes the exact bytes Veto sent. Re-serializing parsed JSON can change key order or whitespace and break the signature. Read req.text() (or your framework's raw-body accessor) before any JSON parsing. The verifier also rejects deliveries more than ±300s from now (the replay window) — override with verifyWebhook(raw, sig, secret, { toleranceSecs }).

Express

Express parses bodies by default; capture the raw bytes on this route so the HMAC sees what was sent:

express-webhook.ts
import express from 'express';
import { verifyWebhook } from '@veto-protocol/checkout';

const app = express();

// express.raw gives a Buffer — don't let express.json() consume the body first.
app.post('/webhooks/veto', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  const result = verifyWebhook(raw, req.header('veto-signature'), process.env.VETO_WEBHOOK_SECRET!);
  if (!result.ok) return res.status(400).send(result.reason);

  const event = JSON.parse(raw);
  // … handle event.type …
  res.sendStatus(200);
});

Make handlers idempotent

Delivery is at-least-once: the same event id can arrive more than once (a retry after a timeout, a duplicate fire). Dedupe on Veto-Event-Id (the envelope id) and make your handler safe to run twice on the same event.

const eventId = req.headers.get('veto-event-id')!;
if (await alreadyProcessed(eventId)) return new Response('ok'); // no-op replay
await markProcessed(eventId);

Retry schedule

A delivery succeeds when your endpoint returns a 2xx within 10 seconds. Anything else — a non-2xx, a timeout, a connection error — schedules a retry on this backoff:

AttemptWhen
1immediately
2+30s
3+2m
4+10m
5+1h
6+6h
7+24h

After 7 attempts (~36h) the delivery is marked permanently failed. Keep your handler under the 10s window and offload slow work to a queue.

Rotating a secret

During a rotation a delivery can be signed with both secrets — Veto-Signature: t=…,v1=<old>,v1=<new>. verifyWebhook accepts a delivery if any v1 matches the secret you pass, so: verify with the old secret, roll your endpoint to the new one, then drop the old. No delivery is lost mid-rotation.

Self-hosted: the onEvent hook

If you run the SDK yourself, wire CheckoutConfig.onEvent — an in-process hook fired fire-and-forget after a settle reaches a terminal outcome. It can never block, slow, or change a verdict (an exception or hang inside it is swallowed), so it's the right place to mirror orders into your own systems.

createCheckout({
  // …
  onEvent: (event) => {
    // event.kind: 'order' for an accepted settle; 'decision' for reject/hold.
    // event.decision: 'accept' | 'reject' | 'hold'.
    console.log(event.kind, event.decision, event.session.id);
    if (event.kind === 'order') fulfil(event.session, event.receipt);
  },
});

This is the same hook the hosted control plane uses internally to keep reputation and orders current — the seam between the zero-dependency SDK and any reporting you add.