Webhooks

Receive signed events when purchases are decided, budgets move or connections change. Signature verification, the event catalogue, retries and at-least-once delivery.

Veriticity posts a signed copy of what happens in your account to a URL you choose. It is how you find out that a person approved a purchase without polling for it.

Creating an endpoint#

Create an endpoint
curl -X POST https://app.veriticity.com/v1/webhooks \
  -H "Authorization: Bearer $VERITICITY_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/veriticity",
    "description": "Production ledger sync",
    "subscribedTypes": ["purchase.approved", "purchase.rejected", "budget.exhausted"]
  }'
201 Created
{
  "id": "0199a4b2-7c31-7e50-a1f2-9d3b8c6e4a05",
  "url": "https://hooks.example.com/veriticity",
  "status": "ACTIVE",
  "subscribedTypes": ["purchase.approved", "purchase.rejected", "budget.exhausted"],
  "apiVersion": "2026-09-01",
  "consecutiveFailures": 0,
  "secret": "cmp_whs_Kp8Qw3pZx7mN2vB4cD6fG9hJ1kL3nP5rS7tU9wX0yZ2"
}

Webhook management requires an organisation-scoped API key. An agent-bound credential is refused, and no OAuth scope reaches it at all — admitting a connection would let an AI assistant point your account’s event stream at a URL of its own choosing.

The envelope#

Five fields, fixed, on every event.

A webhook body
{
  "id": "0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77",
  "type": "purchase.approved",
  "apiVersion": "2026-09-01",
  "createdAt": "2026-09-12T14:03:21.114Z",
  "accountId": "0198c2d4-1a3b-7c50-8e21-4f9a2b6d8e10",
  "data": {
    "purchaseRequestId": "0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77",
    "approvalRequestId": "0199a3f2-1b4d-7a20-8c31-2e5f7a9b1c40",
    "resolvedBy": "HUMAN",
    "agentId": "0199a3d1-5e6f-7a80-9b10-3c4d5e6f7a80",
    "agentName": "Build pipeline",
    "merchant": "Acme Cloud",
    "amountMinor": "42000",
    "currency": "GBP"
  }
}
FieldMeaning
idThe logical event. Stable across retries, identical at every endpoint subscribed to it. Deduplicate on this.
typeWhat happened.
apiVersionThe contract version this endpoint receives. Currently 2026-09-01.
createdAtWhen it happened in the domain, not when it was sent.
accountIdYour Veriticity account. Route on it if you serve several.

Event types#

TypeSent when
purchase.approval_requiredA purchase needs a person to approve it before it can go ahead.
purchase.approvedA purchase was approved, either by your policies or by a person.
purchase.rejectedA purchase was refused, either by your policies or by a person.
purchase.approval_expiredNobody answered in time, so the purchase was refused.
budget.threshold_reachedSpending under a policy has passed 80% of its limit.
budget.exhaustedA policy has used its full limit. Purchases under it are being refused.
agent.disabledAn agent was switched off and can no longer buy anything.
agent.archivedAn agent was archived.
connection.createdSomeone connected an AI assistant to one of your agents.
connection.revokedAn AI assistant's access to one of your agents was withdrawn.
security.connection_token_reuse_detectedA connected application reused a token it should not have. Its access was withdrawn automatically.

There is one more type you can receive but cannot subscribe to: webhook.test, sent by POST /v1/webhooks/{id}/test and delivered regardless of your subscriptions. If you switch exhaustively on event type, handle it.

purchase.approved and purchase.rejected fire for every terminal decision, not only ones a person answered. data.resolvedBy is HUMAN or ENGINE, and approvalRequestId is null for the latter.

Verifying a signature#

Four headers arrive with every delivery.

Delivery headers
Veriticity-Webhook-Id:         0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77
Veriticity-Webhook-Delivery:   0199a3f5-2d6e-7b91-a042-8f1c3e5d7b60
Veriticity-Webhook-Timestamp:  1789394601
Veriticity-Webhook-Signature:  v1=3Qm8xZ...

The signed input is:

Signed material
v1:{timestamp}:{raw body bytes}

HMAC-SHA256 under your endpoint’s signing secret, base64url encoded.

The TypeScript SDK ships this verifier. It takes the Request itself, reads the raw bytes, checks the timestamp and the signature, and hands back a typed event — and it needs no API credential, so a receiver can import it on its own:

With the SDK
import { Webhooks, isKnownWebhookEvent } from "@veriticity/sdk/webhooks";

const webhooks = new Webhooks({
  // An array while a rotation is in flight: either secret verifies.
  secret: process.env.VERITICITY_WEBHOOK_SECRET!,
});

export async function POST(request: Request) {
  let event;

  try {
    // Verifies and parses together, from the bytes as they arrived.
    event = await webhooks.unwrap(request);
  } catch {
    return new Response(null, { status: 400 });
  }

  // Deduplicate on event.id: delivery is at-least-once.
  if (await alreadyHandled(event.id)) {
    return new Response(null, { status: 204 });
  }

  if (isKnownWebhookEvent(event) && event.type === "purchase.approved") {
    // event.data is narrowed to this event's payload.
    await recordApproval(event.data.purchaseRequestId);
  }

  return new Response(null, { status: 204 });
}

