Receive leads in your CRM with webhooks
One subscription, the signature verified, and the two events that move a lead.
Car dealershipsOnly in car-dealership workspaces.
A lead that comes in from the website has to show up in the CRM without anybody copying and pasting. This recipe does it with one event subscription and an endpoint of yours. No polling every five minutes, no spreadsheets to export.
1. A key for the integration
A subscription has an owner, and the owner decides what data each delivery carries. An event arrives with the whole resource only if its owner may read it. So on top of the webhook permissions, the key needs read permission for the resource you're listening to:
curl -X POST https://api.vitrinadev.com/api/v1/api-keys \
-H "Authorization: Bearer $VITRINA_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "CRM leads del sitio",
"scopes": ["webhooks:read", "webhooks:write", "leads:read", "contacts:read"]
}'A key's name is a free-form label, and the examples on this site write theirs in Spanish. The API stores whatever you send.
Without leads:read the subscription is created all the same, and the deliveries arrive with no data, carrying "data_omitted": "missing_scope:leads:read". It's a quiet way to fail: the integration looks like it's running and brings back nothing.
2. The subscription
curl -X POST https://api.vitrinadev.com/api/v1/webhooks \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://crm.tu-dominio.cl/vitrina",
"events": ["lead.created", "lead.stage_changed", "lead.won", "lead.lost"],
"description": "leads al CRM",
"include_data": true
}'The response carries a secret beginning with whsec_, once and only once. Keep it: it's what you verify with that a POST claiming to come from Vitrina comes from Vitrina. If you lose it, delete the subscription and create another.
The url has to be public. A private address or localhost is rejected when the subscription is created, before any delivery is attempted. While you're developing, expose your receiver through a tunnel.
3. Verify the signature before you write anything
Every delivery carries X-Webhook-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 over ${timestamp}.${raw body}. The full verifier, in JavaScript and in Python, with a worked example you can reproduce, is in Get started.
Two rules worth more than the code:
- Sign the bytes you received, never a reserialisation. If your framework hands you the body already parsed, the original bytes are gone and every signature will fail.
- Check the timestamp. The window is 300 seconds. Without it, whoever has seen one delivery can replay it whenever they like.
4. The event that creates the lead
An enquiry from the website fires lead.created, and the POST goes out straight away. The delivery log keeps the exact body that went, so it's also how you see what your endpoint receives:
curl "https://api.vitrinadev.com/api/v1/webhooks/02ee9a15-a743-41ff-a343-cfea200bcfa1/deliveries?limit=1" \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": [
{
"id": 846,
"subscription_id": "02ee9a15-a743-41ff-a343-cfea200bcfa1",
"event": "lead.created",
"event_id": "c5cbbd3d-8b39-4d00-aa35-fe9daf98399c",
"attempt": 1,
"status_code": 200,
"error": null,
"response_excerpt": "",
"request_payload": {
"id": "c5cbbd3d-8b39-4d00-aa35-fe9daf98399c",
"data": {
"title": "Suzuki Swift 2021, consulta del sitio",
"intent": "buy",
"source": "website",
"lead_id": "9f52c753-a6cb-4d03-9837-6a1601fd8874",
"team_id": null,
"stage_id": "00000000-0000-4000-8000-000000004101",
"contact_id": "01a0cbfe-0e3a-7960-9901-ad153b1db135",
"pipeline_id": "00000000-0000-4000-8000-000000004000",
"value_amount": null,
"owner_user_id": null,
"value_currency": "CLP"
},
"type": "lead.created",
"author": {
"id": "0eb8e217-8a6f-4cb8-8216-b077194b3df7",
"kind": "api_key",
"name": "CRM leads del sitio"
},
"version": 1,
"resource": {
"id": "9f52c753-a6cb-4d03-9837-6a1601fd8874",
"url": "https://api.vitrinadev.com/api/v1/leads/9f52c753-a6cb-4d03-9837-6a1601fd8874",
"type": "lead"
},
"tenant_id": "00000000-0000-4000-8000-000000000001",
"created_at": "2026-09-23T02:00:19.841Z"
},
"latency_ms": 613,
"created_at": "2026-09-23T02:00:20.739063+00:00",
"redelivery_of": null
}
],
"meta": {
"pagination": {
"limit": 1
},
"offset": 0
}
}request_payload is the whole body as it travelled. source says which door the lead came in through: website, chileautos, yapo, mercadolibre, conversation. That field is how a dealership answers "where do the leads that close come from?". intent separates whoever wants to buy from whoever wants to sell or to finance.
The event doesn't carry the contact's details, only its contact_id. Each resource travels in its own event, behind its own permission. For the name and the phone number, read the contact with your key:
curl https://api.vitrinadev.com/api/v1/contacts/01a0cbfe-0e3a-7960-9901-ad153b1db135 \
-H "Authorization: Bearer $VITRINA_KEY"Trap
Treat the event's id as an idempotency key
We retry up to five times with a growing wait, and a manual redelivery repeats
the delivery. Your endpoint is going to see the same event more than once. id
is unique per event: store it and discard the one you've already processed.
Answering 200 quickly and processing afterwards is the other half. If you take
too long, it counts as a failure and comes back.
5. The event that moves it
When somebody moves the lead to another stage in the application:
{
"data": [
{
"id": 855,
"subscription_id": "02ee9a15-a743-41ff-a343-cfea200bcfa1",
"event": "lead.stage_changed",
"event_id": "11ff3b38-4465-47d5-844d-c5ab2ecb30e0",
"attempt": 1,
"status_code": 200,
"error": null,
"response_excerpt": "",
"request_payload": {
"id": "11ff3b38-4465-47d5-844d-c5ab2ecb30e0",
"data": {
"reason": null,
"lead_id": "9f52c753-a6cb-4d03-9837-6a1601fd8874",
"to_stage_id": "00000000-0000-4000-8000-000000004102",
"from_stage_id": "00000000-0000-4000-8000-000000004101",
"to_stage_slug": "contacted"
},
"type": "lead.stage_changed",
"author": {
"id": "0eb8e217-8a6f-4cb8-8216-b077194b3df7",
"kind": "api_key",
"name": "CRM leads del sitio"
},
"changes": {
"stage": {
"to": "00000000-0000-4000-8000-000000004102",
"from": "00000000-0000-4000-8000-000000004101"
}
},
"version": 1,
"resource": {
"id": "9f52c753-a6cb-4d03-9837-6a1601fd8874",
"url": "https://api.vitrinadev.com/api/v1/leads/9f52c753-a6cb-4d03-9837-6a1601fd8874",
"type": "lead"
},
"tenant_id": "00000000-0000-4000-8000-000000000001",
"created_at": "2026-09-23T02:00:36.146Z"
},
"latency_ms": 622,
"created_at": "2026-09-23T02:00:40.783945+00:00",
"redelivery_of": null
}
],
"meta": {
"pagination": {
"limit": 1
},
"offset": 0
}
}changes carries the from and the to in the envelope, ahead of data, so you can decide what to do without reading anything else. And to_stage_slug is the stage's stable name: the dealership can rename "Contactado" in the application without breaking your mapping.
lead.won and lead.lost close the cycle, and they're the ones your CRM wants for its reporting.
6. When "the webhooks are not reaching me"
Before writing to us, look at that same delivery log. Every attempt leaves a row, the failed ones included, with its status_code, its error and its attempt.
- There are rows with
status_codeat 500 or atnull→ the problem is at your endpoint, and we're retrying.errorandresponse_excerptsay what it answered. - There's no row at all → the event never fired, or your subscription doesn't include it. Check
events. - The subscription has a
paused_at→ too many deliveries failed in a row and we paused it ourselves rather than keep hammering a receiver that is down.
The two delivery modes, the retries, the auto-pause and redelivery are in Webhooks. Every event with its exact fields is in the catalogue, and the lot that produced this enquiry is in Publish your stock on your website.