VitrinaAPI

Empezar

De cero a una integración que escucha, con las respuestas de una corrida real.

Este recorrido va desde cero hasta una integración que escucha eventos firmados. Pasa por una credencial, una lectura, una falla, una suscripción y la verificación de la firma. Todo existe en cualquier workspace, sea del rubro que sea; lo propio de cada vertical está en su grupo, al final de esta página.

Las respuestas de esta página salen de una corrida contra un workspace de prueba con tres sucursales. Los identificadores serán otros; la forma es la misma.

1. Consigue una API key

Toda llamada a esta API viaja con una key sk_, y esa key decide de qué workspace lees.

Hay dos caminos para tener la primera, y cuál te toca depende de quién eres.

Si eres el workspace, la key se emite desde la aplicación, en Configuración › Desarrolladores › API keys. El secreto aparece una sola vez, al crearla:

La ventana «Key creada» de Vitrina: el secreto `sk_` completo, el botón Copiar y el aviso de que al cerrarla solo queda el prefijo

Cópialo en ese momento. Al cerrar la ventana la lista guarda el nombre, el prefijo, los permisos y cuándo se usó por última vez, y el secreto ya no:

La lista de API keys del workspace, con el prefijo de cada una, sus permisos, la fecha de último uso y su estado

Esa misma sección agrupa Webhooks, en ese orden y solo para administradores. Los servidores MCP se conectan aparte, en Conexiones › Servidores MCP.

Si ya tienes una key con el permiso api_keys:write, emites las siguientes tú mismo. Es una llamada, y es la que abre todo lo que sigue:

curl -X POST https://api.vitrinadev.com/api/v1/api-keys \
  -H "Authorization: Bearer $VITRINA_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "docs — lectura del workspace", "scopes": ["tenant:read"] }'
{
  "data": {
    "id": "2e613613-1142-4fea-a979-a9d3f274b07f",
    "tenant_id": "00000000-0000-4000-8000-000000000001",
    "name": "docs — lectura del workspace",
    "prefix": "sk_LV8lX",
    "scopes": ["tenant:read"],
    "created_at": "2026-09-22T03:16:03.368968+00:00",
    "last_used_at": null,
    "expires_at": null,
    "revoked_at": null,
    "secret": "sk_LV8lX…"
  }
}

data.secret es la llave, y es el único momento en que la vas a ver completa. Las lecturas posteriores devuelven prefix, sus primeros caracteres, y nada más. Aquí arriba va truncada; la tuya llega entera.

export VITRINA_KEY="<el data.secret completo de la respuesta>"

scopes es lo que esa key puede hacer, y conviene pedir lo mínimo. tenant:read abre la lectura del workspace y sus sucursales, y nada más. El catálogo completo, con qué abre cada permiso y qué roles lo llevan, está en Scopes.

Trampa

Pedir un permiso que tu key no tiene devuelve 403

Desde una credencial que solo lleva api_keys:read y api_keys:write, pedir tenant:read para la key nueva termina así:

{
  "error": {
    "code": "FORBIDDEN",
    "message": "You cannot grant an API key that includes a permission you do not have (tenant:read)",
    "requestId": "cf505046-9b85-424c-b0cf-e76a1ec8ac5c"
  }
}

Quien emite tiene que tener ya el permiso que reparte, para que una credencial acotada no se promueva sola.

2. Lee algo del workspace

Las sucursales son el mejor primer GET. Existen en todo workspace, sirven en cualquier rubro y no traen datos de ninguna persona.

