Docs

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

AjustesIntegraciones

App

MásAjustesIntegraciones

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, localhost ni 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ónQué hace
GET /webhooksLos webhooks del negocio. La clave de acceso nunca se devuelve; el secreto de firma, tampoco.
POST /webhooksCrea 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}/testManda 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-secretSecreto de firma nuevo, que viene en secret. El anterior deja de valer en el momento.
GET /webhooks/{id}/deliveriesEl 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}/redeliverVuelve 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-key si 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, cookie ni ninguna que empiece por x-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.

GrupoEventoCuándo sale
Pedidosorder.createdUn pedido deja de ser carrito: el cliente lo confirmó o se creó desde el panel.
order.status_changedPasó a confirmado, preparando, en camino, entregado, cancelado…
order.delivery_quote_neededUn pedido espera que le pongan el costo del delivery para poder cobrarlo.
order.assignedSe le asignó (o cambió) el repartidor a un pedido.
Pagospayment.createdLlegó un comprobante o se registró un cobro.
payment.status_changedUn pago se aprobó, se rechazó o pasó a revisión.
payment.needs_reviewLlegó un comprobante que nadie va a confirmar solo (sin banco conectado o el monto no cuadra): hay que aprobarlo o rechazarlo.
Citasappointment.createdSe agendó una cita.
appointment.status_changedConfirmada, completada, cancelada o el cliente no vino.
appointment.rescheduledCambió la hora, la duración, el servicio o quién atiende.
appointment.deletedLa cita se eliminó.
Clientescustomer.createdEscribió por primera vez o se creó a mano.
customer.updatedCambió su nombre, teléfono, documento, dirección, notas o si pidió que el bot no le responda. Trae qué cambió.
customer.deletedSe borró un cliente (y con él sus pedidos y citas).
customer.lookupEs 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.
Mensajesmessage.receivedCada mensaje que un cliente escribe por WhatsApp.
message.sentCada 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ó.
Viajesride.createdSe pidió un viaje.
ride.status_changedAceptado, en camino, en curso, completado o cancelado.
ride.needs_driverNo quedó ningún conductor a quien ofrecerle el viaje (al pedirlo o después de que lo rechazaran): hay que asignarlo a mano.
Catálogoproduct.createdSe añadió un producto o servicio al catálogo (o se restauró uno borrado).
product.updatedCambió 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.deletedSe quitó un producto o servicio del catálogo.
Tasa de cambiorate.updatedCambió la tasa manual del negocio o pasó de tasa BCV a manual (o al revés).
Conversacionesconversation.needs_agentEl cliente pidió hablar con alguien o el bot no pudo seguir. Trae el motivo.
conversation.taken_overAlguien 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_resumedLa conversación que atendía una persona vuelve a atenderla el bot.
Cuenta y WhatsAppbot.pausedEl asistente dejó de responder: lo pausó el dueño o el sistema por riesgo para el número.
bot.resumedEl asistente vuelve a responder.
whatsapp.disconnectedSe cerró la sesión de WhatsApp: hay que volver a vincular el número. No avisa de las reconexiones de rutina.
whatsapp.connectedEl número volvió a quedar vinculado después de cerrarse la sesión (o se vinculó uno nuevo).
account.balance_lowAl negocio le queda poco saldo de tokens: si se acaba, el asistente deja de responder.
  • order.created sale cuando el pedido deja de ser carrito, no mientras el bot lo arma.
  • message.received sale solo con lo que escribe el cliente. Lo que le manda el negocio —el asistente, una persona del equipo o tu integración— sale como message.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 el id que te devolvió POST /messages para no contestarte a ti mismo.
  • product.updated sale también cuando una venta baja el stock.
  • rate.updated no sale cuando el BCV publica tasa nueva: solo cuando el negocio cambia su modo, su tasa manual o su moneda base.
  • whatsapp.disconnected sale 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.connected sale al volver a vincularlo (o al vincular un número nuevo).
  • account.balance_low sale 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_agent no sale con la espera de la cotización del envío: esa tiene su propio evento, order.delivery_quote_needed.
  • customer.lookup es 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).
