Skip to content

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. components en POST/PATCH es 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. FLOW no existe como opción; no es un caso que se rechace por validación explícita, directamente no está en el vocabulario aceptado.

RequisitoDetalle
Scopemanage:templates (key O cookie — es la única superficie cross-method del developer API).
PlanLos 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.
  1. Crear un draft con POST /platform/v1/templates (status local pending hasta que Meta apruebe).
  2. Esperar la aprobación de Meta (asíncrona — Meta postea al webhook message_template_status_update y Kalipto actualiza meta_status / meta_template_id / meta_rejection_reason en la fila correspondiente). Si tenés una suscripción a webhooks normalizados (manage:webhooks), podés recibir el evento template.status_changed en lugar de tener que hacer polling — ver Webhooks normalizados.
  3. Enviar con POST /platform/v1/messages type=template, body { template_name, language_code, components }.
  4. Editar con PATCH /platform/v1/templates/{id} — Meta acepta editar templates APPROVED o REJECTED; los PENDING devuelven 409 status_locked.
  5. Borrar con DELETE /platform/v1/templates/{id} (borra local + best-effort en Meta).
RutaPropósito
POST /platform/v1/templatesCrear + 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/templatesListar (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/syncImportar + 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-mediaSubida 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}}.

Terminal window
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": "" },
{ "type": "URL", "text": "Ver pedido", "url": "https://shop.com/o/{{1}}", "example": ["https://shop.com/o/123"] }
]
}
]
}

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" }
]
}
}
]
}

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:

Terminal window
# Subida via multipart
curl -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>"] } }
TipoLímite
image5 MB
video16 MB
audio16 MB
document100 MB
ErrorTrigger
422MIME no soportado / SSRF bloqueado (URL privada o redirect a IP interna)
413Excede el límite de tamaño
503WHATSAPP_APP_ID / WHATSAPP_APP_SECRET no configurados en el backend
502Error de red o non-2xx de Meta

QUICK_REPLY / URL / PHONE_NUMBER (managed reference)

Section titled “QUICK_REPLY / URL / PHONE_NUMBER (managed reference)”
{ "type": "QUICK_REPLY", "text": "" }
{ "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}}) requieren example con una URL real de muestra — Meta lo exige para validar el template.

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

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.

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_text libre → 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}.
// 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_apps es passthrough: Kalipto reenvía el shape verbatim, NO valida el package_name ni el signature_hash. La responsabilidad de configurarlos correctamente en Meta es del developer.

En SEND time el código OTP va en AMBOS el body parameter Y el button parameter del send (POST /platform/v1/messages type=template).

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

Terminal window
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:

CodeTrigger
200Edició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).
404Template no encontrado o no pertenece a tu cuenta (opaque cross-tenant).
409 status_lockedEl template está en meta_status='pending' — Meta no acepta editar pendings. Esperá la aprobación o el rechazo.
422 legacy_row_supply_componentsEl row es legacy (sin components almacenado) Y el PATCH no provee components. Re-submit con el body completo.
422Shape de componente inválido / categoría inválida / canal inactivo.
502Error de red o non-2xx de Meta.

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.

Terminal window
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).
  • Enviar mensajes — incluye cómo invocar un template con type=template usando 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_changed para enterarte de una aprobación/rechazo sin hacer polling.
  • Verificación HMAC — verificar la firma de los webhooks normalizados.