curl https://api.vitrinadev.com/api/v1/locations \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": [
    {
      "id": "6812d9f0-9bed-44b5-91df-4e5b90941f6b",
      "tenant_id": "00000000-0000-4000-8000-000000000001",
      "name": "Sucursal Providencia",
      "address_street": "Av. Nueva Providencia",
      "address_number": "2214",
      "comuna_code": "13123",
      "region_code": "13",
      "phone": "+56229876543",
      "email": "[email protected]",
      "manager_name": "Paula Riquelme",
      "hours": { "lun_vie": "09:00-19:00", "sab": "10:00-14:00" },
      "timezone": "America/Santiago",
      "is_active": true,
      "display_seq": 1,
      "display_id": "B-1",
      "created_at": "2026-09-15T18:34:09.375Z",
      "updated_at": "2026-09-15T18:34:26.470Z"
    }
  ]
}

El ejemplo va recortado a una sucursal y a los campos que se explican solos. La respuesta real trae las tres y unos cuantos campos más, todos en Sucursales.

Fíjate en dos campos. display_id, aquí B-1, es el identificador que ve una persona en la aplicación, y las rutas que reciben un :id aceptan ese o el UUID. comuna_code es el código oficial de la comuna, porque la dirección se guarda en partes y no como una línea de texto.

3. Reconoce una falla

Si pides una sucursal que no existe:

curl https://api.vitrinadev.com/api/v1/locations/00000000-0000-4000-8000-0000000000ff \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Sucursal no encontrada",
    "requestId": "bf44aad4-6554-45ea-95f5-bc215f11d373"
  }
}

404, y el cuerpo trae error en vez de data. Una respuesta correcta trae data; una fallida trae error.

Las otras dos fallas del primer día son la credencial que falta y el permiso que falta. No son lo mismo:

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Missing bearer token",
    "requestId": "eb0c880e-20d1-4144-818c-d92d0a93ef05"
  }
}
{
  "error": {
    "code": "FORBIDDEN",
    "message": "Missing required scope: webhooks:write",
    "requestId": "f7614421-ef08-4638-8524-2af320d1ab3c"
  }
}

El 401 dice «no sé quién eres»; el 403, «sé quién eres y no te alcanza». Al segundo se llega con la key del paso 1, que nunca pidió webhooks:write. El paso siguiente lo arregla.

Ramifica por error.code y nunca por error.message. El código no cambia de redacción; el mensaje está escrito para una persona. Guarda también el requestId: con él encontramos tu llamada en los registros cuando algo no coincide.

El catálogo completo, con qué hacer ante cada uno, está en Errores.

4. Suscríbete a los eventos

Hasta aquí preguntaste; desde aquí te avisan. Una suscripción a webhooks es una URL tuya y una lista de eventos. Cada vez que uno ocurre sale un POST firmado hacia esa URL.

La misma suscripción se administra desde la aplicación, en Configuración › Desarrolladores › Webhooks, con su registro de entregas al lado:

La pantalla de Webhooks de Vitrina: las suscripciones del workspace, los eventos de cada una y el estado de sus últimas entregas

Esta parte pide una segunda key: suscribirse, leer el registro de entregas y leer los dos recursos que vas a escuchar. Los dos últimos permisos están ahí por los webhooks. Esta key va a ser la dueña de la suscripción, y un evento trae los datos del recurso solo si su dueño puede leerlo. Sin contacts:read, contact.created llega sin data y con "data_omitted": "missing_scope:contacts:read".

curl -X POST https://api.vitrinadev.com/api/v1/api-keys \
  -H "Authorization: Bearer $VITRINA_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "docs — integración de eventos",
    "scopes": ["webhooks:read", "webhooks:write", "contacts:read", "appointments:read"]
  }'
{
  "data": {
    "id": "44d47e2e-51ad-4787-92e3-e060d4df4dcc",
    "name": "docs — integración de eventos",
    "prefix": "sk_AdHmS",
    "scopes": ["webhooks:read", "webhooks:write", "contacts:read", "appointments:read"],
    "created_at": "2026-09-22T03:16:13.725936+00:00",
    "secret": "sk_AdHmS…"
  }
}
export VITRINA_KEY="<el data.secret completo de la respuesta>"