GET/webhook-events

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: llegan appointment.status_changed y appointment.rescheduled.
  • Un pedido que pasa a out_for_delivery con un repartidor nuevo: order.status_changed y order.assigned.
  • Un comprobante que el sistema no puede confirmar solo: payment.created (o payment.status_changed) y payment.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).

EventoCómo se resuelve
conversation.needs_agentContesta 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_reviewPOST /payments/{id}/approve o /reject. Ver aprobar un comprobante.
order.delivery_quote_neededPOST /orders/{id}/delivery-quote con el costo del envío. Ver cotizar el envío.
ride.needs_driverPOST /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.disconnectedNo 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_lowRecargar 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).

json
{
  "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
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"
  }
}
CampoQué es
nameNombre completo, hasta 160 caracteres. Queda como nombre confirmado.
id_documentCé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.
addressDirección, hasta 500 caracteres.

Si no lo conoces, dilo también con un 2xx:

http
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:

Node.js
// 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 2xx o 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 con PATCH /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.updated con data.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:

json
{
  "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"
  }
}
CampoQué es
idId único del envío. Es el mismo que la cabecera X-AtiendeFacil-Delivery.
typeEl evento (order.status_changed) o ping en las pruebas.
created_atCuándo pasó el cambio (ISO 8601, UTC).
company_idEl negocio. Es el company.id de GET /me.
data.objectEl 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.sourceSolo 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).
EventoQué trae data además de object
order.createdstatus: el estado con el que nació.
order.status_changedstatus y previous_status.
payment.createdstatus.
payment.status_changedstatus y previous_status. El pago trae conversation_id para contestar en el chat.
appointment.createdstatus.
appointment.status_changedstatus y previous_status.
appointment.rescheduledstatus y previous_starts_at (la hora de antes).
appointment.deletedNada. object solo trae id, object y deleted: true.
customer.createdNada.
customer.updatedchanges: qué cambió, entre name, phone, id_document, address, notes y bot_muted.
message.receivedNada.
ride.createdstatus.
ride.status_changedstatus y previous_status.
product.createdNada.
product.updatedchanges (qué cambió) y previous_stock (si cambió el stock).
product.deletedNada. object.deleted_at trae la fecha.
rate.updatedmode, manual_rate, base_currency, previous_mode y previous_manual_rate.
conversation.needs_agentreason: lo que preguntó el cliente o por qué se detuvo el bot (lo mismo que object.agent_reason).
conversation.taken_overNada.
conversation.bot_resumedNada.
payment.needs_reviewstatus (siempre review).
order.delivery_quote_neededconversation_id: el chat del cliente. object es el pedido todavía en draft, sin costo de envío.
order.assignedassigned_to (id del repartidor) y previous_assigned_to (el de antes, o null).
ride.needs_driverstatus 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.deletedphone y name que tenía. object solo trae id, object y deleted: true.
bot.pausedpause_source (manual: lo pausó el negocio; risk: el sistema, para proteger el número) y reason (el motivo, o null).
bot.resumedprevious_pause_source: quién lo había pausado.
whatsapp.disconnectedstatus (logout), previous_status y phone (el número que estaba vinculado).
whatsapp.connectedstatus (connected) y phone (el número vinculado).
account.balance_lowbalance: los tokens que quedaban al avisar (número).
customer.lookupNada. Es una consulta: lo que importa es lo que respondes (ver Consultar un cliente en tu sistema).
message.sentsender: 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.

json
{
  "object": { "id": "5b7c2e1a-…", "object": "order", "number": "4A1B2C", "status": "pending_payment", … },
  "status": "pending_payment"
}

order.status_changed — Pasó a confirmado, preparando, en camino, entregado, cancelado…

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "object": { "id": "7a8b9c0d-…", "object": "payment", "status": "review", "order_id": "5b7c2e1a-…", … },
  "status": "review"
}

Citas

appointment.created — Se agendó una cita.

