Enviar mensajes
POST /platform/v1/messages envía un mensaje WhatsApp o Instagram a través
de un canal de tu cuenta. Auth: X-API-Key + scope send:messages.
La API cubre la superficie completa de Meta: texto, templates, multimedia
(imagen, video, audio, documento, sticker), interactivos (botones, listas,
CTA, carrusel), ubicación, contactos, reacciones, indicador de escritura,
respuesta a un mensaje (context.message_id) y vista previa de enlaces.
Cada tipo declara explícitamente su disponibilidad en Instagram (nativo,
con fallback documentado, o exclusivo de WhatsApp).
Request body
Section titled “Request body”{ "channel_id": 42, "to": "59899123456", "type": "text", "text": "Hola, gracias por escribirnos."}| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
channel_id | int | Sí | Id del canal de tu cuenta. 404 si el canal no es tuyo. |
to | string (1-64) | Sí | E.164 sin + para WhatsApp, o PSID para Instagram. |
type | enum | No (default "text") | Uno de los 13 tipos soportados. Ver la tabla abajo. |
text | string (≤4096) | cuando type="text" | Cuerpo del mensaje. 422 si falta. |
template_name | string | cuando type="template" | Template pre-aprobado por Meta. 422 si falta. |
language_code | string | No (default "es") | Código de idioma del template. |
components | list | No | Componentes de envío del template (variables por sección). Contrato tipado y cerrado (extra="forbid"): header / body / button / carousel, en minúscula — un tipo desconocido o un campo mal escrito devuelve 422 antes de tocar Meta. Es un contrato distinto al de creación de templates (que usa MAYÚSCULAS) — nunca son intercambiables, ver la nota abajo. Solo aplica cuando type="template"; en cualquier otro tipo, components no vacío devuelve 422. Ver la guía de Templates para el catálogo de componentes de creación, OTP, NAMED parameter_format, reglas de cada categoría, y cómo subir media headers con POST /platform/v1/templates/header-media. |
media | object | cuando `type=“image" | "video" |
interactive | object | cuando type="interactive" o "catalog" | Objeto verbatim de Meta (button, list, cta_url, carousel, location_request_message, catalog). |
location | object | cuando type="location" | {latitude, longitude, name?, address?}. |
contacts | list | cuando type="contacts" | Array de objetos vCard de Meta. |
reaction | object | cuando type="reaction" | {message_id, emoji}. emoji="" quita la reacción. |
context | object | No | {"message_id": "<wamid>"} para responder a un mensaje específico (reply-to). Nativo en WhatsApp e Instagram. |
preview_url | bool | No (default false) | Habilita la vista previa de enlaces en WhatsApp para type="text". Instagram lo ignora. |
Tipos de mensaje y paridad WhatsApp / Instagram
Section titled “Tipos de mensaje y paridad WhatsApp / Instagram”type | Nota | ||
|---|---|---|---|
text | nativo | nativo | context y preview_url se inyectan en el payload. IG ignora preview_url. |
template | nativo | wa_only (400) | Solo WhatsApp. Templates pre-aprobados por Meta. |
image | nativo | nativo | Por media.link o media.id. caption opcional. |
video | nativo | nativo | Por media.link o media.id. caption opcional. |
audio | nativo | nativo | voice: true marca nota de voz en WhatsApp (Instagram lo ignora). |
document | nativo (filename requerido) | nativo si es PDF, fallback texto-enlace en otros formatos | Instagram solo acepta PDF como adjunto; otros formatos se envían como un mensaje de texto con la URL pública del archivo. |
sticker | nativo | wa_only (400) | Solo WhatsApp. |
interactive | nativo | wa_only (400) | Botones, listas, CTA, carrusel, solicitud de ubicación. Pasamos interactive verbatim a Meta. |
location | nativo | wa_only (400) | Solo WhatsApp. |
contacts | nativo | wa_only (400) | Solo WhatsApp. |
reaction | nativo | nativo | WhatsApp usa {message_id, emoji}; Instagram usa la API nativa de reacciones. emoji="" quita la reacción. |
typing | nativo | nativo | Indicador de escritura. No consume cuota (msgs_out no incrementa). |
catalog | nativo (passthrough) | wa_only (400) | El payload va en interactive (passthrough). Requiere catálogo configurado en Meta. |
wa_onlysignifica que el endpoint responde 400 con{"error":"wa_only","message":"...","type":"<tipo>"}antes de llamar a Meta. No consume cuota ni cuenta como envío fallido. Cambiá elchannel_ida un canal de WhatsApp o usá un tipo compatible con Instagram.
Ejemplos por tipo
Section titled “Ejemplos por tipo”Texto con vista previa y respuesta a un mensaje
Section titled “Texto con vista previa y respuesta a un mensaje”{ "channel_id": 42, "to": "59899123456", "type": "text", "text": "Te paso la guía: https://kalipto.app/docs", "preview_url": true, "context": {"message_id": "wamid.HBgL..."}}Template con variables NAMED, botón URL y OTP
Section titled “Template con variables NAMED, botón URL y OTP”components en el ENVÍO usa el vocabulario de envío (minúsculas —
header/body/button/carousel), que es distinto del vocabulario de
creación de templates (MAYÚSCULAS — HEADER/BODY/BUTTONS) que
documenta la guía de Templates. Nunca se mezclan.
{ "channel_id": 42, "to": "59899123456", "type": "template", "template_name": "order_confirmation_named", "language_code": "es", "components": [ { "type": "body", "parameters": [ { "type": "text", "text": "Juan", "parameter_name": "nombre" }, { "type": "text", "text": "#123", "parameter_name": "numero_pedido" } ] }, { "type": "button", "sub_type": "url", "index": 0, "parameters": [{ "type": "text", "text": "123" }] } ]}Un template authentication con un botón OTP se envía enviando el código
en ambos lados — el body y el button:
{ "channel_id": 42, "to": "59899123456", "type": "template", "template_name": "otp_login", "language_code": "es", "components": [ { "type": "body", "parameters": [{ "type": "text", "text": "482913" }] }, { "type": "button", "sub_type": "copy_code", "index": 0, "parameters": [{ "type": "coupon_code", "coupon_code": "482913" }] } ]}Cualquier type/sub_type fuera de este vocabulario, o un campo que no
corresponde al sub_type del botón, devuelve 422 antes de llegar a
Meta o al simulador.
Imagen por URL pública con epígrafe
Section titled “Imagen por URL pública con epígrafe”{ "channel_id": 42, "to": "59899123456", "type": "image", "media": { "link": "https://files.tu-app.com/banner.jpg", "caption": "Promo de la semana" }}Imagen por media_id ya subido
Section titled “Imagen por media_id ya subido”{ "channel_id": 42, "to": "59899123456", "type": "image", "media": {"id": "1234567890"}}En Instagram el
captiondel adjunto no se soporta: enviá un mensajetype="text"aparte si necesitás acompañar el archivo con texto.
Documento (WhatsApp requiere filename cuando se envía por link)
Section titled “Documento (WhatsApp requiere filename cuando se envía por link)”{ "channel_id": 42, "to": "59899123456", "type": "document", "media": { "link": "https://files.tu-app.com/factura-1042.pdf", "filename": "factura-1042.pdf" }}En Instagram el
documentnativo (attachment.type:"file") solo acepta PDF. Si elfilenameno termina en
Audio como nota de voz (WhatsApp)
Section titled “Audio como nota de voz (WhatsApp)”{ "channel_id": 42, "to": "59899123456", "type": "audio", "media": { "link": "https://files.tu-app.com/saludo.ogg", "voice": true }}Interactivo de botones (WhatsApp)
Section titled “Interactivo de botones (WhatsApp)”{ "channel_id": 42, "to": "59899123456", "type": "interactive", "interactive": { "type": "button", "body": {"text": "¿Confirmás tu pedido?"}, "action": { "buttons": [ {"type": "reply", "reply": {"id": "ok", "title": "Sí"}}, {"type": "reply", "reply": {"id": "no", "title": "No"}} ] } }}Ubicación (WhatsApp)
Section titled “Ubicación (WhatsApp)”{ "channel_id": 42, "to": "59899123456", "type": "location", "location": { "latitude": -34.9011, "longitude": -56.1645, "name": "Sucursal Centro", "address": "Av. 18 de Julio 1234, Montevideo" }}Reacción (WhatsApp e Instagram)
Section titled “Reacción (WhatsApp e Instagram)”{ "channel_id": 42, "to": "59899123456", "type": "reaction", "reaction": { "message_id": "wamid.HBgL...", "emoji": "👍" }}Mandá
"emoji": ""para quitar la reacción del mensaje. Es un passthrough válido en ambos canales.
Indicador de escritura
Section titled “Indicador de escritura”{ "channel_id": 42, "to": "59899123456", "type": "typing"}
typinges nativo en WhatsApp y en Instagram. No es facturable: no incrementamsgs_outni cuenta contratemplate_limit.
Catálogo (passthrough, solo WhatsApp)
Section titled “Catálogo (passthrough, solo WhatsApp)”{ "channel_id": 42, "to": "59899123456", "type": "catalog", "interactive": { "type": "catalog_message", "body": {"text": "Mirá el catálogo"}, "action": {"name": "catalog_message", "parameters": {"thumbnail_product_retailer_id": "SKU-1"}} }}El campo
interactivese pasa verbatim a Meta. Necesitás un catálogo activo en tu Meta Business Manager.
Response 200
Section titled “Response 200”{ "message_id": "wamid.HBgL...AA==", "channel_id": 42, "type": "text"}| Campo | Descripción |
|---|---|
message_id | Id de Meta. Para WhatsApp es wamid.…; para Instagram es el message_id que devuelve Graph. Si el shape upstream no se reconoce, devolvemos "unknown". Para typing y mark-read puede ser "unknown" porque Meta no siempre devuelve un id. |
channel_id | Echo del request. |
type | Echo del request. |
Marcar un mensaje entrante como leído
Section titled “Marcar un mensaje entrante como leído”POST /platform/v1/messages/mark-read envía el read-receipt de un mensaje
recibido. Auth: X-API-Key + scope send:messages (no se necesita un
scope nuevo). No es facturable: no incrementa msgs_out.
Request
Section titled “Request”{ "channel_id": 42, "message_id": "wamid.HBgL...AA=="}| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
channel_id | int | Sí | Id del canal de tu cuenta. 404 si el canal no es tuyo. |
message_id | string (1-255) | Sí | En WhatsApp es el wamid del mensaje entrante. En Instagram es el IGSID del participante (Meta usa sender_action:"mark_seen", que opera sobre la conversación, no sobre un mensaje específico). |
Response 200
Section titled “Response 200”{ "status": "read", "message_id": "wamid.HBgL...AA=="}Errores específicos
Section titled “Errores específicos”Mismo envelope que POST /messages. 404 cuando el canal no es tuyo;
502 con el envelope estructurado de Meta cuando el upstream falla.
Errores
Section titled “Errores”Todos los errores devuelven un dict en detail con un shape estable
para programar reintentos sin parsear strings.
| Código | Trigger | Body (detail) |
|---|---|---|
400 Bad Request (wa_only) | Tipo solo de WhatsApp enviado a un canal de Instagram (template, interactive, location, contacts, sticker, catalog) | {"error":"wa_only","message":"El tipo '<tipo>' solo está disponible en canales de WhatsApp.","type":"<tipo>"} |
400 Bad Request (template específico) | type="template" en un canal de Instagram (gate dedicado, mantenido por compatibilidad) | "templates are WhatsApp-only" (string) |
401 Unauthorized | Key inválida, inactiva o expirada | "Invalid or inactive API key" (string) |
403 Forbidden | Key sin scope send:messages | "API key lacks required scope: send:messages" (string) |
404 Not Found | channel_id no es tuyo o el canal está suspendido | "channel not found or not owned by this account" (opaco, string) |
422 Unprocessable Entity | Validación Pydantic: campo requerido faltante para el type, media mal armado, to no válido para el canal | "<reason>" (string) |
429 Too Many Requests | Rate limit por key o cuota mensual de templates excedida | Rate: dict estándar con headers (ver abajo). Cuota: "template quota exceeded for your plan — upgrade to increase your monthly template limit" (string) |
502 Bad Gateway | Meta devolvió error, o el canal no tiene token decifrable | Envelope estructurado (ver abajo). |
Envelope estructurado 502 (Meta o token no disponible)
Section titled “Envelope estructurado 502 (Meta o token no disponible)”{ "error": "meta_send_failed", "meta_code": 131051, "meta_subcode": null, "meta_title": "Message type is not supported", "message": "Texto seguro para mostrarle al usuario final.", "fbtrace_id": "AbC..."}Cuando el canal no tiene un token decifrable (Fernet falló o el canal fue desconectado), el envelope cambia a:
{ "error": "channel_token_unavailable", "meta_code": null, "meta_subcode": null, "meta_title": null, "message": "El canal no tiene un token válido. Reconectá el canal.", "fbtrace_id": null}Las 6 claves (error, meta_code, meta_subcode, meta_title,
message, fbtrace_id) son estables: programá los reintentos contra
meta_code (entero) sin parsear message.
Headers de rate limit
Section titled “Headers de rate limit”Cada respuesta incluye los headers X-RateLimit-Limit,
X-RateLimit-Remaining y X-RateLimit-Reset cuando hay snapshot
disponible en Redis. En el 429 agregamos Retry-After (segundos).
Ver Rate limits para el detalle.
Cuota de templates
Section titled “Cuota de templates”- Pre-check: cuando mandás
type="template", sumamosplatform_usage.templates_sentde toda tu cuenta (todos los canales) y lo comparamos conplan.template_limit. Si llegaste al límite, te devolvemos 429 antes de llamar a Meta, el envío no consume cupo. - Post-send: cada envío exitoso incrementa
msgs_outpara el canal. Los templates incrementan tambiéntemplates_sent. Ambos van a counters Redis O(1) y se flushean cada 60s aplatform_usage. - NULL = ∞: si tu plan tiene
template_limit IS NULL(Business), no hay cap. - Exceso puntual bajo concurrencia: el cap-check + INCR no son atómicos, así que puede haber un excedente pequeño igual al número de peticiones simultáneas cuando llegás al límite. Esto está documentado y aceptado para v1 (no hay cobro por excedente).
Política de tokens
Section titled “Política de tokens”El token que usamos para postearle a Meta es platform_channels.access_token_encrypted
decifrado al momento del envío. Nunca tocamos businesses.*: la
superficie de la plataforma es independiente del backend managed (Guard
rail #4).
No hay branch Chakra: la plataforma envía directo a Meta. El flujo managed tiene fallback Chakra; la plataforma no.
Texto vs template
Section titled “Texto vs template”type="text": cualquier canal. Sirve para responder dentro de la ventana de 24h de WhatsApp o cualquier mensaje en Instagram.type="template": solo WhatsApp. Se usa cuando estás FUERA de la ventana de 24h (business-initiated) o cuando necesitás iniciar una conversación nueva. Requiere un template pre-aprobado por Meta.
Cambios
Section titled “Cambios”v399 (typed template send components): components en type="template"
pasa de ser un array libre a un contrato tipado y cerrado (extra="forbid").
Si tu integración construía este array a mano y algún campo no coincide
exactamente con el vocabulario documentado arriba, ahora vas a recibir un
422 en lugar de que el request llegue mal formado hasta Meta. El shape
final que Meta recibe no cambió para un request válido — el cambio es
puramente de validación de entrada.
2026-06-11 (WF2-v2): la API extiende POST /platform/v1/messages a
los 13 tipos de Meta: image, video, audio, document, sticker,
interactive, location, contacts, reaction, typing, catalog
sumados a text y template. Los tipos exclusivos de WhatsApp
(template, interactive, location, contacts, sticker, catalog)
devuelven 400 wa_only en un canal de Instagram antes de llamar a
Meta (cero cuota consumida). Se agregaron context.message_id
(respuesta a un mensaje) y preview_url (vista previa de enlaces en
WhatsApp), ambos opcionales y nativos en los dos canales cuando el canal
los soporta. typing no incrementa msgs_out. Nuevo endpoint
POST /platform/v1/messages/mark-read para el read-receipt (no
facturable). Los tipos text y template son byte-idénticos a la
versión anterior: si no mandás los campos nuevos no cambia nada del
request, response, status ni del medidor de cuota.
2026-06-10 (WF1-v2) — el body del error 502 dejó de ser texto opaco
y ahora es un envelope estructurado con el código de error de Meta. En vez
de {"detail":"upstream send failed: <repr>"} vas a recibir un dict con 6
claves estables: error (constante "meta_send_failed"), meta_code
(numérico de Meta, p. ej. 131051), meta_subcode, meta_title, message
(texto seguro para mostrarle al usuario) y fbtrace_id. El cambio te
permite programar reintentos contra el código de Meta sin parsear strings.
Si tu cliente leía detail como string, ahora tenés que detectar si es
dict y leer detail.message para el texto humano + detail.meta_code para
la lógica de retry. Ver “Errores” arriba para el detalle.
Próximo paso
Section titled “Próximo paso”- Multimedia: cómo subir un binario (multipart o
url-source) y obtener un
media_idpara usar enmedia.id, cómo bajar un binario entrante con la URL firmada, y la tabla de límites de tamaño por tipo. - Webhook entrante (forward): cómo recibís los webhooks crudos de Meta re-firmados con tu secret.