Ahora sí, la suscripción:

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", "appointment.booked"],
    "description": "recorrido de Empezar",
    "include_data": true
  }'
{
  "data": {
    "id": "3c320af5-54a9-4959-9d7f-875d2a046db9",
    "tenant_id": "00000000-0000-4000-8000-000000000001",
    "url": "https://tu-dominio.cl/vitrina",
    "secret": "whsec_ae3…",
    "events": ["contact.created", "appointment.booked"],
    "enabled": true,
    "description": "recorrido de Empezar",
    "created_at": "2026-09-22T03:19:34.808819+00:00",
    "updated_at": "2026-09-22T03:19:34.808819+00:00",
    "last_delivery_at": null,
    "last_status": null,
    "consecutive_failures": 0,
    "owner_kind": "api_key",
    "owner_id": "44d47e2e-51ad-4787-92e3-e060d4df4dcc",
    "include_data": true,
    "paused_at": null,
    "paused_reason": null,
    "failing_since": null,
    "consecutive_failed_deliveries": 0
  }
}

Tres campos de esa respuesta.

include_data: true es «Incluir datos del recurso»: pide que cada entrega traiga el recurso completo en data. Sin él, la misma entrega llega como aviso, con "data_omitted": "not_requested", diciendo qué recurso, quién, cuándo y una url para leerlo. Los dos modos están explicados en Webhooks. owner_kind y owner_id dicen quién es el dueño: la key con la que la creaste.

secret viene entero una sola vez, aquí. Arriba va truncado; el tuyo llega con whsec_ y 64 caracteres hexadecimales. Toda lectura posterior te devuelve nueve caracteres y puntos suspensivos. Guárdalo antes de cerrar la terminal: sin él no puedes verificar nada, y no se recupera.

url tiene que ser pública. Registrar http://localhost:4000/... responde VALIDATION_ERROR con Blocked private/loopback address, y el capítulo de Webhooks explica por qué.

5. Verifica la firma

Cada entrega trae cuatro headers:

X-Webhook-Event: contact.created
X-Webhook-Event-Id: 4026912d-c811-45cf-87c1-4147e5843a01
X-Webhook-Timestamp: 1790047183
X-Webhook-Signature: t=1790047183,v1=<hex>

El v1 es un HMAC-SHA256 con el secreto de tu suscripción, sobre el string ${timestamp}.${cuerpo}: el timestamp, un punto y el cuerpo crudo, byte por byte como llegó.

Este es un ejemplo resuelto, con los tres valores y el resultado. El cuerpo es el de una entrega real. El secreto no lo es: el de verdad es una credencial viva. Va reemplazado por uno de ejemplo, con la firma recalculada sobre ese mismo cuerpo y ese mismo timestamp.

Secreto

whsec_2f6b1e93c0a74d58bd3e0f21a7c4859e6d0b3f47a91c25e8d764b0a3c518f92d

Timestamp

1790047183

Cuerpo crudo, en una línea, tal cual viaja:

{"id":"4026912d-c811-45cf-87c1-4147e5843a01","type":"contact.created","version":1,"created_at":"2026-09-22T03:19:40.395Z","tenant_id":"00000000-0000-4000-8000-000000000001","resource":{"type":"contact","id":"01a0c720-5327-7397-a40b-63944d902a3c","url":"https://api.vitrinadev.com/api/v1/contacts/01a0c720-5327-7397-a40b-63944d902a3c"},"author":{"kind":"api_key","id":"03509054-6048-4fd3-ae65-7f6e9f603a41","name":"Integración CRM"},"data":{"id":"01a0c720-5327-7397-a40b-63944d902a3c","name":"Camila Herrera","email":"[email protected]","phone":"+56961234568","lifecycle_stage":"unknown","origin_channel":"manual","company_id":null,"created_at":"2026-09-22T03:19:39.127+00:00"}}

Firma esperada

56778d7f417517d2f239f6233a8469d364b0f426a7d1ae195e5c718027fd5287

