Templates de WhatsApp
Los templates son los mensajes pre-aprobados por Meta que podés enviar a tus
contactos por WhatsApp. La Kalipto Devs Platform expone la superficie
completa de componentes Meta (no solo el body): HEADER text/media/location,
BODY con parameter_format POSITIONAL o NAMED, FOOTER, BUTTONS (incl. OTP +
COPY_CODE + CATALOG), y CAROUSEL.
Solo WhatsApp. Los templates son una característica de WhatsApp Cloud API. Si tu canal es Instagram, las rutas
/platform/v1/templates/*devuelven 422 desde la capa de servicio.
Componentes tipados: los ejemplos de esta página usan el shape exacto que el schema valida.
componentsenPOST/PATCHes un array cerrado y tipado (extra="forbid") — un tipo de componente/botón desconocido o un campo mal escrito devuelve 422 antes de tocar la DB o Meta.FLOWno existe como opción; no es un caso que se rechace por validación explícita, directamente no está en el vocabulario aceptado.
Scope y plan
Section titled “Scope y plan”| Requisito | Detalle |
|---|---|
| Scope | manage:templates (key O cookie — es la única superficie cross-method del developer API). |
| Plan | Los planes lite (1.000 / mes) y pro (10.000 / mes) tienen cupo; el plan business es ilimitado. El plan free recibe 403 templates_not_in_plan en TODAS las rutas (POST/GET/GET /{id}/PATCH/DELETE/POST /sync/POST /header-media) — defendido por _require_template_plan antes de cualquier escritura en DB o llamada a Meta. |
Ciclo de vida
Section titled “Ciclo de vida”- Crear un draft con
POST /platform/v1/templates(status localpendinghasta que Meta apruebe). - Esperar la aprobación de Meta (asíncrona — Meta postea al webhook
message_template_status_updatey Kalipto actualizameta_status/meta_template_id/meta_rejection_reasonen la fila correspondiente). Si tenés una suscripción a webhooks normalizados (manage:webhooks), podés recibir el eventotemplate.status_changeden lugar de tener que hacer polling — ver Webhooks normalizados. - Enviar con
POST /platform/v1/messagestype=template, body{ template_name, language_code, components }. - Editar con
PATCH /platform/v1/templates/{id}— Meta acepta editar templates APPROVED o REJECTED; los PENDING devuelven409 status_locked. - Borrar con
DELETE /platform/v1/templates/{id}(borra local + best-effort en Meta).
Las 7 rutas
Section titled “Las 7 rutas”| Ruta | Propósito |
|---|---|
POST /platform/v1/templates | Crear + Meta submit. Aceptá el legacy body_text + variables (fast-path body-only, byte-idéntico a WF3) o el components array completo + parameter_format opcional. |
GET /platform/v1/templates | Listar (newest-first); filtros opcionales AND-combinados ?name=&status=&category=&language=. |
GET /platform/v1/templates/{id} | Detalle de un template (owner-scoped 404 opaco). Devuelve el components tree completo + meta_status / meta_template_id / meta_rejection_reason / meta_category snapshot. Nota sobre templates sincronizados desde Meta: POST /sync guarda el array de Meta tal cual vino. Si Meta devolvió un shape que la plataforma todavía no modela (por ejemplo FLOW o LIMITED_TIME_OFFER), ese componente puntual aparece en la respuesta como un objeto JSON plano sin type tipado — nunca rompe la respuesta completa (antes de esta versión, ese caso devolvía 500). Esto solo afecta la lectura: POST/PATCH siguen siendo estrictos y jamás aceptan un componente sin tipar. |
PATCH /platform/v1/templates/{id} | Editar + re-submit a Meta. Solo APPROVED/REJECTED — PENDING → 409 status_locked. |
DELETE /platform/v1/templates/{id} | Borrar local + best-effort Meta. |
POST /platform/v1/templates/sync | Importar + upsert TODOS los templates desde el WABA del canal. Idempotente por (channel_id, name, language_code). Devuelve {imported, updated, skipped, total}. |
POST /platform/v1/templates/header-media | Subida resumable de un binario para usar como header media. Devuelve header_handle para usar en components[].example.header_handle. |
Crear un template body-only (fast-path legacy)
Section titled “Crear un template body-only (fast-path legacy)”Equivalente a WF3 — body simple sin componentes. Útil para templates con
solo texto + variables posicionales {{1}}, {{2}}.
curl -X POST https://api.kalipto.app/platform/v1/templates \ -H "X-API-Key: $KALIPTO_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel_id": 42, "name": "order_confirmation", "category": "utility", "language_code": "es", "body_text": "Hola {{1}}, tu pedido {{2}} está listo.", "variables": ["customer_name", "order_number"] }'Crear un template con componentes completos (POSITIONAL)
Section titled “Crear un template con componentes completos (POSITIONAL)”{ "channel_id": 42, "name": "order_confirmation_v2", "category": "utility", "language_code": "es", "parameter_format": "POSITIONAL", "components": [ { "type": "HEADER", "format": "TEXT", "text": "Pedido confirmado" }, { "type": "BODY", "text": "Hola {{1}}, tu pedido {{2}} está listo.", "example": { "body_text": [["Juan", "#123"]] } }, { "type": "FOOTER", "text": "Equipo de Kalipto" }, { "type": "BUTTONS", "buttons": [ { "type": "QUICK_REPLY", "text": "Sí" }, { "type": "URL", "text": "Ver pedido", "url": "https://shop.com/o/{{1}}", "example": ["https://shop.com/o/123"] } ] } ]}NAMED parameter_format
Section titled “NAMED parameter_format”En lugar de {{1}}, {{2}} posicionales, podés usar nombres descriptivos.
Cambia el top-level parameter_format a "NAMED" y los componentes con
parámetros llevan body_text_named_params en lugar de body_text.
{ "channel_id": 42, "name": "order_confirmation_named", "category": "utility", "language_code": "es", "parameter_format": "NAMED", "components": [ { "type": "BODY", "text": "Hola {{nombre}}, tu pedido {{numero_pedido}} está listo.", "example": { "body_text_named_params": [ { "param_name": "nombre", "example": "Juan" }, { "param_name": "numero_pedido", "example": "#123" } ] } } ]}Header media (resumable upload)
Section titled “Header media (resumable upload)”Para HEADER format: "IMAGE" / "VIDEO" / "DOCUMENT", Meta requiere un
header_handle — no una URL pública — obtenido por el protocolo
resumable-upload. Kalipto expone una ruta dedicada que ejecuta el flow de
2 pasos con el APP token (WHATSAPP_APP_ID|WHATSAPP_APP_SECRET),
nunca con el token del canal:
# Subida via multipartcurl -X POST https://api.kalipto.app/platform/v1/templates/header-media \ -H "X-API-Key: $KALIPTO_KEY" \ -F "mime_type=image/jpeg"
# Subida via url-source (SSRF-guarded)curl -X POST https://api.kalipto.app/platform/v1/templates/header-media \ -H "X-API-Key: $KALIPTO_KEY" \ -H "Content-Type: application/json" \ -d '{ "mime_type": "image/jpeg", "source_url": "https://miapp.com/hero.jpg" }'Devuelve { "header_handle": "<handle>" }. Usalo en el example del componente HEADER:
{ "type": "HEADER", "format": "IMAGE", "example": { "header_handle": ["<handle>"] } }| Tipo | Límite |
|---|---|
| image | 5 MB |
| video | 16 MB |
| audio | 16 MB |
| document | 100 MB |
| Error | Trigger |
|---|---|
422 | MIME no soportado / SSRF bloqueado (URL privada o redirect a IP interna) |
413 | Excede el límite de tamaño |
503 | WHATSAPP_APP_ID / WHATSAPP_APP_SECRET no configurados en el backend |
502 | Error de red o non-2xx de Meta |
Buttons — la superficie completa
Section titled “Buttons — la superficie completa”QUICK_REPLY / URL / PHONE_NUMBER (managed reference)
Section titled “QUICK_REPLY / URL / PHONE_NUMBER (managed reference)”{ "type": "QUICK_REPLY", "text": "Sí" }{ "type": "URL", "text": "Ver pedido", "url": "https://shop.com/o/{{1}}", "example": ["https://shop.com/o/123"] }{ "type": "PHONE_NUMBER", "text": "Llamar", "phone_number": "+59899123456" }URLs con variable suffix (
{{1}}) requierenexamplecon una URL real de muestra — Meta lo exige para validar el template.
COPY_CODE (cupones)
Section titled “COPY_CODE (cupones)”{ "type": "COPY_CODE", "example": "PROMO2026" }CATALOG (passthrough — requiere Meta catalog binding)
Section titled “CATALOG (passthrough — requiere Meta catalog binding)”{ "type": "CATALOG" }Requiere catálogo configurado en Commerce Manager. Kalipto reenvía el shape verbatim, no valida el binding.
FLOW — EXCLUIDO
Section titled “FLOW — EXCLUIDO”Los botones de tipo FLOW son rechazados con 422. Decisión de
arquitectura — no están soportados en la primera iteración de la
plataforma.
Categoría authentication (OTP)
Section titled “Categoría authentication (OTP)”Los templates de categoría authentication tienen un body fijo
renderizado por Meta — no se suministra body_text libre. La estructura
es estricta:
{ "channel_id": 42, "name": "otp_login", "category": "authentication", "language_code": "es", "components": [ { "type": "BODY", "add_security_recommendation": true }, { "type": "FOOTER", "code_expiration_minutes": 10 }, { "type": "BUTTONS", "buttons": [ { "type": "OTP", "otp_type": "COPY_CODE" } ] } ]}Reglas:
- NO se permite
body_textlibre → 422 si se suministra. - NO se permite componente HEADER → 422.
code_expiration_minutes∈[1, 90]cuando está presente; omitir el componente FOOTER si no se quiere expiración.- Exactamente 1 botón OTP;
otp_type ∈ {COPY_CODE, ONE_TAP, ZERO_TAP}.
OTP types
Section titled “OTP types”// COPY_CODE OTP (one-tap copy){ "type": "OTP", "otp_type": "COPY_CODE" }
// ONE_TAP (autofill + copy fallback) — supported_apps PASSTHROUGH{ "type": "OTP", "otp_type": "ONE_TAP", "supported_apps": [{ "package_name": "com.example.app", "signature_hash": "<HASH>" }]}
// ZERO_TAP (zero-tap autofill) — supported_apps PASSTHROUGH + zero_tap_terms_accepted{ "type": "OTP", "otp_type": "ZERO_TAP", "zero_tap_terms_accepted": true, "supported_apps": [{ "package_name": "com.example.app", "signature_hash": "<HASH>" }]}
supported_appses passthrough: Kalipto reenvía el shape verbatim, NO valida elpackage_nameni elsignature_hash. La responsabilidad de configurarlos correctamente en Meta es del developer.
En SEND time el código OTP va en AMBOS el
bodyparameter Y elbuttonparameter del send (POST /platform/v1/messagestype=template).
CAROUSEL (2-10 cards)
Section titled “CAROUSEL (2-10 cards)”{ "type": "CAROUSEL", "cards": [ { "components": [ { "type": "HEADER", "format": "IMAGE", "example": { "header_handle": ["<HANDLE_1>"] } }, { "type": "BODY", "text": "{{1}}", "example": { "body_text": [["Producto A"]] } }, { "type": "BUTTONS", "buttons": [ { "type": "QUICK_REPLY", "text": "Comprar" }, { "type": "URL", "text": "Ver", "url": "https://shop.com/a" } ] } ] }, { "components": [ { "type": "HEADER", "format": "IMAGE", "example": { "header_handle": ["<HANDLE_2>"] } }, { "type": "BODY", "text": "{{1}}", "example": { "body_text": [["Producto B"]] } }, { "type": "BUTTONS", "buttons": [ { "type": "URL", "text": "Ver", "url": "https://shop.com/b" } ] } ] } ]}- Entre 2 y 10 cards (1 o 11+ →
422). - Cada card tiene su propio HEADER + BODY + BUTTONS.
Invariante crítica en SEND time: el número de cards en el mensaje enviado DEBE coincidir con el número aprobado o Meta devuelve error #132012 (template-card-count mismatch). La responsabilidad de hacer match con el count es del developer.
Editar un template aprobado (PATCH)
Section titled “Editar un template aprobado (PATCH)”curl -X PATCH https://api.kalipto.app/platform/v1/templates/123 \ -H "X-API-Key: $KALIPTO_KEY" \ -H "Content-Type: application/json" \ -d '{ "components": [ { "type": "BODY", "text": "Hola {{1}}, tu pedido {{2}} fue enviado.", "example": { "body_text": [["Juan", "#123"]] } } ] }'Status codes:
| Code | Trigger |
|---|---|
200 | Edición aceptada y re-submit a Meta exitoso. La respuesta carga el row actualizado con el nuevo components y meta_status (puede quedar en pending mientras Meta re-revisa). |
404 | Template no encontrado o no pertenece a tu cuenta (opaque cross-tenant). |
409 status_locked | El template está en meta_status='pending' — Meta no acepta editar pendings. Esperá la aprobación o el rechazo. |
422 legacy_row_supply_components | El row es legacy (sin components almacenado) Y el PATCH no provee components. Re-submit con el body completo. |
422 | Shape de componente inválido / categoría inválida / canal inactivo. |
502 | Error de red o non-2xx de Meta. |
Sync desde Meta (POST /sync)
Section titled “Sync desde Meta (POST /sync)”Importá + upsert TODOS los templates que ya existen en el WhatsApp Manager del WABA del canal. Útil cuando un negocio ya tiene una librería de templates en Meta y querés “pull” ese state.
curl -X POST https://api.kalipto.app/platform/v1/templates/sync \ -H "X-API-Key: $KALIPTO_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel_id": 42 }'Respuesta:
{ "imported": 5, "updated": 3, "skipped": 2, "total": 10 }- Idempotente — upsert por
(platform_channel_id, name, language_code). - Re-corre el sync sin miedo; los rows existentes se actualizan, no se duplican.
skipped= rows en Meta con categoría fuera de{utility, marketing, authentication}(raro — Meta a veces lista archivados).
Próximos pasos
Section titled “Próximos pasos”- Enviar mensajes — incluye cómo invocar un template con
type=templateusando los componentes de envío (minúsculas), un contrato tipado DISTINTO al de creación de templates (mayúsculas) — nunca son intercambiables. - Webhooks normalizados — suscribite a
template.status_changedpara enterarte de una aprobación/rechazo sin hacer polling. - Verificación HMAC — verificar la firma de los webhooks normalizados.