The same scheme written by hand, for any other language or a project that would rather not take the dependency:

Without the SDK
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

/**
 * Verify a Veriticity webhook.
 *
 * rawBody must be the exact bytes that arrived. JSON.parse followed by
 * JSON.stringify is not the identity function, and a re-serialised body will
 * not verify.
 */
export function verifyWebhook(
  rawBody: string,
  headers: Headers,
  secret: string,
  now: Date = new Date(),
): boolean {
  const timestamp = headers.get("veriticity-webhook-timestamp");
  const signatureHeader = headers.get("veriticity-webhook-signature");

  if (timestamp === null || signatureHeader === null) {
    return false;
  }

  // Reject anything too far from our own clock, in either direction, so a
  // captured request cannot be replayed tomorrow.
  const age = Math.abs(Math.floor(now.getTime() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
    return false;
  }

  // The version is inside the signed material, not only in the header.
  const signed = `v1:${timestamp}:${rawBody}`;
  const expected = createHmac("sha256", secret)
    .update(signed, "utf8")
    .digest("base64url");

  // During the 24 hours after a rotation the header carries two signatures.
  // Verifying against either is a success.
  return signatureHeader
    .split(",")
    .map((part) => part.trim())
    .filter((part) => part.startsWith("v1="))
    .map((part) => part.slice(3))
    .some((candidate) => {
      const a = Buffer.from(candidate);
      const b = Buffer.from(expected);

      // timingSafeEqual throws on a length mismatch rather than returning
      // false, so the lengths are compared first.
      return a.length === b.length && timingSafeEqual(a, b);
    });
}
An Express handler
import express from "express";

const app = express();

// The raw body, not a parsed one. Most frameworks parse JSON by default and
// discard the bytes -- that is the single most common reason a first webhook
// integration fails to verify.
app.post(
  "/veriticity",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");

    if (!verifyWebhook(rawBody, new Headers(req.headers as never), SECRET)) {
      return res.status(400).send("bad signature");
    }

    const event = JSON.parse(rawBody);

    // Deduplicate on the event id. Delivery is at-least-once, so the same
    // event can arrive more than once and you will see it eventually.
    if (alreadyHandled(event.id)) {
      return res.status(200).send("ok");
    }

    // Acknowledge quickly, then do the work. Veriticity gives you 10 seconds.
    res.status(200).send("ok");
    void handleAsync(event);
  },
);

Delivery semantics#

  • At least once. Duplicates are possible. Deduplicate on Veriticity-Webhook-Id.
  • No ordering guarantee. Retries reorder, and workers run concurrently. Every purchase event carries enough to implement last-write-wins on (purchaseRequestId, createdAt), and id is a UUIDv7 so it sorts by creation time.
  • No sequence numbers. One would invite you to assume gapless delivery, which is impossible — an endpoint subscribed to three of the types sees gaps by design.
  • The body is identical across attempts. A retry sends the same bytes with a new timestamp, so a stored raw body can be re-verified later.

If you need certainty that you have not missed anything, reconcile against GET /v1/purchase-requests/{id}.

Retries#

Your responseVerdict
2xxSuccess
408, 429Retry
5xxRetry
Network, DNS, TLS or timeout failureRetry
3xxPermanent failure. Redirects are never followed.
Any other 4xxPermanent failure. The event is dropped for this endpoint.

Seven attempts over roughly 24 hours: 0, +1m, +5m, +25m, +2h, +6h, +15h, each jittered by about ±20% so a mass outage does not produce a thundering herd on recovery.

Veriticity does not disable a failing endpoint. A three-day outage should not also cost you a re-enable. Watch consecutiveFailures on the endpoint to alert yourself.

Rotating a secret#

POST /v1/webhooks/{id}/rotate-secret issues a new secret and returns it once. For 24 hours the old one keeps signing alongside it, so every delivery in that window carries two signatures and a receiver verifying with either succeeds. That is what lets you deploy the new secret without coordinating a cutover.

Send {"retireImmediately": true} to kill the old secret at once, which is what a leak calls for.

Where Veriticity will send#

RuleRequirement
SchemeHTTPS only
Port443 only
HostA hostname. IP literals are refused.
UserinfoRefused
ResolutionEvery resolved address must be publicly routable. Checked at creation and before every delivery.
RedirectsNever followed
Custom headersNot supported

A URL that fails these answers 400 webhook_url_not_permitted rather than invalid_request — the address is the problem, not your JSON.

Since custom headers are unsupported, authenticate inbound deliveries by verifying the signature. A secret path segment works too, but the signature is the control that actually proves the request came from Veriticity.

Delivery history#

GET /v1/webhooks/{id}/deliveries shows recent attempts: what was sent, what your server answered, and when the next attempt is due. lastResponseSnippet carries up to 512 bytes of your own error response, which is usually the fastest way to find out what went wrong.

Use POST /v1/webhooks/{id}/test to send a real signed event through the real pipeline. There is no verification handshake — a signature proves control on every request, which is better than proving it once.

See Examples for a complete endpoint lifecycle, and API reference for every field.