Y este es el verificador, sin dependencias, en los tres lenguajes en los que suele escribirse un receptor:

# La firma es un HMAC sobre `${timestamp}.${cuerpo}`; openssl basta para verla.
TS=1790047183
BODY=$(cat entrega.json)   # el cuerpo crudo, sin reindentar

printf '%s.%s' "$TS" "$BODY" \
  | openssl dgst -sha256 -hmac "$VITRINA_WEBHOOK_SECRET" -r \
  | cut -d' ' -f1

-r imprime el hex solo; compáralo con el v1 del header. Para un receptor de verdad usa uno de los dos siguientes: esto no compara en tiempo constante ni revisa la ventana de tiempo.

Cualquiera de los dos verificadores, corrido sobre los valores de arriba, imprime esto:

X-Webhook-Timestamp: 1790047183
X-Webhook-Signature: t=1790047183,v1=56778d7f417517d2f239f6233a8469d364b0f426a7d1ae195e5c718027fd5287
v1 = 56778d7f417517d2f239f6233a8469d364b0f426a7d1ae195e5c718027fd5287
verificación (reloj del envío): {"ok":true,…}
verificación (una hora después): stale
un byte cambiado: bad_signature

Las tres últimas líneas son el punto. Con el reloj del envío, la firma vale. Una hora después la misma entrega se rechaza por vieja, que es lo que impide reenviar una entrega vista el mes pasado. Y con un solo byte del cuerpo cambiado, la firma deja de coincidir.

Trampa

Con req.body ya parseado, toda firma falla

JSON.parse seguido de JSON.stringify te devuelve un objeto equivalente y un string distinto, y la firma es sobre el string. Si tu framework te entrega req.body ya parseado, los bytes originales se perdieron y toda firma va a fallar sin que entiendas por qué.

En Express, quédate con el buffer:

app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

En Python es lo mismo con otros nombres: request.get_data() en Flask en vez de request.json, await request.body() en FastAPI, request.body en Django.

Compara en tiempo constante, con crypto.timingSafeEqual o hmac.compare_digest en vez de ===. Y revisa siempre el timestamp: sin ventana, una firma vieja sigue sirviendo.

6. Recibe el primer evento

Con la suscripción viva, alguien creó un contacto en el workspace. Esto llegó al receptor, entero:

{
  "id": "4026912d-c811-45cf-87c1-4147e5843a01",
  "type": "contact.created",
  "version": 1,
  "created_at": "2026-09-22T03:19:40.395Z",
  "tenant_id": "00000000-0000-4000-8000-000000000001",
  "resource": {
    "type": "contact",
    "id": "01a0c720-5327-7397-a40b-63944d902a3c",
    "url": "https://api.vitrinadev.com/api/v1/contacts/01a0c720-5327-7397-a40b-63944d902a3c"
  },
  "author": {
    "kind": "api_key",
    "id": "03509054-6048-4fd3-ae65-7f6e9f603a41",
    "name": "Integración CRM"
  },
  "data": {
    "id": "01a0c720-5327-7397-a40b-63944d902a3c",
    "name": "Camila Herrera",
    "email": "[email protected]",
    "phone": "+56961234568",
    "lifecycle_stage": "unknown",
    "origin_channel": "manual",
    "company_id": null,
    "created_at": "2026-09-22T03:19:39.127+00:00"
  }
}

El sobre dice de qué se trata antes de data. resource es el contacto, con la url para leerlo. author es quien lo creó, aquí una API key. Si lo hubiera creado una persona desde la aplicación, diría "kind": "member" con su nombre. version es la versión del esquema de data, y sube cuando ese esquema cambia de forma incompatible.

phone viene en E.164 cuando se pudo normalizar. lifecycle_stage empieza en unknown; la mueve el equipo desde la aplicación, nunca la API.

Tres cosas no disparan este evento: un contacto que llega junto a una conversación (WhatsApp, Instagram, correo…), una importación por CSV y una fusión de duplicados. El capítulo de Eventos lleva esa lista por evento.

