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/1The body is the event envelope; data.object is the resource snapshot (an order/session/
receipt projection):
{
"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.
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:
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:
| Attempt | When |
|---|---|
| 1 | immediately |
| 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.
Policies & trust
Set the rules that decide which agent spends you accept — dollar caps, velocity limits, mandate requirements, reputation floors, and review routing — then publish them safely as immutable versions.
Verify receipts
Verify a merchant receipt offline against the published Ed25519 key, and publish your own verifying key at /.well-known/jwks.json.