Webhooks
Register an endpoint, and Veto POSTs you a signed event on every sale — order.accepted, order.settled, order.rejected, order.held. HMAC-SHA256 signed with a per-endpoint whsec_ secret, at-least-once, retried on a 7-step backoff. Fulfill on order.settled.
Webhooks are how the hosted control plane tells your server a sale happened — so you can
deliver the goods. When a checkout crosses a terminal boundary, Veto emits an event and
POSTs it to the endpoints you've registered. Every delivery is HMAC-SHA256 signed with a
per-endpoint secret, retried on a backoff, and at-least-once — so your handler must verify
the signature and dedupe on the event id.
This is the delivery trigger.
Veto doesn't hand the product to the buyer — you do, on the order.settled event. See
How Veto works — discover, pay, deliver for the full
picture; this page is the wire contract your receiver implements.
1 · Register an endpoint
Veto only delivers to endpoints you've registered. Two ways:
- Dashboard — open Sale notifications, add your URL, pick which events you want
(or all of them), and copy the
whsec_…secret. This is the path most merchants use. - API —
POST /v1/webhooks/endpoints(scopewebhooks:write):
curl -X POST https://api.veto-ai.com/v1/webhooks/endpoints \
-H "Authorization: Bearer veto_test_8f2c…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.acme.example/webhooks/veto",
"enabled_events": ["order.settled"]
}'{
"id": "whk_01J…",
"url": "https://api.acme.example/webhooks/veto",
"secret": "whsec_…",
"enabled_events": ["order.settled"],
"status": "pending",
"livemode": false
}The whsec_ secret is shown once.
Copy it into your secret manager at creation — it's the HMAC key you'll pass to
verifyWebhook, and Veto won't show it again. Endpoints are scoped to the key's mode
(webhooks:read / webhooks:write), so a test key registers a test-mode endpoint.
Use ["*"] for enabled_events to subscribe to everything. Disabled endpoints are skipped
on fan-out (the event still records, so you can replay later).
2 · Event types
| Type | When | Fulfill? |
|---|---|---|
order.accepted | The gate accepted; receipt issued, before irreversible capture. | not yet |
order.settled | The rail confirmed settlement — the money is yours. | yes, deliver here |
order.rejected | A terminal reject. | no |
order.held | Routed to human review. | no |
An accepted settle fires both order.accepted and order.settled (accept happens before
capture; settled confirms the rail). Fulfill on order.settled. A reject fires
order.rejected; a hold fires order.held.
3 · The event envelope
Every delivery body has the same stable shape. The bytes Veto signs are the bytes it sends, so a retry re-signs identical bytes:
{
"id": "evt_01J…",
"type": "order.settled",
"created": 1750000000,
"livemode": false,
"api_version": "v1",
"data": {
"object": {
"id": "ord_01J…",
"session_id": "sess_…",
"merchant_id": "mrch_01J…",
"agent_id": "11111111-1111-1111-1111-111111111111",
"total": { "currency": "USD", "subtotal": "39.00", "tax": "0.00", "total": "39.00" },
"items": [{ "sku": "slipper-cloud-9", "qty": 1 }],
"rail_name": "x402",
"settlement_ref": "0x…txHash…",
"receipt_id": "rcpt_01J…",
"mandate_type": "veto",
"mandate_ref": "…",
"trust_tier": "trusted",
"buyer": {
"name": "Ada Lovelace",
"shipping_address": {
"line1": "12 Mathematician's Way",
"city": "London",
"postal_code": "EC1A 1BB",
"country": "GB"
}
}
}
}
}The order object (data.object)
| Field | Meaning |
|---|---|
id | The order id (ord_…) — your idempotency anchor for fulfillment. |
session_id | The checkout session this order came from. |
merchant_id | Which of your merchants was bought from (mrch_…). |
agent_id | The buying agent (a UUID); anchors reputation and rate limits. |
total | Exact-decimal cart total — { currency, subtotal, tax, total }, never a float. |
items | Line items to fulfill — [{ sku, qty }]. |
rail_name | The settlement rail, e.g. x402. |
settlement_ref | The settlement reference — for x402, the on-chain transaction hash. |
receipt_id | Points at the signed merchant receipt (rcpt_…). |
mandate_type | The buyer's mandate kind: veto, ap2, acp, or none. |
mandate_ref | The mandate subject — links this order to the buyer's authorization. |
trust_tier | premium, trusted, standard, or cautious. |
buyer | Who and where to deliver — name plus shipping_address for physical goods. Present when the checkout captured buyer details. |
Reject / hold payloads.
order.rejected and order.held carry the same order object for context, but with no
settlement — settlement_ref and receipt_id are absent because nothing settled.
4 · Delivery headers
| Header | Value |
|---|---|
Veto-Signature | t=<unix_seconds>,v1=<hex HMAC-SHA256> |
Veto-Event-Id | evt_… — dedupe on this. |
Veto-Delivery-Id | wha_… — the individual attempt. |
Content-Type | application/json |
User-Agent | Veto-Checkout-Webhooks/1 |
5 · Verify a delivery
The v1 value is HMAC-SHA256(secret, "<t>.<rawBody>") in hex. The SDK ships a verifier so
you never hand-roll the constant-time compare. It takes positional arguments —
verifyWebhook(rawBody, signatureHeader, secret, opts?) — and returns
{ ok, reason?, timestamp? }. 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 the RAW bytes
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);
// Dedupe on event.id; deliver on event.type === 'order.settled'.
return new Response('ok', { status: 200 });
}Verify against the raw request body.
verifyWebhook hashes the exact bytes Veto sent. Re-serializing parsed JSON can reorder keys
or change whitespace and break the signature — read req.text() (or your framework's raw-body
accessor) before any JSON parse. The verifier also rejects deliveries more than ±300s from
now (the replay window); override with verifyWebhook(raw, sig, secret, { toleranceSecs }).
6 · Idempotency — dedupe on Veto-Event-Id
Delivery is at-least-once: a retry after a timeout, or a duplicate fire, means the same
event id can arrive more than once. 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);7 · Retry schedule
A delivery succeeds on any 2xx returned within a 10s timeout. Anything else — a non-2xx,
a timeout, a connection error — schedules the next attempt on this backoff, up to 7 attempts
over ~36 hours:
| Attempt | Delay from enqueue |
|---|---|
| 1 | immediate |
| 2 | +30s |
| 3 | +2m |
| 4 | +10m |
| 5 | +1h |
| 6 | +6h |
| 7 | +24h |
Exhausting the schedule marks the delivery permanently failed. Keep your handler under the
10s window and offload slow work (shipping API calls, file generation) 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 the 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-hosting the SDK?
There's no hosted control plane to deliver webhooks. Use the in-process
onEvent hook instead — it fires on the
same terminal outcomes. The Webhooks guide covers Express, Next.js,
and the self-hosted path.
Reputation
Bidirectional agent and merchant reputation. The hot path never 404s — an unknown id returns a neutral 200 so the SDK degrades safely.
SDK · CLI · MCP
The three surfaces of Veto Checkout — the @veto-protocol/checkout SDK, the veto CLI, and the MCP server that lets an agent buy from any protocol-speaking merchant.