Apartado 6 de 12 · Sincronizar
Escribir en Chatendo: contactos y mensajes
Alta y edición de contactos, etiquetas, envío de mensajes por WhatsApp e Instagram, la ventana de 24 horas y los webhooks de entrada para sistemas sin token.
En esta página (6)
6.1 · Contactos
# Alta
curl -s -X POST "$API/contacts" \
-H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"name":"Ana Ruiz","phone":"+34600111222"}'
# Edición
curl -s -X PATCH "$API/contacts/41" … -d '{"name":"Ana Ruiz Gómez"}'
# Etiquetas (reemplaza la lista entera, no añade)
curl -s -X PUT "$API/contacts/41/tags" … -d '{"tag_ids":[3,7]}'phone es obligatorio y se normaliza a E.164. name es opcional.
El alta responde 201 con el contacto dentro de contact —{"contact":{"id":541,…}}—, y la lista lo trae dentro de data. No es la misma envoltura: léela según la ruta, o tu código buscará el id donde no está (medido el 22-09-2026).
POST /contacts NO es un upsert. Si el teléfono ya existe responde 422 PHONE_TAKEN y no escribe nada — probado. Para sincronizar usa PUT /contacts (abajo) o busca primero por teléfono o external_id.
Búsqueda exacta y upsert — para sincronizar no hace falta el q (es un LIKE):
# 0 o 1 fila, nunca una lista. El teléfono se normaliza igual que en el alta:
# +34600111222, 0034600111222 y 600111222 son el mismo contacto.
curl -s -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' \
"$API/contacts?phone=+34600111222"
curl -s -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' \
"$API/contacts?external_id=CRM-7f3a9c"
# Crea (201) o actualiza (200); nunca da PHONE_TAKEN.
curl -s -X PUT "$API/contacts" \
-H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"phone":"+34600111222","external_id":"CRM-7f3a9c","name":"Ana Ruiz"}'PUT /contacts exige phone o external_id (al menos uno). Si mandas los dos y no casan con el mismo contacto —o el que casa es el perdedor de una fusión—, 409 CONTACT_CONFLICT con conflicting_contact_id (nunca PHONE_TAKEN); si el external_id no existe todavía y no mandas phone, 422 VALIDATION_ERROR con el error en errors.phone, porque hace falta para dar de alta. Llamarlo dos veces con el mismo cuerpo deja un único contacto, no dos. El external_id se compara como en §6.6: sin distinguir mayúsculas, acentos ni espacios al final.
6.2 · Mensajes
curl -s -X POST "$API/conversations/2856/messages" \
-H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"type":"text","body":"Tu pedido sale hoy."}'Necesita messages:send. Sin ese permiso responde 403 TOKEN_SCOPE_DENIED y no envía nada — comprobado en producción, que es como se prueba esto sin escribirle a una persona real.
type admite: text, template, location, contacts, reaction, product, product_list y, solo en Instagram, quick_replies, button_template y generic_template. Texto libre: 4.096 caracteres en WhatsApp, 1.000 en Instagram.
6.3 · La ventana de 24 horas
Regla de Meta, no de Chatendo. Fuera de las 24 h desde el último mensaje del cliente no sale texto libre: 422 WINDOW_EXPIRED, y no se envía nada.
Fuera de ventana solo pasa una plantilla aprobada (type: "template"). Que el cliente conteste vuelve a abrir la ventana.
Cada conversación te dice cuándo cierra, en window_expires_at y last_inbound_at. Si tu sistema manda avisos que pueden caer fuera, ten la plantilla aprobada antes, no el día que la necesites: Meta tarda en aprobarlas y rechaza, por ejemplo, cualquier plantilla cuya variable quede al principio o al final del texto.
6.4 · Empujar sin token: los webhooks de entrada
Para sistemas que no pueden guardar un token —una tienda, un CRM— hay once puertas firmadas con HMAC contra el secreto del canal (shop_webhook_secret), fuera de /v1:
| Puerta | Para qué |
|---|---|
POST /api · shop-order-v2 |
Un pedido nuevo de la tienda |
POST /api |
La ficha del pedido, compuesta por la tienda |
POST /api |
Aviso de reparto |
POST /api |
«Aún no has pedido» |
POST /api |
El CRM rellena nombre, empresa y código de cliente |
POST /api |
Alguien dejó su número en una web y le escribimos nosotros |
POST /api |
Alta de cuenta de empresa aprobada |
POST /api |
Cupón canjeado |
POST /api |
Cambió la lista de administradores |
Dos detalles que ahorran una tarde:
contact-identity-v1nunca crea un contacto, solo rellena los que ya existen. Si el contacto no está, no pasa nada y no hay error.lead-v1sí crea, y además escribe: el primer mensaje es siempre una plantilla aprobada. Sin plantilla no da de alta al contacto.
El secreto es del canal, no de tu integración. Si se lo das a dos sistemas y quieres echar a uno, tienes que rotarlo y romper el otro. Eso cambia en §10.
6.5 · Lo que NO hay que hacer
- No reintentes una escritura a ciegas. Hoy solo tres rutas miran
Idempotency-Key(enlaces de pago, ofertas y etapas). En las demás, un reintento tras un corte de red crea el contacto o manda el mensaje dos veces. Hasta que eso cambie (§10), comprueba antes de reintentar. - No uses el token desde el navegador del cliente. Es una llave de tu cuenta: va en tu servidor.
- No borres conversaciones por API aunque encuentres la ruta:
conversations:writeno se le da a un token de fuera a propósito. - No inventes parámetros. Un parámetro que la ruta no conoce se ignora en silencio y te devuelve 200 con la colección entera. Lo que parece un filtro puede no serlo: compruébalo con un caso que tenga que dar cero.
6.6 · Conversaciones: guardar tu id (external_id)
Igual que en los contactos, una conversación puede guardar el id que tiene en tu sistema:
# Guardarlo (null lo borra). Necesita conversations:write.
curl -s -X PATCH "$API/conversations/2856" \
-H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"external_id":"CRM-LEAD-2291"}'
# Buscarla luego: 0 o 1 fila, por igualdad, no un `q`. `filter=all` la
# encuentra también si está cerrada (sin `filter`, la lista solo trae las abiertas).
curl -s -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' \
"$API/conversations?filter=all&external_id=CRM-LEAD-2291"external_ides obligatorio en el cuerpo delPATCH(hasta 191 caracteres; mandarnulllo borra). Cualquier otro campo en el cuerpo da422 UNKNOWN_BODY_FIELD.- Es único por inquilino, contando las conversaciones de la papelera: si ya lo tiene otra,
422 VALIDATION_ERRORcon el error enerrors.external_id— a diferencia dePUT /contacts, aquí no hay un409 CONTACT_CONFLICTcon el id de quien lo tiene, solo el 422 de validación. - Una conversación de otro inquilino, o de un canal que no ves, da
404. Buscar elexternal_idde otro inquilino da una lista vacía, no un error. ?external_id[]=(una lista) no es el mismo parámetro: da422.- La igualdad es la de la base de datos de producción: no distingue mayúsculas, acentos ni espacios al final.
CRM-LEAD-1,crm-lead-1yCRM-LEAD-1son el mismo id: la búsqueda devuelve la conversación que lo tenga y un segundoPATCHcon la variante da422. Si en tu sistema dos ids solo se diferencian en eso, no los guardes tal cual.