Webhooks
Para programadores: los eventos (también los que piden una acción), el cuerpo de cada aviso, cómo verificar la firma y cómo funcionan los reintentos.
Revisado el 28 de octubre de 2026
Cómo funciona
Un webhook es una dirección HTTPS de tu servidor a la que AtiendeFácil le hace un POST con un JSON cada vez que pasa algo que elegiste: entra un pedido, se aprueba un pago, escribe un cliente, cambia un precio… Así tu sistema se entera al instante sin preguntar a cada rato.
El aviso se prepara en el mismo momento en que se guarda el cambio: si el cambio queda hecho, el aviso existe (aunque el servidor se reinicie), y si el cambio se deshace, el aviso también. Suele llegar en uno o dos segundos.
Configurar un webhook
Lo crea el dueño o alguien del equipo con permiso para editar Ajustes: pega tu dirección, marca los eventos y pulsa Probar. Después te pasa el secreto de firma (whsec_…) para que verifiques los avisos. Los pasos están en Integraciones.
Panel web
App
La dirección tiene que cumplir esto:
- Empezar por
https://, con un certificado válido, y ser un dominio público: no se aceptan IP,localhostni direcciones de red interna (tampoco un dominio que resuelva a una). - Sin usuario ni contraseña dentro de la URL, y de hasta 500 caracteres.
- No redirigir: las redirecciones (
3xx) no se siguen y cuentan como fallo.
Un negocio puede tener hasta 5 webhooks, cada uno con sus eventos y su propio secreto. Si dos están suscritos al mismo evento, cada uno recibe su aviso con un id distinto.
Crear el webhook por la API
Además de crearlo a mano en Ajustes → Integraciones, tu sistema puede conectarse solo con su clave de API.
| Petición | Qué hace |
|---|---|
GET /webhooks | Los webhooks del negocio. La clave de acceso nunca se devuelve; el secreto de firma, tampoco. |
POST /webhooks | Crea uno. Cuerpo: url, events (lista), y opcionales description, auth_key y auth_header (la clave de acceso y su cabecera). Devuelve el webhook con su secret: es la única vez que viaja, guárdalo. Si ya había un webhook a esa misma URL, lo reemplaza, con secreto nuevo. |
POST /webhooks/{id}/test | Manda un ping ahora y responde delivered, status_code y error. |
PATCH /webhooks/{id} | Lo cambia: url, events, description, auth_key (null la quita), auth_header e is_active (encenderlo pone a cero la cuenta de fallos). Manda solo lo que cambia. El secreto no se toca. |
POST /webhooks/{id}/rotate-secret | Secreto de firma nuevo, que viene en secret. El anterior deja de valer en el momento. |
GET /webhooks/{id}/deliveries | El historial de envíos (30 días), de lo más nuevo a lo más viejo, de 20 en 20: page y status (pending, succeeded o failed). Cada envío trae id, event, status, attempts, status_code, error, created_at, delivered_at y next_attempt_at. |
POST /webhook-deliveries/{id}/redeliver | Vuelve a poner ese envío en cola, con todos sus intentos y el mismo cuerpo. Es el botón Reenviar. |
DELETE /webhooks/{id} | Lo borra. |
Valen las mismas reglas que en el panel: URL pública con https, eventos del catálogo y el mismo tope de webhooks (409 webhooks_full si ya no caben).
Clave de acceso
Si tu servidor rechaza todo lo que llega sin su clave (un túnel, una pasarela de API, un cortafuegos de aplicación), el dueño puede guardar en el webhook una clave de acceso: una cabecera fija que AtiendeFácil manda en cada aviso, también en el de Probar y en los reintentos. Se pone al crear el webhook o al editarlo, en la sección Clave de acceso, en el panel y en la app.
- Viaja en la cabecera
x-api-keysi no se elige otra. El nombre de la cabecera lleva solo letras, números y guiones (hasta 64 caracteres) y se manda en minúsculas. - No puede ser una cabecera que ya usa el aviso:
host,content-type,content-length,user-agent,connection,transfer-encoding,cookieni ninguna que empiece porx-atiendefacil-. - La clave admite hasta 500 caracteres, sin saltos de línea, acentos ni emojis. Se manda tal cual se pegó: si tu servidor espera un prefijo (
Bearer …), va dentro de la clave. - Después de guardarla no se vuelve a mostrar: en pantalla solo se ve en qué cabecera viaja. Se puede reemplazar por otra o quitar.
Eventos
Hay 32 eventos. El nombre del evento es el contrato: no se renombra, y con el tiempo pueden añadirse otros.
| Grupo | Evento | Cuándo sale |
|---|---|---|
| Pedidos | order.created | Un pedido deja de ser carrito: el cliente lo confirmó o se creó desde el panel. |
order.status_changed | Pasó a confirmado, preparando, en camino, entregado, cancelado… | |
order.delivery_quote_needed | Un pedido espera que le pongan el costo del delivery para poder cobrarlo. | |
order.assigned | Se le asignó (o cambió) el repartidor a un pedido. | |
| Pagos | payment.created | Llegó un comprobante o se registró un cobro. |
payment.status_changed | Un pago se aprobó, se rechazó o pasó a revisión. | |
payment.needs_review | Llegó un comprobante que nadie va a confirmar solo (sin banco conectado o el monto no cuadra): hay que aprobarlo o rechazarlo. | |
| Citas | appointment.created | Se agendó una cita. |
appointment.status_changed | Confirmada, completada, cancelada o el cliente no vino. | |
appointment.rescheduled | Cambió la hora, la duración, el servicio o quién atiende. | |
appointment.deleted | La cita se eliminó. | |
| Clientes | customer.created | Escribió por primera vez o se creó a mano. |
customer.updated | Cambió su nombre, teléfono, documento, dirección, notas o si pidió que el bot no le responda. Trae qué cambió. | |
customer.deleted | Se borró un cliente (y con él sus pedidos y citas). | |
customer.lookup | Es una consulta, no un aviso: cuando escribe alguien que todavía no está identificado, se le pregunta a tu sistema si lo conoce. Si responde con su nombre y su cédula, quedan en su ficha y el asistente no se los pide. | |
| Mensajes | message.received | Cada mensaje que un cliente escribe por WhatsApp. |
message.sent | Cada mensaje que sale del negocio hacia un cliente y queda en su chat: del asistente, de una persona del equipo o de tu integración. Trae quién lo mandó. | |
| Viajes | ride.created | Se pidió un viaje. |
ride.status_changed | Aceptado, en camino, en curso, completado o cancelado. | |
ride.needs_driver | No quedó ningún conductor a quien ofrecerle el viaje (al pedirlo o después de que lo rechazaran): hay que asignarlo a mano. | |
| Catálogo | product.created | Se añadió un producto o servicio al catálogo (o se restauró uno borrado). |
product.updated | Cambió el nombre, el precio, el stock, el código de barras, la disponibilidad u otro dato. Incluye el stock que baja con cada venta. | |
product.deleted | Se quitó un producto o servicio del catálogo. | |
| Tasa de cambio | rate.updated | Cambió la tasa manual del negocio o pasó de tasa BCV a manual (o al revés). |
| Conversaciones | conversation.needs_agent | El cliente pidió hablar con alguien o el bot no pudo seguir. Trae el motivo. |
conversation.taken_over | Alguien del equipo tomó la conversación (desde el teléfono del negocio o por la API); el bot se calla ahí hasta que se la devuelvan. | |
conversation.bot_resumed | La conversación que atendía una persona vuelve a atenderla el bot. | |
| Cuenta y WhatsApp | bot.paused | El asistente dejó de responder: lo pausó el dueño o el sistema por riesgo para el número. |
bot.resumed | El asistente vuelve a responder. | |
whatsapp.disconnected | Se cerró la sesión de WhatsApp: hay que volver a vincular el número. No avisa de las reconexiones de rutina. | |
whatsapp.connected | El número volvió a quedar vinculado después de cerrarse la sesión (o se vinculó uno nuevo). | |
account.balance_low | Al negocio le queda poco saldo de tokens: si se acaba, el asistente deja de responder. |
order.createdsale cuando el pedido deja de ser carrito, no mientras el bot lo arma.message.receivedsale solo con lo que escribe el cliente. Lo que le manda el negocio —el asistente, una persona del equipo o tu integración— sale comomessage.sent, en cuanto queda en el chat: no espera a que WhatsApp confirme la entrega. Un mensaje que no se pudo mandar por estar fuera de la ventana de 24 horas no avisa. Los mensajes que tú mandas por la API también llegan: reconócelos por elidque te devolvióPOST /messagespara no contestarte a ti mismo.product.updatedsale también cuando una venta baja el stock.rate.updatedno sale cuando el BCV publica tasa nueva: solo cuando el negocio cambia su modo, su tasa manual o su moneda base.whatsapp.disconnectedsale solo cuando la sesión se cerró y hay que volver a vincular el número. Las reconexiones de rutina, que pasan varias veces al día y se arreglan solas, no avisan.whatsapp.connectedsale al volver a vincularlo (o al vincular un número nuevo).account.balance_lowsale una vez cuando el saldo baja del aviso de saldo bajo (ver Saldo y facturación), y no se repite hasta que el negocio recarga y vuelve a bajar.conversation.needs_agentno sale con la espera de la cotización del envío: esa tiene su propio evento,order.delivery_quote_needed.customer.lookupes el único que no es un aviso sino una consulta: se espera tu respuesta y se usa. Tiene sus propias reglas (ver Consultar un cliente en tu sistema).
La misma lista, por la API: { "data": [ { "type", "description" } ] }.
Un cambio puede disparar varios eventos
Cada evento mira una cosa distinta, así que un mismo cambio puede mandar dos o más avisos a la vez, cada uno con su id. Por ejemplo:
- Una cita que se mueve de hora y a la vez vuelve a
requested: lleganappointment.status_changedyappointment.rescheduled. - Un pedido que pasa a
out_for_deliverycon un repartidor nuevo:order.status_changedyorder.assigned. - Un comprobante que el sistema no puede confirmar solo:
payment.created(opayment.status_changed) ypayment.needs_review.
Suscríbete solo a los que vayas a usar y trata cada aviso por separado: el orden en que llegan no está garantizado.
Eventos que piden una acción
La mayoría de eventos solo informan. Estos, en cambio, dicen que algo está esperando a una persona: son los mismos avisos que le llegan al dueño por WhatsApp o en la app, y tu sistema puede resolverlos (o avisar a quien toque).
| Evento | Cómo se resuelve |
|---|---|
conversation.needs_agent | Contesta con POST /messages (eso ya levanta la escalada) o toma el chat con POST /conversations/{id}/takeover y devuélvelo con /resume al terminar. Ver Conversaciones. |
payment.needs_review | POST /payments/{id}/approve o /reject. Ver aprobar un comprobante. |
order.delivery_quote_needed | POST /orders/{id}/delivery-quote con el costo del envío. Ver cotizar el envío. |
ride.needs_driver | POST /rides/{id}/assign con el conductor, o el dueño lo asigna desde el panel o la app. Comprueba antes con GET /rides/{id} que siga sin conductor. Ver asignar un viaje. |
whatsapp.disconnected | No se resuelve por la API: hay que volver a vincular el número desde el panel o la app (ver Conectar WhatsApp). Mientras tanto no entra ni sale ningún mensaje. |
account.balance_low | Recargar saldo desde el panel o la app (ver Saldo y facturación). Si se acaba, el asistente deja de responder. |
Una escalada (conversation.needs_agent) que nadie atiende no dura para siempre: si pasan 12 horas y el cliente vuelve a escribir, el bot retoma la conversación solo y sale conversation.bot_resumed. Un chat tomado a mano (conversation.taken_over), en cambio, no caduca: sigue así hasta que alguien lo devuelve.
Consultar un cliente en tu sistema
Si el negocio ya tiene a sus clientes en otro sistema (un ERP, un punto de venta, un CRM), customer.lookup evita que el asistente les pida datos que ya están ahí. Cuando escribe alguien a quien le falta el nombre o la cédula, AtiendeFácil le pregunta a tu servidor por su teléfono antes de contestarle. Si lo conoces, respondes con sus datos: quedan en su ficha, el saludo ya va por su nombre y el asistente no se los pide.
Lo que te llega
Un POST a la misma dirección del webhook, con la misma forma, las mismas cabeceras y la misma firma que cualquier aviso. data.object es el cliente tal como está en ese momento; búscalo por phone (solo dígitos, con código de país).
{
"id": "2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b",
"type": "customer.lookup",
"created_at": "2026-10-11T14:02:07.512Z",
"company_id": "0f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a",
"data": {
"object": {
"id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
"object": "customer",
"name": "Mari 🌸",
"phone": "584140000000",
"id_document": null,
"address": null,
"notes": null,
"name_confirmed": false,
"bot_muted": false,
"created_at": "2026-10-11T14:02:07.101Z",
"updated_at": "2026-10-11T14:02:07.101Z"
}
}
}Con name_confirmed: false, name es solo el nombre de su perfil de WhatsApp: no lo uses para buscarlo.
Lo que respondes
Un 2xx con un JSON. Si lo conoces, sus datos dentro de customer; manda solo los que tengas:
HTTP/1.1 200 OK
Content-Type: application/json
{
"customer": {
"name": "María Pérez",
"id_document": "V-00000000",
"address": "Av. Principal de Los Palos Grandes, casa 12"
}
}| Campo | Qué es |
|---|---|
name | Nombre completo, hasta 160 caracteres. Queda como nombre confirmado. |
id_document | Cédula o RIF. Se valida y se guarda en el formato del sistema (V-00000000, J-00000000-0; sin letra se toma como V). Si no es válido, se ignora ese campo y lo demás se guarda. |
address | Dirección, hasta 500 caracteres. |
Si no lo conoces, dilo también con un 2xx:
HTTP/1.1 200 OK
Content-Type: application/json
{ "customer": null }Vale igual un cuerpo vacío o un JSON sin customer. En Express, dentro del mismo receptor:
// Dentro del receptor de arriba, después de verificar la firma:
if (event.type === "customer.lookup") {
const phone = event.data.object.phone; // "584140000000"
const row = await erp.findCustomerByPhone(phone); // tu consulta, rápida
return res.json({
customer: row
? { name: row.fullName, id_document: row.taxId, address: row.address }
: null,
});
}Reglas
- Tienes 4 segundos, no 10: el cliente está esperando su respuesta. Si no llegas, respondes otra cosa que un
2xxo no se puede conectar, el asistente sigue sin el dato y lo pide como siempre. - No se reintenta ni pasa por la cola. Si falló, se vuelve a consultar con el siguiente mensaje de ese cliente (tras un fallo, tu servidor descansa un minuto de consultas). Los fallos de consulta no apagan el webhook.
- Una vez por cliente. En cuanto respondes un
2xx, lo conozcas o no, no se vuelve a preguntar por él. Si lo das de alta después en tu sistema, mándalo conPATCH /customers/{id}. - Solo rellena lo que falta. Lo que el cliente ya dijo por el chat, o lo que el negocio escribió en su ficha, no se cambia. El nombre del perfil de WhatsApp sí se reemplaza.
- Se consulta por quien escribe y le falta el nombre confirmado o la cédula, sea nuevo o no: al conectar tu sistema también se pregunta por los clientes de antes que nunca se identificaron, la próxima vez que escriban. Crear un cliente a mano no dispara la consulta (se hará cuando escriba, si le falta algo), y el banco de pruebas del asistente tampoco.
- Si varios webhooks están suscritos a
customer.lookup, se consulta solo al más antiguo de los encendidos. - Si la respuesta cambió la ficha, sale
customer.updatedcondata.source = "lookup:<id del webhook>", para que reconozcas que esos datos son los tuyos. - La consulta queda en el Historial de envíos con lo que respondió tu servidor. Reenviar una consulta la manda otra vez, pero esa respuesta ya no se lee.
- El botón Probar manda un
ping, no una consulta: para probarla, escribe al WhatsApp del negocio desde un número que no esté identificado.
El cuerpo del aviso
Todos los avisos tienen la misma forma:
{
"id": "0c6f1e2d-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
"type": "order.status_changed",
"created_at": "2026-09-29T18:03:40.130Z",
"company_id": "0f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a",
"data": {
"object": { "id": "5b7c2e1a-…", "object": "order", "status": "preparing", … },
"status": "preparing",
"previous_status": "confirmed"
}
}| Campo | Qué es |
|---|---|
id | Id único del envío. Es el mismo que la cabecera X-AtiendeFacil-Delivery. |
type | El evento (order.status_changed) o ping en las pruebas. |
created_at | Cuándo pasó el cambio (ISO 8601, UTC). |
company_id | El negocio. Es el company.id de GET /me. |
data.object | El recurso completo, con la misma forma que en la API: un pedido, un pago, un producto… (ver cada artículo). |
data.* | Datos del momento del cambio, según el evento (tabla de abajo). |
data.source | Solo si el cambio lo hizo una integración con su clave: "api:<id de la clave>". Puede venir en cualquier evento (ver tus propios cambios). |
| Evento | Qué trae data además de object |
|---|---|
order.created | status: el estado con el que nació. |
order.status_changed | status y previous_status. |
payment.created | status. |
payment.status_changed | status y previous_status. El pago trae conversation_id para contestar en el chat. |
appointment.created | status. |
appointment.status_changed | status y previous_status. |
appointment.rescheduled | status y previous_starts_at (la hora de antes). |
appointment.deleted | Nada. object solo trae id, object y deleted: true. |
customer.created | Nada. |
customer.updated | changes: qué cambió, entre name, phone, id_document, address, notes y bot_muted. |
message.received | Nada. |
ride.created | status. |
ride.status_changed | status y previous_status. |
product.created | Nada. |
product.updated | changes (qué cambió) y previous_stock (si cambió el stock). |
product.deleted | Nada. object.deleted_at trae la fecha. |
rate.updated | mode, manual_rate, base_currency, previous_mode y previous_manual_rate. |
conversation.needs_agent | reason: lo que preguntó el cliente o por qué se detuvo el bot (lo mismo que object.agent_reason). |
conversation.taken_over | Nada. |
conversation.bot_resumed | Nada. |
payment.needs_review | status (siempre review). |
order.delivery_quote_needed | conversation_id: el chat del cliente. object es el pedido todavía en draft, sin costo de envío. |
order.assigned | assigned_to (id del repartidor) y previous_assigned_to (el de antes, o null). |
ride.needs_driver | status y reason: no_drivers_available (no había nadie al pedirlo) o no_drivers_left (se le ofreció a todos y nadie lo tomó). |
customer.deleted | phone y name que tenía. object solo trae id, object y deleted: true. |
bot.paused | pause_source (manual: lo pausó el negocio; risk: el sistema, para proteger el número) y reason (el motivo, o null). |
bot.resumed | previous_pause_source: quién lo había pausado. |
whatsapp.disconnected | status (logout), previous_status y phone (el número que estaba vinculado). |
whatsapp.connected | status (connected) y phone (el número vinculado). |
account.balance_low | balance: los tokens que quedaban al avisar (número). |
customer.lookup | Nada. Es una consulta: lo que importa es lo que respondes (ver Consultar un cliente en tu sistema). |
message.sent | sender: quién lo mandó, assistant (el asistente o un aviso automático) o agent (una persona del equipo, o tu integración con POST /messages). Es el mismo valor que object.sender. |
Ejemplos por evento
El data de cada evento (el objeto va abreviado con …; completo es igual que en la API).
Pedidos
order.created — Un pedido deja de ser carrito: el cliente lo confirmó o se creó desde el panel.
{
"object": { "id": "5b7c2e1a-…", "object": "order", "number": "4A1B2C", "status": "pending_payment", … },
"status": "pending_payment"
}order.status_changed — Pasó a confirmado, preparando, en camino, entregado, cancelado…
{
"object": { "id": "5b7c2e1a-…", "object": "order", "number": "4A1B2C", "status": "preparing", … },
"status": "preparing",
"previous_status": "confirmed"
}order.delivery_quote_needed — Un pedido espera que le pongan el costo del delivery para poder cobrarlo.
{
"object": { "id": "5b7c2e1a-"fulfillment": "delivery", "delivery_fee": "0.00", …", "object": "order", "number": "4A1B2C", "status": "draft", … },
"conversation_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"
}order.assigned — Se le asignó (o cambió) el repartidor a un pedido.
{
"object": { "id": "5b7c2e1a-…", "object": "order", "number": "4A1B2C", "status": "out_for_delivery", … },
"assigned_to": "b3c4d5e6-f7a8-4b9c-8d0e-1f2a3b4c5d6e",
"previous_assigned_to": null
}Pagos
payment.created — Llegó un comprobante o se registró un cobro.
{
"object": { "id": "7a8b9c0d-…", "object": "payment", "status": "pending", "order_id": "5b7c2e1a-…", … },
"status": "pending"
}payment.status_changed — Un pago se aprobó, se rechazó o pasó a revisión.
{
"object": { "id": "7a8b9c0d-…", "object": "payment", "status": "approved", "order_id": "5b7c2e1a-…", … },
"status": "approved",
"previous_status": "pending"
}payment.needs_review — Llegó un comprobante que nadie va a confirmar solo (sin banco conectado o el monto no cuadra): hay que aprobarlo o rechazarlo.
{
"object": { "id": "7a8b9c0d-…", "object": "payment", "status": "review", "order_id": "5b7c2e1a-…", … },
"status": "review"
}Citas
appointment.created — Se agendó una cita.
{
"object": { "id": "d6e7f8a9-…", "object": "appointment", "status": "requested", "starts_at": "2026-10-02T16:00:00.000Z", … },
"status": "requested"
}appointment.status_changed — Confirmada, completada, cancelada o el cliente no vino.
{
"object": { "id": "d6e7f8a9-…", "object": "appointment", "status": "confirmed", "starts_at": "2026-10-02T16:00:00.000Z", … },
"status": "confirmed",
"previous_status": "requested"
}appointment.rescheduled — Cambió la hora, la duración, el servicio o quién atiende.
{
"object": { "id": "d6e7f8a9-…", "object": "appointment", "status": "confirmed", "starts_at": "2026-10-02T16:00:00.000Z", … },
"status": "confirmed",
"previous_starts_at": "2026-10-02T15:00:00+00:00"
}appointment.deleted — La cita se eliminó.
{
"object": { "id": "d6e7f8a9-b0c1-4d2e-9f3a-4b5c6d7e8f9a", "object": "appointment", "deleted": true }
}Clientes
customer.created — Escribió por primera vez o se creó a mano.
{
"object": { "id": "c4d5e6f7-…", "object": "customer", "name": null, "phone": "584140000000", … }
}customer.updated — Cambió su nombre, teléfono, documento, dirección, notas o si pidió que el bot no le responda. Trae qué cambió.
{
"object": { "id": "c4d5e6f7-…", "object": "customer", "name": "María Pérez", "phone": "584140000000", … },
"changes": ["address", "notes"]
}customer.deleted — Se borró un cliente (y con él sus pedidos y citas).
{
"object": { "id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f", "object": "customer", "deleted": true },
"phone": "584140000000",
"name": "María Pérez"
}customer.lookup — Es una consulta, no un aviso: cuando escribe alguien que todavía no está identificado, se le pregunta a tu sistema si lo conoce. Si responde con su nombre y su cédula, quedan en su ficha y el asistente no se los pide.
{
"object": { "id": "c4d5e6f7-…", "object": "customer", "name": "Mari 🌸", "phone": "584140000000", "id_document": null, "name_confirmed": false, … }
}Mensajes
message.received — Cada mensaje que un cliente escribe por WhatsApp.
{
"object": {
"id": "f5a6b7c8-…",
"object": "message",
"conversation_id": "e1f2a3b4-…",
"direction": "inbound",
"sender": "customer",
"type": "text",
"text": "Buenas, ¿tienen pizza de pepperoni?",
"location": null,
"file": null,
"has_media": false,
"customer": { "id": "c4d5e6f7-…", "name": "María Pérez", "phone": "584140000000" },
"created_at": "2026-09-29T18:40:12.551Z"
}
}message.sent — Cada mensaje que sale del negocio hacia un cliente y queda en su chat: del asistente, de una persona del equipo o de tu integración. Trae quién lo mandó.
{
"object": {
"id": "a6b7c8d9-…",
"object": "message",
"conversation_id": "e1f2a3b4-…",
"direction": "outbound",
"sender": "assistant",
"type": "text",
"text": "¡Hola, María! Sí, la de pepperoni está en $10. ¿Te anoto una?",
"location": null,
"file": null,
"has_media": false,
"customer": { "id": "c4d5e6f7-…", "name": "María Pérez", "phone": "584140000000" },
"created_at": "2026-09-29T18:40:19.204Z"
},
"sender": "assistant"
}Viajes
ride.created — Se pidió un viaje.
{
"object": { "id": "f8a9b0c1-…", "object": "ride", "status": "requested", "driver": null, … },
"status": "requested"
}ride.status_changed — Aceptado, en camino, en curso, completado o cancelado.
{
"object": { "id": "f8a9b0c1-…", "object": "ride", "status": "accepted", "driver": { "id": "…", "name": "Pedro" }, … },
"status": "accepted",
"previous_status": "requested"
}ride.needs_driver — No quedó ningún conductor a quien ofrecerle el viaje (al pedirlo o después de que lo rechazaran): hay que asignarlo a mano.
{
"object": { "id": "f8a9b0c1-…", "object": "ride", "status": "requested", "driver": null, … },
"status": "requested",
"reason": "no_drivers_left"
}Catálogo
product.created — Se añadió un producto o servicio al catálogo (o se restauró uno borrado).
{
"object": { "id": "a1b2c3d4-…", "object": "product", "external_id": "SKU-0042", "barcode": "7591234567895", "price": "10.00", "stock": 11, "deleted_at": null, … },
"source": "api:9f1c2b3a-4d5e-4f60-8a7b-1c2d3e4f5a6b"
}product.updated — Cambió el nombre, el precio, el stock, el código de barras, la disponibilidad u otro dato. Incluye el stock que baja con cada venta.
{
"object": { "id": "a1b2c3d4-…", "object": "product", "external_id": "SKU-0042", "barcode": "7591234567895", "price": "10.00", "stock": 11, "deleted_at": null, … },
"changes": ["price", "stock"],
"previous_stock": 12,
"source": "api:9f1c2b3a-4d5e-4f60-8a7b-1c2d3e4f5a6b"
}product.deleted — Se quitó un producto o servicio del catálogo.
{
"object": { "id": "a1b2c3d4-…", "object": "product", "external_id": "SKU-0042", "barcode": "7591234567895", "price": "10.00", "stock": 11, "deleted_at": "2026-09-29T20:15:00.000Z", … }
}Tasa de cambio
rate.updated — Cambió la tasa manual del negocio o pasó de tasa BCV a manual (o al revés).
{
"object": { "object": "rate", "mode": "manual", "manual_rate": "38.5000", "base_currency": "USD" },
"mode": "manual",
"manual_rate": 38.5000,
"base_currency": "USD",
"previous_mode": "bcv",
"previous_manual_rate": null,
"source": "api:9f1c2b3a-4d5e-4f60-8a7b-1c2d3e4f5a6b"
}Conversaciones
conversation.needs_agent — El cliente pidió hablar con alguien o el bot no pudo seguir. Trae el motivo.
{
"object": { "id": "e1f2a3b4-…", "object": "conversation", "needs_agent": true, "taken_over": false, "customer": { "id": "c4d5e6f7-…", … }, … },
"reason": "¿Hacen tortas por encargo para 40 personas?"
}conversation.taken_over — Alguien del equipo tomó la conversación (desde el teléfono del negocio o por la API); el bot se calla ahí hasta que se la devuelvan.
{
"object": { "id": "e1f2a3b4-…", "object": "conversation", "needs_agent": false, "taken_over": true, "customer": { "id": "c4d5e6f7-…", … }, … }
}conversation.bot_resumed — La conversación que atendía una persona vuelve a atenderla el bot.
{
"object": { "id": "e1f2a3b4-…", "object": "conversation", "needs_agent": false, "taken_over": false, "customer": { "id": "c4d5e6f7-…", … }, … }
}Cuenta y WhatsApp
bot.paused — El asistente dejó de responder: lo pausó el dueño o el sistema por riesgo para el número.
{
"object": {
"object": "account",
"bot": { "paused": true, "pause_source": "manual", "pause_reason": null },
"whatsapp": { "status": "connected", "phone": "584140000000", "channel": "qr" },
"balance": { "tokens": 184250, "low": false }
},
"pause_source": "manual",
"reason": null
}bot.resumed — El asistente vuelve a responder.
{
"object": {
"object": "account",
"bot": { "paused": false, "pause_source": null, "pause_reason": null },
"whatsapp": { "status": "connected", "phone": "584140000000", "channel": "qr" },
"balance": { "tokens": 184250, "low": false }
},
"previous_pause_source": "risk"
}whatsapp.disconnected — Se cerró la sesión de WhatsApp: hay que volver a vincular el número. No avisa de las reconexiones de rutina.
{
"object": {
"object": "account",
"bot": { "paused": false, "pause_source": null, "pause_reason": null },
"whatsapp": { "status": "logout", "phone": null, "channel": "qr" },
"balance": { "tokens": 184250, "low": false }
},
"status": "logout",
"previous_status": "connected",
"phone": "584140000000"
}whatsapp.connected — El número volvió a quedar vinculado después de cerrarse la sesión (o se vinculó uno nuevo).
{
"object": {
"object": "account",
"bot": { "paused": false, "pause_source": null, "pause_reason": null },
"whatsapp": { "status": "connected", "phone": "584140000000", "channel": "qr" },
"balance": { "tokens": 184250, "low": false }
},
"status": "connected",
"phone": "584140000000"
}account.balance_low — Al negocio le queda poco saldo de tokens: si se acaba, el asistente deja de responder.
{
"object": {
"object": "account",
"bot": { "paused": false, "pause_source": null, "pause_reason": null },
"whatsapp": { "status": "connected", "phone": "584140000000", "channel": "qr" },
"balance": { "tokens": 49850, "low": true }
},
"balance": 49850
}Detalles de algunos campos
changes(enproduct.updated) usa los nombres de la API:name,details,price,stock,kind,duration_minutes,applies_delivery,discount,available,daily_offer,external_id,barcode,fractionaleimage(se cambió la foto; en el producto se refleja enhas_image).previous_stocky, en los pedidos,items[].quantityson números que pueden llevar hasta 3 decimales (lo que se vende por peso o por medida).previous_starts_at(enappointment.rescheduled) es una fecha ISO 8601 con zona, que puede venir como+00:00en vez deZ.- En
rate.updated,manual_rateyprevious_manual_ratevienen como número, no como texto. - Si el recurso ya no existe cuando se manda el aviso,
data.objecttrae al menos suidy suobject. - En
bot.*,whatsapp.*yaccount.*,data.objectes el estado de la cuenta (object: "account"):bot,whatsappybalance, lo mismo que devuelve GET /me. No llevaid: el negocio es elcompany_iddel aviso. - En
conversation.*,data.objectes la conversación (ver Conversaciones). assigned_toyprevious_assigned_toson ids de personas del equipo: el nombre sale enGET /team, y en el pedido o el viaje dedata.object.
ping
El aviso de prueba que manda el botón Probar. No lleva recurso y no se reintenta:
{
"id": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"type": "ping",
"created_at": "2026-09-29T18:30:00.000Z",
"company_id": "0f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a",
"data": {
"object": { "message": "Prueba desde AtiendeFácil. Si ves esto, tu webhook funciona." }
}
}Cabeceras
POST /webhooks/atiendefacil HTTP/1.1
Content-Type: application/json
User-Agent: AtiendeFacil-Webhooks/1.0
X-AtiendeFacil-Event: order.status_changed
X-AtiendeFacil-Delivery: 0c6f1e2d-3a4b-4c5d-8e6f-7a8b9c0d1e2f
X-AtiendeFacil-Signature: t=1759169020,v1=5f2b9c…e41a
x-api-key: la-clave-de-tu-servidor| Cabecera | Qué es |
|---|---|
X-AtiendeFacil-Event | El tipo de evento, igual que type. |
X-AtiendeFacil-Delivery | Id único del envío, igual que id. Úsalo para no procesar dos veces el mismo aviso. |
X-AtiendeFacil-Signature | t=<unix>,v1=<firma>. Ver abajo. |
User-Agent | AtiendeFacil-Webhooks/1.0 |
Content-Type | application/json |
x-api-key | Solo si el webhook tiene clave de acceso: la clave, en esta cabecera o en la que se eligió. No forma parte de la firma. |
Verificar la firma
Cualquiera que conozca tu dirección podría mandarte un POST. La firma te asegura que el aviso lo mandó AtiendeFácil y que nadie lo tocó por el camino:
Lee la cabecera
X-AtiendeFacil-Signature: t=1759169020,v1=5f2b….tes la hora del envío en segundos Unix;v1, la firma en hexadecimal.Calcula la tuya
HMAC-SHA256(secreto, "<t>.<cuerpo crudo>")en hexadecimal: el valor det, un punto y el cuerpo tal como llegó.Compara y mira la hora
Compara las dos firmas con una comparación de tiempo constante y rechaza el aviso si
ttiene más de 5 minutos: así un aviso capturado no sirve para repetirlo días después. Cada intento se firma de nuevo, con su propiot.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}function verify($rawBody, $header, $secret) {
parse_str(str_replace(',', '&', $header), $p);
$expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
return abs(time() - (int)$p['t']) < 300 && hash_equals($expected, $p['v1'] ?? '');
}
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_X_ATIENDEFACIL_SIGNATURE'] ?? '';import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
p = dict(x.split("=", 1) for x in header.split(","))
expected = hmac.new(secret.encode(), f"{p['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return abs(time.time() - int(p["t"])) < 300 and hmac.compare_digest(expected, p.get("v1", ""))Si el secreto se filtra, el dueño pulsa Cambiar secreto en Integraciones: el anterior deja de valer en el momento, así que ten listo el nuevo.
Responder rápido
- Responde con cualquier
2xxen menos de 10 segundos. Lo que respondas en el cuerpo no se lee. La excepción escustomer.lookup: ahí el cuerpo es la respuesta y el plazo son 4 segundos. - Si tienes trabajo pesado (facturar, llamar a otro sistema), responde primero y procésalo después, en una cola.
- Cualquier otra cosa cuenta como fallo y se reintenta: un
4xx, un5xx, una redirección, pasarse de 10 segundos, no poder conectar o un certificado inválido.
Un receptor completo en Node.js con Express:
import express from "express";
const app = express();
const SECRET = process.env.ATIENDEFACIL_WEBHOOK_SECRET; // whsec_…
const seen = new Set(); // en producción: una tabla con índice único
// express.raw: el cuerpo llega tal cual, que es sobre lo que se firma.
app.post("/webhooks/atiendefacil", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("X-AtiendeFacil-Signature") ?? "", SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
res.sendStatus(200); // responde YA; el trabajo va después
if (seen.has(event.id)) return; // repetido: ya se procesó
seen.add(event.id);
queueMicrotask(() => handle(event)); // o a tu cola de trabajos
});
app.listen(3000);Reintentos
Si un envío falla, se vuelve a intentar solo, esperando cada vez más, hasta 8 intentos en unas 11 horas:
| Intento | Cuándo |
|---|---|
| 1 | Al momento del cambio. |
| 2 | 30 segundos después del 1. |
| 3 | 2 minutos después del 2. |
| 4 | 10 minutos después del 3. |
| 5 | 30 minutos después del 4. |
| 6 | 1 hora después del 5. |
| 7 | 3 horas después del 6. |
| 8 | 6 horas después del 7. Si también falla, el envío queda como fallido. |
En Historial de envíos (Integraciones) se ve cada aviso de los últimos 30 días: su estado, cuántos intentos lleva, qué respondió tu servidor y el cuerpo exacto que se mandó. Desde ahí, Reenviar vuelve a mandar un aviso con todos sus intentos.
Idempotencia, orden y tus propios cambios
- Puede llegar repetido. Si tu servidor procesó el aviso pero la respuesta no llegó a tiempo, se reintenta. Guarda el
id(oX-AtiendeFacil-Delivery) y descarta los que ya viste. Reenviar desde el historial manda el mismoid. - Puede llegar desordenado. Los avisos salen en paralelo y los reintentos se intercalan. Compara el
updated_atdel objeto con el que ya tienes y quédate con el más nuevo; si necesitas el estado exacto, pídelo a la API. - Tus propios cambios también avisan. Lo que tu integración cambia por la API dispara webhooks como cualquier otro cambio. Cuando el cambio va marcado con tu clave, el aviso trae
data.source = "api:<id de la clave>". Compáralo con elapi_key.iddeGET /mey, si es el tuyo, ignóralo: así tu sistema no vuelve a mandar lo que acaba de recibir y no entra en bucle. Lo marcan el catálogo, la tasa, editar un cliente y tomar o devolver un chat (product.*,rate.updated,customer.updated,conversation.taken_over,conversation.bot_resumed). Los datos que entran por tu respuesta acustomer.lookupllegan condata.source = "lookup:<id del webhook>". Lo que pasa por los mismos procesos que el panel —cambiar el estado de un pedido, aprobar o registrar pagos, cotizar el envío— todavía llega sinsource: ahí compara con lo que ya tienes antes de volver a escribir.
Si el webhook se apaga solo
Si 10 avisos seguidos agotan todos sus intentos, el webhook se apaga solo y en Integraciones sale el motivo. Un aviso entregado pone la cuenta a cero.
- Mientras está apagado, no se guardan avisos: lo que pase en ese tiempo no te llegará después. Lo que ya estaba esperando reintento queda como fallido.
- Cuando tu servidor esté arreglado, el dueño lo vuelve a encender con el interruptor (la cuenta de fallos vuelve a cero). Para recuperar lo que te perdiste, pide a la API lo que cambió desde entonces con
updated_after(ver sincronización incremental), o reenvía desde el historial los que quedaron fallidos.
Probar en tu computadora
AtiendeFácil no puede llamar a localhost. Para probar tu receptor mientras lo programas, expónlo con un túnel HTTPS que te dé un dominio público, por ejemplo ngrok http 3000 o cloudflared tunnel --url http://localhost:3000, y usa esa dirección en el webhook.
Crea el webhook con la dirección del túnel
Marca solo los eventos que vas a probar.
Pulsa Probar
Llega un
pingfirmado y en pantalla ves si tu servidor respondió bien o por qué falló («No respondió en 10 segundos», «Respondió 401»…).Provoca un evento de verdad
Cambia un pedido de estado desde el panel o un precio del catálogo, y mira el aviso en tu consola y en el Historial de envíos.
Usa Reenviar para repetir
Vuelve a mandar el mismo aviso las veces que haga falta mientras ajustas tu código.
Preguntas frecuentes
¿Puedo crear webhooks por la API?
Sí: crear, cambiar, probar, borrar, cambiar el secreto y consultar o reenviar envíos (ver Crear el webhook por la API). También se hace a mano en Integraciones, en el panel o en la app.
Mi servidor responde 401 o 403 a todos los avisos
Si exige una clave propia en una cabecera, guárdala en el webhook como clave de acceso y pulsa Probar. Si ya la tiene, revisa que la cabecera sea la que tu servidor lee y, si la clave cambió de tu lado, reemplázala: la guardada no se puede consultar.
¿Recibir webhooks gasta saldo?
No. Los webhooks que te enviamos no gastan tokens (los avisos por WhatsApp al dueño sí, ver Qué consume saldo).
El asistente sigue pidiendo el nombre aunque el cliente está en mi sistema
Mira la consulta en el Historial de envíos. Si salió «Falló», tu servidor tardó más de 4 segundos o no respondió un 2xx. Si salió «Entregado», revisa que los datos vayan dentro de customer y que busques por el teléfono con código de país (584140000000, no 04140000000). Si no hay ninguna consulta, ese cliente ya había sido consultado antes o ya tenía nombre y cédula.
Mi servidor responde 200 pero en el historial sale «Falló»
Revisa que responda en menos de 10 segundos, que no redirija (por ejemplo, de /webhook a /webhook/, o de http a https) y que el certificado sea válido. El historial dice el motivo exacto.
