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#
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.
| 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. |
budget.threshold_reached | Spending under a policy has passed 80% of its limit. |
budget.exhausted | A policy has used its full limit. Purchases under it are being refused. |
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.
Verifying a signature#
Four headers arrive with every delivery.
The signed input is:
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:
The same scheme written by hand, for any other language or a project that would rather not take the dependency:
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.
See Examples for a complete endpoint lifecycle, and API reference for every field.