Receive signed events
Subscribe to the workspace's events and verify every delivery.
POST /webhooks subscribes your endpoint to the workspace's events. You tell Vitrina which events to listen to and which URL we send them to. When something happens in the workspace (a contact is created, an appointment is booked, a lead moves to another stage) a signed POST goes out to your endpoint.
The responses on this page come from a run against a test receiver. Your identifiers will be different; the shape is the same.
Two modes: the notice or the data
Every delivery carries the notice: which resource changed, what changed, who did it and when. Those are resource (with its id and a url to read it), changes when it applies, author and created_at. With that, your integration reads the resource with its own credential. That read respects its permissions and visibility, and it lands in the access log. This is the default mode.
A subscription can also ask for the resource's data («Incluir datos del recurso», include_data: true). The delivery then carries data, the resource as it was when the event happened. It arrives only if the subscription's owner may read it at that moment. Otherwise you get the notice with data_omitted saying why. It's never left out silently: every delivery carries exactly one of the two, data or data_omitted.
A subscription has no permissions of its own, which is why it has an owner. The owner is whoever created the subscription, a member or an API key, or last changed its url, its events or include_data. It's evaluated on every delivery, not when the subscription was created.
These are all the reasons data can be missing:
data_omitted | What it means |
|---|---|
not_requested | The subscription asked for the notice only. |
missing_scope:<scope> | The owner does not hold the read permission for that resource (for example missing_scope:contacts:read). Each event's permission is in the catalogue. |
restricted_visibility | The owner sees only some records: the ones assigned to them, or those of some branches. |
owner_unavailable | The owner is no longer an active member, or their API key was revoked or expired. Create the subscription again, or edit its url or events, and you become the owner. |
sensitive | The event is about a dato sensible (health data). It always arrives as the notice, whatever the subscription asked for. |
These are the three deliveries of one event: three subscriptions to contact.created, and one contact created by a member from the app. The first asked for the notice only:
{
"id": "ec8ca90e-964f-45d3-adee-977e42f13a81",
"type": "contact.created",
"version": 1,
"livemode": true,
"created_at": "2026-09-22T00:16:26.420Z",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"resource": {
"type": "contact",
"id": "01a0c678-92ed-7743-9044-e560fdcfa381",
"url": "https://api.vitrinadev.com/api/v1/contacts/01a0c678-92ed-7743-9044-e560fdcfa381"
},
"author": {
"kind": "member",
"id": "20000000-0000-4000-8000-000000000001",
"name": "Camila Rojas"
},
"data_omitted": "not_requested"
}The second asked for the data, but it was created by an API key that holds only webhooks:read, webhooks:write and leads:read:
{
"id": "ec8ca90e-964f-45d3-adee-977e42f13a81",
"type": "contact.created",
"version": 1,
"livemode": true,
"created_at": "2026-09-22T00:16:26.420Z",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"resource": {
"type": "contact",
"id": "01a0c678-92ed-7743-9044-e560fdcfa381",
"url": "https://api.vitrinadev.com/api/v1/contacts/01a0c678-92ed-7743-9044-e560fdcfa381"
},
"author": {
"kind": "member",
"id": "20000000-0000-4000-8000-000000000001",
"name": "Camila Rojas"
},
"data_omitted": "missing_scope:contacts:read"
}The third asked for the data, and its owner holds contacts:read with unrestricted visibility:
{
"id": "ec8ca90e-964f-45d3-adee-977e42f13a81",
"type": "contact.created",
"version": 1,
"livemode": true,
"created_at": "2026-09-22T00:16:26.420Z",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"resource": {
"type": "contact",
"id": "01a0c678-92ed-7743-9044-e560fdcfa381",
"url": "https://api.vitrinadev.com/api/v1/contacts/01a0c678-92ed-7743-9044-e560fdcfa381"
},
"author": {
"kind": "member",
"id": "20000000-0000-4000-8000-000000000001",
"name": "Camila Rojas"
},
"data": {
"id": "01a0c678-92ed-7743-9044-e560fdcfa381",
"name": "María González",
"email": "[email protected]",
"phone": "+56912345678",
"lifecycle_stage": "unknown",
"origin_channel": null,
"company_id": null,
"created_at": "2026-09-22T00:16:24.356+00:00"
}
}Trap
Editing the URL makes you the owner
Changing the url, the events or include_data with PUT /webhooks/{id}
makes the caller the owner. If that credential lacks contacts:read, the next
events arrive with "data_omitted": "missing_scope:contacts:read", even if
they carried data before. Changing enabled or description leaves the owner
alone.
Subscribing
One call. Asks for webhooks:write. Whoever makes it becomes the owner.
curl -X POST https://api.vitrinadev.com/api/v1/webhooks \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://tu-dominio.cl/vitrina",
"events": ["contact.created"],
"description": "sincronización de contactos",
"include_data": true
}'{
"data": {
"id": "bc011bae-4e3f-43ae-b858-04874b9a5ba0",
"tenant_id": "00000000-0000-4000-8000-000000000001",
"url": "https://tu-dominio.cl/vitrina",
"secret": "whsec_cb6…",
"events": ["contact.created"],
"enabled": true,
"description": "sincronización de contactos",
"created_at": "2026-09-22T00:16:13.559911+00:00",
"updated_at": "2026-09-22T00:16:13.559911+00:00",
"last_delivery_at": null,
"last_status": null,
"consecutive_failures": 0,
"owner_kind": "api_key",
"owner_id": "90aa1346-3fdc-44a0-9ac1-2de84fe76689",
"include_data": true,
"paused_at": null,
"paused_reason": null,
"failing_since": null,
"consecutive_failed_deliveries": 0
}
}The url and the description are in Spanish, like the rest of the examples on this site. description is a free-form label for you, and the API stores whatever you send.
secret arrives whole only in this response. It's truncated above; yours comes complete, with whsec_ and 64 hexadecimal characters. Keep it where you keep your credentials: it's what you verify with that a POST claiming to come from Vitrina comes from Vitrina. Every later read (GET /webhooks, GET /webhooks/{id}) returns the first nine characters and nothing else. No endpoint ever shows it again. If you lose it, delete the subscription and create another.
include_data is false if you don't send it: the subscription receives the notice. owner_kind and owner_id say who the owner is: member with the user's id, or api_key with the key's id. A personal token counts as the member it acts for, with the permissions that both the token and that member hold.
events accepts the names from the catalogue, up to twenty per subscription, and also "*", which means "all of them, including the ones we add later". * is convenient for exploring and expensive in production: you'll receive everything the platform emits, and everything your vertical emits, and your endpoint will have to discard it one by one. If you need more than twenty names, create a second subscription.
Trap
We cannot deliver to a private address
With http://localhost:4000/webhooks/vitrina, the URL you have to hand while
developing, the subscription is never created:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Host localhost resolves to a private/blocked address (::1)",
"requestId": "25de7ca1-b24e-4e6c-9ffb-bd2d696eeb44"
}
}The URL is resolved through DNS when you register it, and every private, loopback, link-local or cloud-metadata address is out.
For development, expose your receiver through a tunnel and register the public URL.
An event name that is not in the catalogue is rejected in body validation, and the message carries the full list of the ones that are:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": {
"body": [{
"path": "events.0",
"message": "Invalid enum value. Expected '*' | 'ai_agent.publish' | 'ai_agent_graph.publish' | …",
"code": "invalid_enum_value"
}]
},
"requestId": "8a2f3d17-6b04-4e51-9c3a-1d7e0f2b5a64"
}
}What arrives
Every delivery is a POST with five headers of its own and a JSON body:
X-Webhook-Event: contact.created
X-Webhook-Event-Id: ec8ca90e-964f-45d3-adee-977e42f13a81
X-Webhook-Timestamp: 1790036189
X-Webhook-Signature: t=1790036189,v1=…
Vitrina-Livemode: trueThe envelope is the same for every event:
ididentifies the event, not the delivery: retries and redeliveries repeat the same one. It is the key your endpoint uses to make itself idempotent.typeis the event's name, andversionthe version of its schema. It goes up only when we changedatain a way that breaks whoever reads it; a new field does not bump it. If your code depends on the shape ofdata, checkversionbefore reading it.livemodeisfalsewhen the event came from a sandbox (test-mode) workspace,trueotherwise. It also rides theVitrina-Livemodeheader, and since it's in the signed body, verifying the signature verifies this too. More in Test mode.resourcesays what it is about:type,idandurl. Theurlis aGETon this same API; call it with your credential. It isnullwhen no singleGETreturns that resource.changes, when present, carries{ "from": …, "to": … }for each identifier or state that changed, for example a lead's stage inlead.stage_changed. It never carries personal data.authoris who did it:member,api_key,ai_agent,systemorcontact, with itsidand name. When a member acted through a personal token or a connected app, it is named invia.dataordata_omitted, per the two modes.
What data carries for each event is in the catalogue, together with its version, its resource and the permission it takes to receive it. It's generated from the same GET /webhooks/events you can call yourself.
Every event is stored in the same transaction as the change it describes, and delivered afterwards. If our process dies between the one and the other, the event isn't lost: it's delivered when the process comes back. Your endpoint may receive it a few seconds after the change.
Test mode
A sandbox workspace subscribes, filters events, asks for include_data and gets retries and auto-pause the same as any other workspace: the only difference is livemode. Every delivery out of a sandbox carries "livemode": false in the body and the Vitrina-Livemode: false header; a delivery from a real workspace carries true in both.
The two workspaces never cross: a subscription created on the sandbox receives only its own sandbox's events, and a subscription on the real workspace receives only its own. If one endpoint serves subscriptions from both, check livemode (or the header) before processing, so test data and real data never mix.
Verifying the signature
X-Webhook-Signature arrives as t=<unix seconds>,v1=<hex>, where the hex is an HMAC-SHA256 over the string ${t}.${body} keyed with your subscription's secret.
Two rules:
Sign the bytes you received. JSON.parse and JSON.stringify hand you an equivalent object and a different string, and the signature is over the string. In Express that means express.raw({ type: 'application/json' }) on the webhook route, or keeping the raw buffer:
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));Compare in constant time and watch the clock. A comparison with === leaks information through how long it takes to fail. A signature with no time window never expires: whoever has seen a delivery can replay it whenever they like. The window is 300 seconds.
The v1 above is trimmed because it's a real delivery's signature. The worked example in Get started carries the three values in full (body, timestamp and secret) and the hex they produce, with the verifier that computes it.
Retries
With a receiver that answers 500, this is what one delivery leaves in the log:
[
{ "attempt": 2, "status_code": 500, "error": "http_500", "created_at": "2026-09-22T00:19:03.819073+00:00" },
{ "attempt": 3, "status_code": 500, "error": "http_500", "created_at": "2026-09-22T00:19:17.461594+00:00" },
{ "attempt": 4, "status_code": 500, "error": "http_500", "created_at": "2026-09-22T00:19:39.27822+00:00" },
{ "attempt": 5, "status_code": 500, "error": "http_500", "created_at": "2026-09-22T00:20:22.437628+00:00" }
]Each attempt waits twice as long as the one before: five seconds, ten, twenty, forty. The full ladder and the attempt ceiling are in the catalogue.
Every attempt carries the same X-Webhook-Event-Id. That's why the endpoint has to be idempotent. A 200 of yours that arrives late, a network timeout, or a deploy of yours at a bad moment produce exactly this. Without dedup on id you'll process the same contact several times.
We cut the wait off at ten seconds. Answer 2xx as soon as you have the body stored, and process afterwards. If your work takes longer than that, the delivery counts as failed even though you did it right.
Auto-pause
A dead endpoint is not retried forever. The subscription pauses itself when 20 deliveries in a row exhaust their retries, or when it has been failing for 24 hours without a single successful response. Whichever comes first wins. That's twenty deliveries, not twenty attempts: a ten-second deploy of yours in the middle of a burst won't pause it. The workspace's owners and admins get one notice, in the app's notifications and by email.
A subscription paused by the first threshold is left like this:
{
"paused_at": "2026-09-22T00:20:22.623471+00:00",
"paused_reason": "consecutive_failures",
"consecutive_failures": 5,
"consecutive_failed_deliveries": 20,
"failing_since": "2026-09-22T00:18:58.437362+00:00"
}paused_reason is consecutive_failures or failing_24h. consecutive_failures counts attempts and consecutive_failed_deliveries counts deliveries; both go back to zero with the first good response.
While paused it receives nothing, and the events in between are not kept for later: when you resume it, catch up by reading the resources or by redelivering from the delivery log. Paused is not the same as switched off: enabled is your switch; the pause is ours.
To resume it, fix the endpoint and call:
curl -X POST https://api.vitrinadev.com/api/v1/webhooks/bc011bae-4e3f-43ae-b858-04874b9a5ba0/resume \
-H "Authorization: Bearer $VITRINA_KEY"It answers 200 with the subscription: paused_at back to null and both counters at zero, so it does not pause again on the first failure. If it was not paused, it returns it unchanged. In the app it is the «Reanudar» button.
The delivery log
Every attempt leaves a row, the good one and the bad ones. It's the first thing to look at when "the webhooks are not reaching me".
curl https://api.vitrinadev.com/api/v1/webhooks/bc011bae-4e3f-43ae-b858-04874b9a5ba0/deliveries \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": [
{
"id": 83,
"subscription_id": "bc011bae-4e3f-43ae-b858-04874b9a5ba0",
"event": "contact.created",
"event_id": "0b044a32-0998-4138-9c39-528404135441",
"attempt": 1,
"status_code": 200,
"error": null,
"response_excerpt": "{\"received\":true}",
"latency_ms": 997,
"created_at": "2026-09-22T00:20:54.77778+00:00",
"redelivery_of": 82
},
{
"id": 82,
"subscription_id": "bc011bae-4e3f-43ae-b858-04874b9a5ba0",
"event": "contact.created",
"event_id": "0b044a32-0998-4138-9c39-528404135441",
"attempt": 5,
"status_code": 500,
"error": "http_500",
"response_excerpt": "{\"error\":\"service unavailable\"}",
"latency_ms": 1034,
"created_at": "2026-09-22T00:20:22.437628+00:00",
"redelivery_of": null
}
],
"meta": { "pagination": { "limit": 50 }, "offset": 0 }
}Newest first. response_excerpt is the first 500 characters of what you answered, enough to recognise your own error message. error tells the three ways of failing apart: http_<status> when you answered something that isn't 2xx, timeout when you didn't answer in time, and the network message when we never managed to connect. Every row also carries request_payload, the exact body we sent; it's left out of the example above so it reads.
Redelivering a delivery
Row 83 above is a redelivery of row 82: the same event, sent again by hand.
curl -X POST https://api.vitrinadev.com/api/v1/webhooks/bc011bae-4e3f-43ae-b858-04874b9a5ba0/deliveries/82/redeliver \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": {
"subscription_id": "bc011bae-4e3f-43ae-b858-04874b9a5ba0",
"delivery_id": 82,
"event": "contact.created",
"event_id": "0b044a32-0998-4138-9c39-528404135441",
"status": "queued"
}
}It answers 202: the redelivery is queued and its outcome shows up in the log as a new row whose redelivery_of points at the original. It goes out with a fresh signature and a single attempt, to the subscription's url as it is now. The X-Webhook-Event-Id is the same one, so for your endpoint it's the same event. It's recorded in the workspace's audit log.
Two details. data is decided again, against the owner as they are now: if they lost the permission since the first delivery, the redelivery arrives as the notice. And it works on a paused subscription, so you can test the fix before resuming, but not on a switched-off one (409). In the app it's the «Reenviar» button on each row.
Switching off and deleting
PUT /webhooks/{id} with { "enabled": false } leaves the subscription in place and stops the deliveries. The ones already queued are discarded; they don't pile up for when you switch it back on. It's what you want during maintenance. The same PUT changes url, events, description or include_data, and the three that decide where and what is delivered make you the owner.
DELETE /webhooks/{id} deletes it and returns 204. The secret goes with it. It can't be recovered and can't be rotated in place, so rotating the secret means deleting and creating again.