Webhooks
Suscripciones a los eventos del workspace, y el registro de cada entrega.
Descarga la proyección completa de la API pública: openapi.json.
Recibir eventos firmados explica este recurso en prosa, con ejemplos ejecutables.
| Método | Ruta | Qué hace |
|---|---|---|
GET | /webhooks | List webhook subscriptions |
POST | /webhooks | Create a webhook subscription (returns the plaintext secret once) |
DELETE | /webhooks/{id} | Delete a webhook subscription |
GET | /webhooks/{id} | Get a webhook subscription |
PUT | /webhooks/{id} | Update a webhook subscription |
GET | /webhooks/{id}/deliveries | List webhook delivery attempts |
POST | /webhooks/{id}/deliveries/{deliveryId}/redeliver | Redeliver one webhook delivery |
POST | /webhooks/{id}/resume | Resume an auto-paused webhook subscription |
GET | /webhooks/events | The webhook event catalogue and the signing contract |
GET /webhooks
List webhook subscriptions
Every subscription in the workspace, newest first, with its secret redacted to the first nine characters. Health is last_status + the two failure streaks; paused_at set means the platform AUTO-PAUSED it (20 failed deliveries in a row, or 24 h failing) and it receives nothing until POST /webhooks/\{id\}/resume.
curl https://api.vitrinadev.com/api/v1/webhooks \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": "d1d1d1d1-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"url": "https://example.com/hooks/vitrina",
"secret": "whsec_d1d…",
"events": [
"lead.created",
"sla.breached"
],
"enabled": true,
"description": "ERP sync",
"created_at": "2026-09-20T01:41:04.274Z",
"updated_at": "2026-09-20T01:41:04.274Z",
"last_delivery_at": "2026-09-20T01:42:12.154Z",
"last_status": 200,
"consecutive_failures": 0,
"owner_kind": "api_key",
"owner_id": "c1c1c1c1-0000-4000-8000-000000000001",
"include_data": true,
"paused_at": null,
"paused_reason": null,
"failing_since": null,
"consecutive_failed_deliveries": 0
}
]
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /webhooks
Create a webhook subscription (returns the plaintext secret once)
Subscribes url to events (names from GET /webhooks/events, up to twenty, or "*" for all — including events added later). The caller becomes the subscription's OWNER.
include_data defaults to false: every delivery is the notice (resource, changes, author, time, url) with data_omitted: "not_requested". With true, data is added when, at dispatch time, the owner holds the event's read_scope and sees every record — otherwise data_omitted names the reason — and never for a sensitive event.
The response is the ONLY one carrying the full whsec_… secret. The URL must resolve to a public address (private, loopback and metadata hosts are refused with 400).
curl -X POST https://api.vitrinadev.com/api/v1/webhooks \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/vitrina",
"events": [
"lead.created",
"sla.breached"
],
"description": "ERP sync"
}'Ejemplo de respuesta (201)
{
"data": {
"id": "d1d1d1d1-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"url": "https://example.com/hooks/vitrina",
"secret": "whsec_d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1",
"events": [
"lead.created",
"sla.breached"
],
"enabled": true,
"description": "ERP sync",
"created_at": "2026-09-20T01:41:04.274Z",
"updated_at": "2026-09-20T01:41:04.274Z",
"last_delivery_at": null,
"last_status": null,
"consecutive_failures": 0,
"owner_kind": "api_key",
"owner_id": "c1c1c1c1-0000-4000-8000-000000000001",
"include_data": false,
"paused_at": null,
"paused_reason": null,
"failing_since": null,
"consecutive_failed_deliveries": 0
}
}Responde: 201 · 400 · 401 · 403 · 404 · 409 · 429
DELETE /webhooks/{id}
Delete a webhook subscription
Removes the subscription and its delivery log. The secret goes with it; rotating a secret is delete-and-recreate.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X DELETE https://api.vitrinadev.com/api/v1/webhooks/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Responde: 204 · 400 · 401 · 403 · 404 · 409 · 429
GET /webhooks/{id}
Get a webhook subscription
One subscription, secret redacted. 404 for an id in another workspace.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl https://api.vitrinadev.com/api/v1/webhooks/<id> \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "d1d1d1d1-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"url": "https://example.com/hooks/vitrina",
"secret": "whsec_d1d…",
"events": [
"lead.created",
"sla.breached"
],
"enabled": true,
"description": "ERP sync",
"created_at": "2026-09-20T01:41:04.274Z",
"updated_at": "2026-09-20T01:41:04.274Z",
"last_delivery_at": null,
"last_status": null,
"consecutive_failures": 0,
"owner_kind": "api_key",
"owner_id": "c1c1c1c1-0000-4000-8000-000000000001",
"include_data": false,
"paused_at": null,
"paused_reason": null,
"failing_since": null,
"consecutive_failed_deliveries": 0
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
PUT /webhooks/{id}
Update a webhook subscription
Partial: send only what changes (at least one field). enabled: false stops deliveries and discards queued ones.
Changing url, events or include_data makes the caller the OWNER. Otherwise anyone with webhooks:write could point a data-carrying subscription at their own server and read what their role cannot. enabled and description leave the owner as it is.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
Cuerpo
| Campo | Tipo | Obligatorio | Restricciones |
|---|---|---|---|
url | string | — | uri, máx. 2048 |
events | * \ | ai_agent.publish \ | ai_agent_graph.publish \ |
enabled | boolean | — | |
description | string | null | — | máx. 500 |
include_data | boolean | — |
curl -X PUT https://api.vitrinadev.com/api/v1/webhooks/<id> \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": false
}'Ejemplo de respuesta (200)
{
"data": {
"id": "d1d1d1d1-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"url": "https://example.com/hooks/vitrina",
"secret": "whsec_d1d…",
"events": [
"lead.created",
"sla.breached"
],
"enabled": false,
"description": "ERP sync",
"created_at": "2026-09-20T01:41:04.274Z",
"updated_at": "2026-09-20T01:41:04.274Z",
"last_delivery_at": null,
"last_status": null,
"consecutive_failures": 0,
"owner_kind": "api_key",
"owner_id": "c1c1c1c1-0000-4000-8000-000000000001",
"include_data": false,
"paused_at": null,
"paused_reason": null,
"failing_since": null,
"consecutive_failed_deliveries": 0
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /webhooks/{id}/deliveries
List webhook delivery attempts
One row per ATTEMPT, newest first — the first place to look when "the webhooks don't arrive". error is http_<code>, timeout or the network error; response_excerpt is the first 500 characters of what the endpoint answered; request_payload is the exact body sent. A manual redelivery appears as a new row with redelivery_of set.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
limit | query | integer | no | ≥ 1, ≤ 200, por defecto 50 |
offset | query | integer | null | no | ≥ 0, por defecto 0 |
curl https://api.vitrinadev.com/api/v1/webhooks/<id>/deliveries \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": [
{
"id": 54,
"subscription_id": "d1d1d1d1-0000-4000-8000-000000000001",
"event": "lead.created",
"event_id": "99999999-0000-4000-8000-000000000001",
"attempt": 1,
"status_code": 200,
"error": null,
"response_excerpt": "{\"ok\":true}",
"request_payload": {
"id": "99999999-0000-4000-8000-000000000001",
"type": "lead.created",
"version": 1,
"created_at": "2026-09-20T01:42:11.194Z",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"resource": {
"type": "lead",
"id": "99999999-0000-4000-8000-000000000002",
"url": "https://api.vitrinadev.com/api/v1/leads/99999999-0000-4000-8000-000000000002"
},
"author": {
"kind": "api_key",
"id": "c1c1c1c1-0000-4000-8000-000000000001",
"name": "Website integration"
},
"data": {
"lead_id": "99999999-0000-4000-8000-000000000002",
"contact_id": "22222222-0000-4000-8000-000000000001",
"pipeline_id": "44444444-0000-4000-8000-000000000001",
"stage_id": "55555555-0000-4000-8000-000000000001",
"title": "Lead disparado desde el sitio",
"intent": "buy",
"source": "website",
"value_amount": null,
"value_currency": "CLP",
"owner_user_id": null,
"team_id": null
}
},
"latency_ms": 549,
"created_at": "2026-09-20T01:42:12.116Z",
"redelivery_of": null
}
],
"meta": {
"pagination": {
"limit": 50
},
"offset": 0
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
POST /webhooks/{id}/deliveries/{deliveryId}/redeliver
Redeliver one webhook delivery
Sends the delivery's event again — same X-Webhook-Event-Id, a fresh signature, ONE attempt — to the subscription's current url. Returns 202 once queued; the outcome appears in GET /webhooks/\{id\}/deliveries as a new row with redelivery_of = this delivery.
data is decided again, against the owner as it stands NOW: an owner who lost the read scope since gets the notice. Works on an auto-paused subscription (to test a fix before resuming); 409 on a disabled one. Audited as webhooks.redeliver.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí | |
deliveryId | path | integer | sí | > 0 |
curl -X POST https://api.vitrinadev.com/api/v1/webhooks/<id>/deliveries/<id>/redeliver \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (202)
{
"data": {
"subscription_id": "d1d1d1d1-0000-4000-8000-000000000001",
"delivery_id": 54,
"event": "lead.created",
"event_id": "99999999-0000-4000-8000-000000000001",
"status": "queued"
}
}Responde: 202 · 400 · 401 · 403 · 404 · 409 · 429
POST /webhooks/{id}/resume
Resume an auto-paused webhook subscription
Lifts the platform's AUTO-PAUSE (paused_at), which it sets after 20 failed deliveries in a row — events whose every retry failed — or 24 hours of failures without a single success, and restarts both failure streaks at zero. Events that happened while it was paused were not queued: read the resources to catch up, or redeliver from the delivery log.
Idempotent: resuming a subscription that is not paused returns it unchanged. Audited as webhooks.resume when it actually resumes. Separate from enabled, which is your own switch.
| Parámetro | En | Tipo | Obligatorio | Restricciones |
|---|---|---|---|---|
id | path | uuid | sí |
curl -X POST https://api.vitrinadev.com/api/v1/webhooks/<id>/resume \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"id": "d1d1d1d1-0000-4000-8000-000000000001",
"tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
"url": "https://example.com/hooks/vitrina",
"secret": "whsec_d1d…",
"events": [
"lead.created",
"sla.breached"
],
"enabled": true,
"description": "ERP sync",
"created_at": "2026-09-20T01:41:04.274Z",
"updated_at": "2026-09-20T01:41:04.274Z",
"last_delivery_at": null,
"last_status": null,
"consecutive_failures": 0,
"owner_kind": "api_key",
"owner_id": "c1c1c1c1-0000-4000-8000-000000000001",
"include_data": false,
"paused_at": null,
"paused_reason": null,
"failing_since": null,
"consecutive_failed_deliveries": 0
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429
GET /webhooks/events
The webhook event catalogue and the signing contract
Every event this workspace can dispatch, each with a sentence on what it means, the exact producer condition that fires it, its schema version, the resource_type it is about, the read_scope that unlocks data, whether it is sensitive (always a notice), an informal data_schema and a realistic sample — plus the signing block: the envelope, the two data modes and every data_omitted reason, the four X-Webhook-* headers plus Vitrina-Livemode, the HMAC scheme with verify pseudocode, the replay window, the retry schedule and the auto-pause thresholds.
Read this before hard-coding an event name. POST /webhooks accepts only names that appear here (plus * for everything), so a picker built from this response cannot offer something the create call will reject.
The catalogue is a constant, not tenant state: it is identical for every workspace and changes only when Vitrina ships a new event. Nothing here is secret — it needs webhooks:read because the rest of the family does, not because the content is sensitive.
Idempotency. Every delivery carries X-Webhook-Event-Id, and all retries and redeliveries of one event reuse it — so a bulk first import produces one id per car and a consumer can dedupe on it. Event ids are unique per event, never per attempt.
curl https://api.vitrinadev.com/api/v1/webhooks/events \
-H "Authorization: Bearer $VITRINA_KEY"Ejemplo de respuesta (200)
{
"data": {
"events": [
{
"name": "sending_domain.verified",
"version": 1,
"resource_type": "sending_domain",
"read_scope": "messaging_accounts:read",
"sensitive": false,
"description": "A tenant email sending domain finished DKIM verification and can now send email campaigns (ADR 0032).",
"fires_when": "A sending-domain refresh observes SES flip VerifiedForSendingStatus to true for the first time.",
"data_schema": {
"id (string, uuid)": "The verified sending domain",
"domain (string)": "The domain name"
},
"sample": {
"id": "99999999-0000-4000-8000-000000000005",
"domain": "mail.cliente.cl"
}
}
],
"signing": {
"envelope": {
"id (string, uuid)": "Unique event id; use for idempotency",
"type (string)": "One of the events above",
"version (integer)": "The schema version of this event type; bumped only on a breaking change to `data`",
"livemode (boolean)": "`false` when the event came from a sandbox (test-mode) workspace, `true` otherwise. Mirrored on the `Vitrina-Livemode` header.",
"created_at (string, ISO 8601 timestamp)": "When the event was produced",
"tenant_id (string, uuid)": "Your workspace id",
"resource (object)": "{ type, id, url } — what the event is about; GET `url` with your credential to read it",
"changes (object, optional)": "field → { from, to } for the identifiers and states that changed",
"author (object)": "{ kind: 'member' | 'api_key' | 'ai_agent' | 'system' | 'contact', id, name, via? } — who did it",
"data (object, optional)": "The resource payload (see catalog); present only when the subscription includes data and its owner may read it",
"data_omitted (string, optional)": "Why `data` is not in this delivery; exactly one of `data` / `data_omitted` is present"
},
"data_modes": {
"notice": "The default. Every delivery carries the notice — resource, changes, author, time, url — and `data_omitted: \"not_requested\"`.",
"data": "«Incluir datos del recurso» (`include_data: true`). `data` is added when, at dispatch time, the subscription owner holds the resource read scope and sees every record; otherwise the delivery is a notice with the reason in `data_omitted`.",
"not_requested": "The subscription did not ask for data.",
"sensitive": "The event is about a dato sensible (health data). It is always a notice, whatever the subscription asked for.",
"missing_scope:<scope>": "The owner does not hold that read scope (e.g. `missing_scope:contacts:read`).",
"restricted_visibility": "The owner sees only some records (assigned-only, or some sucursales).",
"owner_unavailable": "The owner is no longer an active member, or its API key was revoked or expired. Recreate the subscription, or edit its URL or events, to make yourself its owner."
},
"headers": {
"X-Webhook-Event": "The event name (e.g. lead.created).",
"X-Webhook-Event-Id": "Unique event id. Retries reuse this id so you can dedupe.",
"X-Webhook-Timestamp": "Unix seconds when the signature was generated.",
"X-Webhook-Signature": "t=<unix>,v1=<hex> — HMAC-SHA256 of `${timestamp}.${rawBody}` with your subscription secret.",
"Vitrina-Livemode": "`true` or `false`, mirroring the body's `livemode`. Signed as part of the body — verifying the signature verifies this too."
},
"signature_scheme": "hmac-sha256",
"signature_format": "t=<unix>,v1=<hex>",
"verify_pseudocode": [
"const [ts, sig] = parseHeader('X-Webhook-Signature'); // 't=…,v1=…'",
"const expected = hmac_sha256(secret, ts + '.' + rawBody); // hex",
"if (!timing_safe_equal(sig.v1, expected)) reject(401);",
"if (Math.abs(now_unix() - ts) > 300) reject(401); // 5-min replay window"
],
"timing_window_seconds": 300,
"retry": {
"attempts": 5,
"backoff": "exponential",
"delays_seconds": [
5,
10,
20,
40,
80
],
"note": "All retries reuse the same X-Webhook-Event-Id so your endpoint can be idempotent."
},
"auto_pause": {
"consecutive_failed_deliveries": 20,
"failing_hours": 24,
"note": "A subscription is paused when 20 events in a row exhaust their retries, or when it has failed for 24 hours without a single success. The workspace admins are notified once. A paused subscription receives nothing until POST /webhooks/{id}/resume; events in between are not queued — read the resources to catch up, or redeliver from the delivery log."
}
}
}
}Responde: 200 · 400 · 401 · 403 · 404 · 409 · 429