Webhooks
A webhook is a name, a set of triggers, an endpoint and its settings. It fires when any of its triggers fires, and the payload names the one that did.
Manage them from your own tooling over /v1/webhooks (webhooks:read /
webhooks:write), or in the app.
Creating one
Section titled “Creating one”curl -X POST https://api.vendorica.com/v1/webhooks \ -H "Authorization: Bearer $VENDORICA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Drift alerts → PagerDuty", "endpoint": "https://hooks.example.com/vendorica", "triggers": ["control.drift_detected", "vendor.rating_dropped"] }'GET /v1/webhooks/triggers lists every available trigger, annotated with
whether your plan includes it — read that rather than hard-coding names.
The delivery
Section titled “The delivery”POST /your-endpoint HTTP/1.1content-type: application/jsonuser-agent: Vendorica-Webhook/1x-vendorica-event: control.drift_detectedx-vendorica-delivery: 7c1e...x-vendorica-delivery-attempt: 1x-vendorica-api-version: 2026-06-01{ "id": "7c1e...", "type": "control.drift_detected", "apiVersion": "2026-06-01", "createdAt": "2026-08-19T09:41:12.004Z", "organizationId": "3c91...", "data": { }}| Header | Meaning |
|---|---|
x-vendorica-event |
The trigger that fired. |
x-vendorica-delivery |
The delivery id, also id in the body. The same on every attempt — your dedupe key. |
x-vendorica-delivery-attempt |
1 on the first attempt, 2 on the first retry, and so on. |
x-vendorica-api-version |
The payload schema version. |
x-vendorica-signature |
Only when signing is on — see below. |
data carries identifiers and non-identifying facts only, never a person’s
details or a credential; fetch the record from the API when you need more.
Trust Center access triggers
Section titled “Trust Center access triggers”| Trigger | data |
|---|---|
trustcenter.access_requested |
accessRequestId: a request is waiting for a person to decide it |
trustcenter.access_approved |
accessRequestId, visitorTokenId, decidedBy |
trustcenter.access_rejected |
accessRequestId, decidedBy |
trustcenter.resource_downloaded |
accessEventId, resourceId, visitorTokenId, channel, watermarked, occurredAt |
decidedBy says who decided: reviewer (a person in the app), access_rule
(an auto-approval or auto-deny rule, at submission) or api_client (an API
key, through
POST /v1/trust-center/access-requests/{id}/approve
or …/reject).
An integration that decides requests can use api_client to recognise the
echo of its own decision. These are premium triggers. See
Decide Trust Center access.
Verifying the signature
Section titled “Verifying the signature”Turn signing on and every delivery carries a timestamped HMAC:
x-vendorica-signature: t=1755594072,v1=6f3a...v1 is HMAC-SHA256(secret, "<t>.<raw request body>"), hex-encoded.
Verify against the raw body bytes, before any JSON parsing — a
re-serialised body will not match.
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t, v1 = parts["t"], parts["v1"] if abs(time.time() - int(t)) > tolerance: # reject replays return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) # constant timeThe timestamp is inside the signed string, so an attacker cannot replay an
old delivery with a fresh t. Reject anything outside your tolerance
window, and compare in constant time. Every attempt is signed afresh,
with its own t — a retry six hours later still lands inside a five-minute
window.
You can also attach custom headers for your receiver’s own auth — both the signing secret and those headers are encrypted at rest.
Delivery semantics — read this before you rely on it
Section titled “Delivery semantics — read this before you rely on it”What counts as failed
Section titled “What counts as failed”Each attempt is judged on its own. Only a 2xx is a success; the body of
your response is never read.
| Outcome | Class | What happens |
|---|---|---|
2xx |
— | Delivered. |
| No response within 10 s | timeout |
Retried. |
| Connection refused or reset, DNS failure | network |
Retried. |
408 Request Timeout |
http_408 |
Retried. |
429 Too Many Requests |
http_429 |
Retried, after your Retry-After if you send one. |
5xx except 501 and 505 |
http_5xx |
Retried; a 503 with Retry-After is honoured too. |
410 Gone |
http_410 |
Fails at once, and suspends the webhook (unless disableOnGone is off). |
501, 505 |
http_unsupported |
Fails at once. |
Any other 4xx (400, 401, 403, 404, 409, 422…) |
http_4xx |
Fails at once — unless you opt in, below. |
A redirect without a usable Location, or more than 3 hops |
redirect |
Fails at once. |
| The endpoint resolves to a private or reserved address | blocked |
Fails at once. |
So to tell us not to retry, answer with a 4xx — 400 or 422 for a
payload you will never accept, 410 for an endpoint that is gone for good.
A 5xx says “try again later”, and we will.
Retry-After may be a number of seconds or an HTTP date. It is honoured
between 10 seconds and the policy’s maximum delay; a longer one is capped.
The retry schedule
Section titled “The retry schedule”Every webhook has a retryPolicy. The default:
{ "enabled": true, "maxAttempts": 6, "backoff": "exponential", "initialDelaySeconds": 60, "maxDelaySeconds": 21600, "retryOn": ["timeout", "network", "http_408", "http_429", "http_5xx"]}Exponential backoff waits initialDelaySeconds × 4^(n−1) before retry n,
± 20% so that retries do not arrive in a burst, and never more than
maxDelaySeconds. With the defaults that is roughly:
| Attempt | Sent after the event |
|---|---|
| 1 | immediately |
| 2 | ~1 min |
| 3 | ~5 min |
| 4 | ~21 min |
| 5 | ~1 h 25 min |
| 6 | ~5 h 40 min |
"backoff": "fixed" waits initialDelaySeconds every time instead.
| Field | Range | Meaning |
|---|---|---|
enabled |
— | false makes every delivery a single attempt. |
maxAttempts |
1–10 | Total attempts, including the first. |
backoff |
exponential, fixed |
How the wait grows. |
initialDelaySeconds |
10–3600 | The first wait. Must not exceed maxDelaySeconds. |
maxDelaySeconds |
60–86400 | The ceiling on any one wait. |
retryOn |
subset of timeout, network, http_408, http_429, http_5xx, http_4xx |
Which classes are retried. Add http_4xx only if your receiver uses a 409 or 423 to mean “busy”. http_410, http_unsupported, redirect and blocked are never retried. |
When a webhook stops
Section titled “When a webhook stops”failureHandling decides what failures do to the webhook itself:
| Field | Default | Meaning |
|---|---|---|
autoDisableAfter |
15 |
Suspend after this many deliveries fail in a row (1–100). A delivery that took six attempts to fail counts once; any success resets the count. |
disableOnGone |
true |
Suspend as soon as the endpoint answers 410 Gone. |
A suspended webhook sends nothing, and your organization’s admins are
emailed. Deliveries that were waiting for a retry are closed as failed
(lastFailureClass: "webhook_inactive") rather than held back — the same
happens when you pause a webhook. Re-enable it, then reconcile for the gap.
Change either object with a PATCH; both are partial, so you can send just
the field you want to change:
curl -X PATCH https://api.vendorica.com/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer $VENDORICA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "retryPolicy": { "maxAttempts": 3 }, "failureHandling": { "disableOnGone": false } }'A change applies to deliveries already waiting, from their next attempt. If you change the endpoint while a retry is waiting, the retry goes to the new endpoint.
Designing your receiver
Section titled “Designing your receiver”- Deduplicate on
x-vendorica-delivery. Keep the ids you have processed for longer than the retry window — a day is plenty. - Acknowledge fast. Answer
2xxas soon as the delivery is stored, and do the work afterwards. A slow answer is a timeout, and a timeout is a retry of something you may already have processed. - Order is not guaranteed, and retries make that more visible: a retried
delivery can arrive after a later event. Order on
createdAtin the body, or re-read the record from the API. - Webhooks are still not a source of truth. Retries cover an outage of hours, not days. Reconcile periodically against the API so that a delivery that failed for good becomes a delay instead of a permanent gap.
The delivery log
Section titled “The delivery log”GET /v1/webhooks/{id}/deliveries is the delivery log, cursor-paginated,
and is where you look when the receiver saw nothing. Each delivery is
pending, retrying (with nextAttemptAt), succeeded or failed, and
lists every attempt in attempts — when it was sent, the status your
endpoint answered and the failure class. Error messages are deliberately
coarse (timeout, responded 503, could not connect to endpoint).
Not to be confused with
Section titled “Not to be confused with”/v1/webhooks/inbound/* is a different thing entirely — that is where
third-party providers post to Vendorica. It is not part of this
management surface.