Skip to content

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

{
"channel_id": 42,
"to": "59899123456",
"type": "text",
"text": "Hola, gracias por escribirnos."
}
CampoTipoRequeridoDescripción
channel_idintId del canal de tu cuenta. 404 si el canal no es tuyo.
tostring (1-64)E.164 sin + para WhatsApp, o PSID para Instagram.
typeenumNo (default "text")Uno de los 13 tipos soportados. Ver la tabla abajo.
textstring (≤4096)cuando type="text"Cuerpo del mensaje. 422 si falta.
template_namestringcuando type="template"Template pre-aprobado por Meta. 422 si falta.
language_codestringNo (default "es")Código de idioma del template.
componentslistNoComponentes 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.
mediaobjectcuando `type=“image""video"
interactiveobjectcuando type="interactive" o "catalog"Objeto verbatim de Meta (button, list, cta_url, carousel, location_request_message, catalog).
locationobjectcuando type="location"{latitude, longitude, name?, address?}.
contactslistcuando type="contacts"Array de objetos vCard de Meta.
reactionobjectcuando type="reaction"{message_id, emoji}. emoji="" quita la reacción.
contextobjectNo{"message_id": "<wamid>"} para responder a un mensaje específico (reply-to). Nativo en WhatsApp e Instagram.
preview_urlboolNo (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”
typeWhatsAppInstagramNota
textnativonativocontext y preview_url se inyectan en el payload. IG ignora preview_url.
templatenativowa_only (400)Solo WhatsApp. Templates pre-aprobados por Meta.
imagenativonativoPor media.link o media.id. caption opcional.
videonativonativoPor media.link o media.id. caption opcional.
audionativonativovoice: true marca nota de voz en WhatsApp (Instagram lo ignora).
documentnativo (filename requerido)nativo si es PDF, fallback texto-enlace en otros formatosInstagram solo acepta PDF como adjunto; otros formatos se envían como un mensaje de texto con la URL pública del archivo.
stickernativowa_only (400)Solo WhatsApp.
interactivenativowa_only (400)Botones, listas, CTA, carrusel, solicitud de ubicación. Pasamos interactive verbatim a Meta.
locationnativowa_only (400)Solo WhatsApp.
contactsnativowa_only (400)Solo WhatsApp.
reactionnativonativoWhatsApp usa {message_id, emoji}; Instagram usa la API nativa de reacciones. emoji="" quita la reacción.
typingnativonativoIndicador de escritura. No consume cuota (msgs_out no incrementa).
catalognativo (passthrough)wa_only (400)El payload va en interactive (passthrough). Requiere catálogo configurado en Meta.

wa_only significa 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á el channel_id a un canal de WhatsApp o usá un tipo compatible con Instagram.

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.

{
"channel_id": 42,
"to": "59899123456",
"type": "image",
"media": {
"link": "https://files.tu-app.com/banner.jpg",
"caption": "Promo de la semana"
}
}
{
"channel_id": 42,
"to": "59899123456",
"type": "image",
"media": {"id": "1234567890"}
}

En Instagram el caption del adjunto no se soporta: enviá un mensaje type="text" aparte si necesitás acompañar el archivo con texto.

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 document nativo (attachment.type:"file") solo acepta PDF. Si el filename no termina en .pdf, el envío se degrada a un mensaje de texto con la URL pública del archivo.

{
"channel_id": 42,
"to": "59899123456",
"type": "audio",
"media": {
"link": "https://files.tu-app.com/saludo.ogg",
"voice": true
}
}
{
"channel_id": 42,
"to": "59899123456",
"type": "interactive",
"interactive": {
"type": "button",
"body": {"text": "¿Confirmás tu pedido?"},
"action": {
"buttons": [
{"type": "reply", "reply": {"id": "ok", "title": ""}},
{"type": "reply", "reply": {"id": "no", "title": "No"}}
]
}
}
}
{
"channel_id": 42,
"to": "59899123456",
"type": "location",
"location": {
"latitude": -34.9011,
"longitude": -56.1645,
"name": "Sucursal Centro",
"address": "Av. 18 de Julio 1234, Montevideo"
}
}
{
"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.

{
"channel_id": 42,
"to": "59899123456",
"type": "typing"
}

typing es nativo en WhatsApp y en Instagram. No es facturable: no incrementa msgs_out ni cuenta contra template_limit.

{
"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 interactive se pasa verbatim a Meta. Necesitás un catálogo activo en tu Meta Business Manager.

{
"message_id": "wamid.HBgL...AA==",
"channel_id": 42,
"type": "text"
}
CampoDescripción
message_idId 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_idEcho del request.
typeEcho del request.

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.

{
"channel_id": 42,
"message_id": "wamid.HBgL...AA=="
}
CampoTipoRequeridoDescripción
channel_idintId del canal de tu cuenta. 404 si el canal no es tuyo.
message_idstring (1-255)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).
{
"status": "read",
"message_id": "wamid.HBgL...AA=="
}

Mismo envelope que POST /messages. 404 cuando el canal no es tuyo; 502 con el envelope estructurado de Meta cuando el upstream falla.

Todos los errores devuelven un dict en detail con un shape estable para programar reintentos sin parsear strings.

CódigoTriggerBody (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 UnauthorizedKey inválida, inactiva o expirada"Invalid or inactive API key" (string)
403 ForbiddenKey sin scope send:messages"API key lacks required scope: send:messages" (string)
404 Not Foundchannel_id no es tuyo o el canal está suspendido"channel not found or not owned by this account" (opaco, string)
422 Unprocessable EntityValidación Pydantic: campo requerido faltante para el type, media mal armado, to no válido para el canal"<reason>" (string)
429 Too Many RequestsRate limit por key o cuota mensual de templates excedidaRate: dict estándar con headers (ver abajo). Cuota: "template quota exceeded for your plan — upgrade to increase your monthly template limit" (string)
502 Bad GatewayMeta devolvió error, o el canal no tiene token decifrableEnvelope 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.

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.

  • Pre-check: cuando mandás type="template", sumamos platform_usage.templates_sent de toda tu cuenta (todos los canales) y lo comparamos con plan.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_out para el canal. Los templates incrementan también templates_sent. Ambos van a counters Redis O(1) y se flushean cada 60s a platform_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).

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.

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

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.

  • Multimedia: cómo subir un binario (multipart o url-source) y obtener un media_id para usar en media.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.