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#
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"]
}'{
"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.
{
"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"
}
}| Field | Meaning |
|---|---|
id | The logical event. Stable across retries, identical at every endpoint subscribed to it. Deduplicate on this. |
type | What happened. |
apiVersion | The contract version this endpoint receives. Currently 2026-09-01. |
createdAt | When it happened in the domain, not when it was sent. |
accountId | Your Veriticity account. Route on it if you serve several. |
Event types#
| Type | Sent when |
|---|---|
purchase.approval_required | A purchase needs a person to approve it before it can go ahead. |
purchase.approved | A purchase was approved, either by your policies or by a person. |
purchase.rejected | A purchase was refused, either by your policies or by a person. |
purchase.approval_expired | Nobody answered in time, so the purchase was refused. |
purchase.authorization_used | A purchase authorization was checked against its exact terms and used. It can't be used again. |
purchase.cancelled | A purchase was cancelled: a request taken back before approval, or an approval withdrawn or voided. It no longer counts towards budgets. |
budget.threshold_reached | Spending under a policy has passed 80% of its limit. |
budget.exhausted | A policy has spent its full limit. Sent once, when spending crosses it. To hear about a purchase being refused, subscribe to purchase.rejected. |
agent.disabled | An agent was switched off and can no longer buy anything. |
agent.archived | An agent was archived. |
connection.created | Someone connected an AI assistant to one of your agents. |
connection.revoked | An AI assistant's access to one of your agents was withdrawn. |
security.connection_token_reuse_detected | A 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.
purchase.approval_expired is the one event a request does not cause: nobody answered, so nothing happened at the moment the deadline passed. It is sent within a few minutes of approval.expiresAt rather than at it.
purchase.authorization_used means a purchase authorization was verified and used. It does not mean the purchase was made: Veriticity is not told its outcome.
purchase.cancelled is sent when a pending request is cancelled, or an authorization is withdrawn or voided. data.cancellation says which, and data.cancelledBy is user, api_key or connection. The reason and the person’s name are in your audit trail, not in the event. Nothing is sent when an authorization expires.
Verifying a signature#
Four headers arrive with every delivery.
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:
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:
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:
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);
});
}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), andidis 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 response | Verdict |
|---|---|
2xx | Success |
408, 429 | Retry |
5xx | Retry |
| Network, DNS, TLS or timeout failure | Retry |
3xx | Permanent failure. Redirects are never followed. |
Any other 4xx | Permanent 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#
| Rule | Requirement |
|---|---|
| Scheme | HTTPS only |
| Port | 443 only |
| Host | A hostname. IP literals are refused. |
| Userinfo | Refused |
| Resolution | Every resolved address must be publicly routable. Checked at creation and before every delivery. |
| Redirects | Never followed |
| Custom headers | Not 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.
Test events are limited to 5 a minute for each endpoint and 30 every 15 minutes for the organisation, whether they are sent from the API or the dashboard. Over either limit the answer is 429 and no event is queued. See Errors.
See Examples for a complete endpoint lifecycle, and API reference for every field.

