VetoVetoDocs
Hosted REST API

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.
  • APIPOST /v1/webhooks/endpoints (scope webhooks:write):
POST /v1/webhooks/endpoints
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"]
  }'
201 — the secret is shown ONCE
{
  "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

TypeWhenFulfill?
order.acceptedThe gate accepted; receipt issued, before irreversible capture.not yet
order.settledThe rail confirmed settlement — the money is yours.yes, deliver here
order.rejectedA terminal reject.no
order.heldRouted 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:

order.settled
{
  "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)

FieldMeaning
idThe order id (ord_…) — your idempotency anchor for fulfillment.
session_idThe checkout session this order came from.
merchant_idWhich of your merchants was bought from (mrch_…).
agent_idThe buying agent (a UUID); anchors reputation and rate limits.
totalExact-decimal cart total — { currency, subtotal, tax, total }, never a float.
itemsLine items to fulfill — [{ sku, qty }].
rail_nameThe settlement rail, e.g. x402.
settlement_refThe settlement reference — for x402, the on-chain transaction hash.
receipt_idPoints at the signed merchant receipt (rcpt_…).
mandate_typeThe buyer's mandate kind: veto, ap2, acp, or none.
mandate_refThe mandate subject — links this order to the buyer's authorization.
trust_tierpremium, trusted, standard, or cautious.
buyerWho 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

HeaderValue
Veto-Signaturet=<unix_seconds>,v1=<hex HMAC-SHA256>
Veto-Event-Idevt_…dedupe on this.
Veto-Delivery-Idwha_… — the individual attempt.
Content-Typeapplication/json
User-AgentVeto-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.

webhook-handler.ts
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:

AttemptDelay from enqueue
1immediate
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.