7. Recibe un segundo, de otro recurso

Después le agendaron una visita a ese mismo contacto:

{
  "id": "ff9de834-d795-4d27-b9a0-b6c322ba6e77",
  "type": "appointment.booked",
  "version": 1,
  "created_at": "2026-09-22T03:20:59.358Z",
  "tenant_id": "00000000-0000-4000-8000-000000000001",
  "resource": {
    "type": "appointment",
    "id": "ac36555c-9d1f-4413-ad72-f2ea08afbf41",
    "url": "https://api.vitrinadev.com/api/v1/appointments/ac36555c-9d1f-4413-ad72-f2ea08afbf41"
  },
  "author": {
    "kind": "api_key",
    "id": "03509054-6048-4fd3-ae65-7f6e9f603a41",
    "name": "Integración CRM"
  },
  "data": {
    "id": "ac36555c-9d1f-4413-ad72-f2ea08afbf41",
    "display_id": "A-1",
    "status": "confirmed",
    "kind": "external",
    "starts_at": "2026-09-25T14:00:00.000Z",
    "ends_at": "2026-09-25T14:45:00.000Z",
    "vehicle_id": null,
    "owner_user_id": null,
    "lead_id": null,
    "contact_id": "01a0c720-5327-7397-a40b-63944d902a3c",
    "appointment_type_id": null
  }
}

Mismo sobre, otro recurso: eso es todo lo que cambia entre un evento y otro. contact_id enlaza con el evento anterior. kind dice de qué clase de cita se trata, y cada vertical usa las suyas; el capítulo de Eventos las enumera.

starts_at y ends_at son UTC, siempre. La zona horaria con la que una persona los ve es la de su sucursal (timezone, en el paso 2).

8. Revisa qué entregamos

Cada intento de entrega deja una fila, incluidos los que fallaron:

curl https://api.vitrinadev.com/api/v1/webhooks/3c320af5-54a9-4959-9d7f-875d2a046db9/deliveries \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": [
    {
      "id": 113,
      "subscription_id": "3c320af5-54a9-4959-9d7f-875d2a046db9",
      "event": "appointment.booked",
      "event_id": "ff9de834-d795-4d27-b9a0-b6c322ba6e77",
      "attempt": 1,
      "status_code": 200,
      "error": null,
      "response_excerpt": "{\"received\":true}",
      "latency_ms": 676,
      "created_at": "2026-09-22T03:21:01.237791+00:00",
      "redelivery_of": null
    },
    {
      "id": 112,
      "subscription_id": "3c320af5-54a9-4959-9d7f-875d2a046db9",
      "event": "contact.created",
      "event_id": "4026912d-c811-45cf-87c1-4147e5843a01",
      "attempt": 1,
      "status_code": 200,
      "error": null,
      "response_excerpt": "{\"received\":true}",
      "latency_ms": 691,
      "created_at": "2026-09-22T03:19:44.069495+00:00",
      "redelivery_of": null
    }
  ],
  "meta": { "pagination": { "limit": 50 }, "offset": 0 }
}

Cada fila trae además request_payload, el sobre exacto que enviamos. Aquí no aparece, porque ya lo viste dos veces. redelivery_of va en null salvo en un reenvío hecho a mano, que apunta a la fila que repite.

Es el primer lugar donde mirar cuando «no llegan los webhooks». Con filas en status_code 500, el problema está en tu endpoint y nosotros reintentamos. Sin ninguna fila, el evento nunca se disparó o tu suscripción no lo incluye.

Desde aquí, Autenticación y API keys cubre la rotación, la revocación y el techo de llamadas por minuto. Webhooks cubre los reintentos y la auto-pausa, y el catálogo de eventos trae los campos de cada uno. Para leer el workspace desde Claude o Cursor, en solo lectura, está MCP.

Y de aquí en adelante, lo propio de tu rubro:

En esta página