Skip to content

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.

Terminal window
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.

POST /your-endpoint HTTP/1.1
content-type: application/json
user-agent: Vendorica-Webhook/1
x-vendorica-event: control.drift_detected
x-vendorica-delivery: 7c1e...
x-vendorica-delivery-attempt: 1
x-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.

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.

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 time

The 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”

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.

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.

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:

Terminal window
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.

  • Deduplicate on x-vendorica-delivery. Keep the ids you have processed for longer than the retry window — a day is plenty.
  • Acknowledge fast. Answer 2xx as 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 createdAt in 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.

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).

/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.