json
{
  "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.

json
{
  "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.

json
{
  "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ó.

json
{
  "object": { "id": "d6e7f8a9-b0c1-4d2e-9f3a-4b5c6d7e8f9a", "object": "appointment", "deleted": true }
}

Clientes

customer.created — Escribió por primera vez o se creó a mano.

json
{
  "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ó.

json
{
  "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).

json
{
  "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.

json
{
  "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.

json
{
  "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ó.

json
{
  "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.

json
{
  "object": { "id": "f8a9b0c1-…", "object": "ride", "status": "requested", "driver": null, … },
  "status": "requested"
}

ride.status_changed — Aceptado, en camino, en curso, completado o cancelado.

json
{
  "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.

json
{
  "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).

json
{
  "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.

json
{
  "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.

json
{
  "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).

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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.

json
{
  "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).

json
{
  "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.

json
{
  "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 (en product.updated) usa los nombres de la API: name, details, price, stock, kind, duration_minutes, applies_delivery, discount, available, daily_offer, external_id, barcode, fractional e image (se cambió la foto; en el producto se refleja en has_image).
  • previous_stock y, en los pedidos, items[].quantity son números que pueden llevar hasta 3 decimales (lo que se vende por peso o por medida).
  • previous_starts_at (en appointment.rescheduled) es una fecha ISO 8601 con zona, que puede venir como +00:00 en vez de Z.
  • En rate.updated, manual_rate y previous_manual_rate vienen como número, no como texto.
  • Si el recurso ya no existe cuando se manda el aviso, data.object trae al menos su id y su object.
  • En bot.*, whatsapp.* y account.*, data.object es el estado de la cuenta (object: "account"): bot, whatsapp y balance, lo mismo que devuelve GET /me. No lleva id: el negocio es el company_id del aviso.
  • En conversation.*, data.object es la conversación (ver Conversaciones).
  • assigned_to y previous_assigned_to son ids de personas del equipo: el nombre sale en GET /team, y en el pedido o el viaje de data.object.

ping

El aviso de prueba que manda el botón Probar. No lleva recurso y no se reintenta:

json
{
  "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

http
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
CabeceraQué es
X-AtiendeFacil-EventEl tipo de evento, igual que type.
X-AtiendeFacil-DeliveryId único del envío, igual que id. Úsalo para no procesar dos veces el mismo aviso.
X-AtiendeFacil-Signaturet=<unix>,v1=<firma>. Ver abajo.
User-AgentAtiendeFacil-Webhooks/1.0
Content-Typeapplication/json
x-api-keySolo 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:

  1. Lee la cabecera

    X-AtiendeFacil-Signature: t=1759169020,v1=5f2b…. t es la hora del envío en segundos Unix; v1, la firma en hexadecimal.

  2. Calcula la tuya

    HMAC-SHA256(secreto, "<t>.<cuerpo crudo>") en hexadecimal: el valor de t, un punto y el cuerpo tal como llegó.

  3. Compara y mira la hora

    Compara las dos firmas con una comparación de tiempo constante y rechaza el aviso si t tiene 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 propio t.

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);
}

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 2xx en menos de 10 segundos. Lo que respondas en el cuerpo no se lee. La excepción es customer.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, un 5xx, una redirección, pasarse de 10 segundos, no poder conectar o un certificado inválido.

Un receptor completo en Node.js con Express:

Node.js
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:

IntentoCuándo
1Al momento del cambio.
230 segundos después del 1.
32 minutos después del 2.
410 minutos después del 3.
530 minutos después del 4.
61 hora después del 5.
73 horas después del 6.
86 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 (o X-AtiendeFacil-Delivery) y descarta los que ya viste. Reenviar desde el historial manda el mismo id.
  • Puede llegar desordenado. Los avisos salen en paralelo y los reintentos se intercalan. Compara el updated_at del 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 el api_key.id de GET /me y, 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 a customer.lookup llegan con data.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 sin source: 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.

  1. Crea el webhook con la dirección del túnel

    Marca solo los eventos que vas a probar.

  2. Pulsa Probar

    Llega un ping firmado y en pantalla ves si tu servidor respondió bien o por qué falló («No respondió en 10 segundos», «Respondió 401»…).

  3. 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.

  4. 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.

¿No encontraste lo que buscabas?

Escríbenos y te responde una persona del equipo.