¿Tu webhook de Teams “funciona” pero la Adaptive Card aparece vacía? La causa casi siempre es la forma del JSON. Aquí te explico, con ejemplos listos para usar en Python, cómo publicar tarjetas con datos dinámicos que sí se ven en el canal y cómo depurar los errores más comunes.
Resumen del problema
Generas en Python un JSON de Adaptive Card con variables como ${releaseTitle}
y ${description}
, haces un POST al Incoming Webhook de Teams y el mensaje llega… pero la tarjeta sale en blanco. ¿Por qué? Porque el conector espera un sobre de mensaje con adjuntos (attachments) y la tarjeta debe ir dentro de attachments[0].content
; además, Teams no realiza el templating por ti: si mandas ${...}
tal cual, se publicarán esos literales o un vacío.
La causa técnica más probable
El payload mezcla esquemas o ubica propiedades en lugares equivocados. Los Incoming Webhooks de Teams usan la estructura de mensaje con adjuntos. La tarjeta real (Adaptive Card) va dentro del adjunto, en la propiedad content
, y el tipo del adjunto debe ser application/vnd.microsoft.card.adaptive
. Si body
o cualquier elemento de la tarjeta queda “al lado” del adjunto, o si falta el tipo de contenido, Teams no sabe cómo renderizarla.
Estructura mínima válida
Este es el ejemplo más corto que puedes enviar a un Incoming Webhook para mostrar una tarjeta simple con un título y una descripción:
{
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{ "type": "TextBlock", "text": "Título", "weight": "bolder", "size": "large" },
{ "type": "TextBlock", "text": "Descripción", "wrap": true }
]
}
}
]
}
Observa los detalles clave:
"type": "message"
en la raíz.attachments
es una lista.- Cada adjunto define
contentType
ycontent
. - La tarjeta completa (
$schema
,type
,version
,body
) vive dentro deattachments[0].content
.
Datos dinámicos y plantillas
Los placeholders ${variable}
pertenecen al lenguaje de plantillas de Adaptive Cards, pero el Incoming Webhook de Teams no los resuelve. Debes expandirlos tú antes de enviar. Eso significa construir el JSON final sustituyendo los valores dinámicos desde tu propio código o usando una librería de plantillas en tu backend. El webhook debe recibir una tarjeta sin marcadores, con los textos ya colocados.
Ejemplo end‑to‑end en Python
Este ejemplo recibe dos variables y publica la tarjeta con las cadenas ya resueltas. Incluye manejo básico de errores y tiempo de espera:
import os
import json
import requests
WEBHOOKURL = os.environ.get("TEAMSWEBHOOK_URL") # Guárdala como secreto
def publicaradaptivecard(release_title: str, description: str) -> None:
card = {
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": release_title,
"weight": "bolder",
"size": "large",
"wrap": True
},
{
"type": "TextBlock",
"text": description,
"wrap": True
}
]
}
}
]
}
if not WEBHOOK_URL:
raise RuntimeError("Falta TEAMSWEBHOOKURL en variables de entorno")
resp = requests.post(
WEBHOOK_URL,
json=card,
timeout=10 # segundos
)
try:
resp.raiseforstatus()
except requests.HTTPError as e:
# Suele devolver 400 si el payload es inválido.
# Añade el cuerpo devuelto para depurar.
raise RuntimeError(f"Error HTTP {resp.status_code}: {resp.text}") from e
if name == "main":
publicaradaptivecard(
release_title="Versión 3.2",
description="Correcciones de bugs, mejoras de rendimiento y ajustes menores."
)
Puntos finos a considerar:
- En Python,
True
yFalse
se serializan correctamente atrue
yfalse
en JSON cuando usasjson=...
conrequests
. - Evita pasar una cadena JSON preformateada en
data=
; preferiblemente pasa el objeto conjson=
. - Usa variables de entorno para el webhook; nunca lo subas al repositorio.
Comparativa: correcto vs. incorrecto
Problema | Payload incorrecto | Corrección |
---|---|---|
La tarjeta está “al lado” del adjunto | {"type":"message", "body":[...]} | Mueve body a attachments[0].content.body |
Falta el tipo de adjunto | {"attachments":[{"content":{...}}]} | Incluye contentType:"application/vnd.microsoft.card.adaptive" |
Se envían placeholders sin expandir | TextBlock con "text":"${releaseTitle}" | Reemplaza previamente a "text":"Versión 3.2" |
Uso de propiedades de otros esquemas | Mezclar potentialAction de Office 365 Connectors | Usa actions de Adaptive Cards (p. ej. Action.OpenUrl ) |
Se usa Action.Submit | Acciones interactivas que requieren respuesta | Incoming Webhook no procesa Action.Submit ; usa Action.OpenUrl o ToggleVisibility |
Tamaño excedido | Adjuntos muy grandes (> ~28 KB) | Reduce texto, imágenes o número de elementos |
Plantillas: tres formas de expandir variables
Elige la que prefieras en tu backend. Cualquiera te sirve para que el JSON final no contenga ${...}
.
Usar f-strings o format
release_title = "Versión 3.2"
description = "Correcciones y mejoras."
card\text = f"{release\title}\n\n{description}"
Construir el diccionario Python directamente
card_content = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{"type": "TextBlock", "text": release_title, "weight": "bolder", "size": "large"},
{"type": "TextBlock", "text": description, "wrap": True},
]
}
Usar una librería de plantillas (opcional)
Con Jinja2 o similar, mantienes archivos de plantilla y los renders cuando publicas. Es cómodo si tu tarjeta tiene muchas variantes o necesitas condiciones (if/for).
from jinja2 import Template
tpl = Template(r'''
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{"type": "TextBlock", "text": "{{ title }}", "weight": "bolder", "size": "large", "wrap": true},
{"type": "TextBlock", "text": "{{ desc }}", "wrap": true}
]
}
''')
content = tpl.render(title=release_title, desc=description)
Renderiza la tarjeta y colócala en attachments[0].content
(convierte la cadena a dict con json.loads
si lo necesitas).
Ejemplos prácticos de tarjetas
Card de lanzamiento con enlace y metadatos
{
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{ "type": "TextBlock", "text": "Versión 3.2", "weight": "bolder", "size": "large", "wrap": true },
{ "type": "TextBlock", "text": "Correcciones y mejoras de rendimiento.", "wrap": true },
{
"type": "FactSet",
"facts": [
{ "title": "Commit:", "value": "a1b2c3d" },
{ "title": "Autor:", "value": "CI/CD Bot" },
{ "title": "Fecha:", "value": "2025-08-09" }
]
}
],
"actions": [
{ "type": "Action.OpenUrl", "title": "Notas de versión", "url": "https://example.com/releases/3.2" }
]
}
}
]
}
Card con secciones plegables
Con Action.ToggleVisibility
puedes mostrar/ocultar detalles sin interacción de servidor:
{
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{ "type": "TextBlock", "text": "Resultado de despliegue", "weight": "bolder", "size": "large" },
{ "type": "TextBlock", "text": "Servicios actualizados con éxito.", "wrap": true },
{ "type": "TextBlock", "id": "detalles", "text": "Logs y métricas...", "wrap": true, "isVisible": false }
],
"actions": [
{ "type": "Action.ToggleVisibility", "title": "Ver detalles", "targetElements": ["detalles"] }
]
}
}
]
}
Checklist rápido
- Usa el sobre correcto:
type:"message"
+attachments[]
. - Define
contentType:"application/vnd.microsoft.card.adaptive"
. - Coloca
body
dentro deattachments[0].content
. - Expande
${releaseTitle}
y${description}
en tu backend antes de enviar. - Si no quieres gestionar el webhook directo, usa Workflows/Power Automate con la acción “Post card in chat or channel”.
Solución alternativa con Workflows o Power Automate en Teams
Si prefieres evitar la forma “manual” del webhook, puedes hacer que Teams procese la tarjeta por ti con un flujo. El patrón típico:
- Crea un flujo con el desencadenador “When a Teams webhook request is received”.
- En el desencadenador define el esquema JSON de entrada (por ejemplo,
releaseTitle
ydescription
). - Añade la acción “Post card in chat or channel”.
- En la acción, pega tu Adaptive Card y usa el panel de contenido dinámico para insertar los campos del trigger.
- En tu backend, haz POST al endpoint del flujo con un JSON simple de datos (no la card completa).
Ejemplo de esquema de entrada para el flujo
{
"type": "object",
"properties": {
"releaseTitle": { "type": "string" },
"description": { "type": "string" },
"urlNotas": { "type": "string" }
},
"required": ["releaseTitle", "description"]
}
Ejemplo de payload que envías al flujo
{
"releaseTitle": "Versión 3.2",
"description": "Correcciones y mejoras.",
"urlNotas": "https://example.com/releases/3.2"
}
Ventajas de este enfoque:
- El diseño de la tarjeta queda en manos de Power Automate/Workflows.
- No necesitas incrustar la tarjeta completa en tu aplicación, solo los datos.
- Es más fácil para usuarios no técnicos mantener la tarjeta.
Compatibilidades y límites útiles
- Acciones soportadas por Incoming Webhook:
Action.OpenUrl
,Action.ShowCard
,Action.ToggleVisibility
. EvitaAction.Submit
(requiere un bot para procesar el submit). - Tamaño máximo de mensaje: aproximadamente 28 KB incluyendo el adjunto.
- Versión de Adaptive Card: 1.4–1.5 suelen funcionar bien en Teams actuales; si tu tenant es antiguo, usa 1.3/1.4 o prueba en el diseñador y ajusta.
- Markdown en TextBlock: soportado en lo básico (negritas, enlaces, listas); usa
"wrap": true
para saltos.
Tabla de ubicación correcta de propiedades
Propiedad | Dónde va | Notas |
---|---|---|
type (message) | Raíz | Obligatorio |
attachments | Raíz | Lista de adjuntos; al menos 1 |
contentType | attachments[i] | Debe ser application/vnd.microsoft.card.adaptive |
content | attachments[i] | Aquí vive la Adaptive Card |
$schema , type (AdaptiveCard), version , body , actions | attachments[i].content | Propiedades propias del esquema de Adaptive Cards |
Buenas prácticas de seguridad y operación
- Protege la URL del webhook: trátala como contraseña; almacénala en Key Vault/Secret Manager/Variables de entorno.
- Rotación: ante filtraciones, deshabilita el conector y crea uno nuevo.
- Idempotencia: si tu pipeline puede reenviar, agrega un identificador de mensaje en la tarjeta (p. ej., hash del commit).
- Observabilidad: registra el estado HTTP y un snippet del payload (sin secretos) cuando recibas 400/429/500 para depurar.
- Pruebas: valida tu card con un diseñador de Adaptive Cards antes de publicarla.
Prueba rápida con cURL
Útil para aislar problemas de tu código Python:
curl -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{
"type":"message",
"attachments":[
{
"contentType":"application/vnd.microsoft.card.adaptive",
"content":{
"$schema":"http://adaptivecards.io/schemas/adaptive-card.json",
"type":"AdaptiveCard",
"version":"1.5",
"body":[
{"type":"TextBlock","text":"Hola desde cURL","weight":"bolder","size":"large"},
{"type":"TextBlock","text":"Si ves esto en Teams, tu sobre es correcto","wrap":true}
]
}
}
]
}'
Patrón reutilizable en tu código
Si vas a publicar tarjetas a menudo, encapsula la lógica:
from typing import Dict, Any
import requests
def makeadaptivecard(body: list, actions: list | None = None) -> Dict[str, Any]:
card: Dict[str, Any] = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": body
}
if actions:
card["actions"] = actions
return card
def wrapforwebhook(card_content: Dict[str, Any]) -> Dict[str, Any]:
return {
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": card_content
}
]
}
def posttoteams(webhook_url: str, payload: Dict[str, Any]) -> None:
r = requests.post(webhook_url, json=payload, timeout=10)
try:
r.raiseforstatus()
except requests.HTTPError:
raise RuntimeError(f"Falló el POST: {r.status_code} - {r.text}")
Uso
body = [
{"type": "TextBlock", "text": "Título dinámico", "weight": "bolder", "size": "large", "wrap": True},
{"type": "TextBlock", "text": "Descripción dinámica", "wrap": True}
]
actions = [
{"type":"Action.OpenUrl", "title":"Abrir tablero", "url":"https://example.com/board"}
]
card = makeadaptivecard(body, actions)
payload = wrapforwebhook(card)
posttoteams(WEBHOOK_URL, payload)
Solución de problemas paso a paso
- Valida la forma: ¿existen
type:"message"
,attachments
(lista),contentType
,content
? - Comprueba el tamaño: si se acerca a 28 KB, reduce texto o divide en varias tarjetas.
- Elimina placeholders: asegura que no viajen
${...}
al webhook. - Simplifica: empieza con dos
TextBlock
; si eso funciona, añade piezas hasta encontrar qué rompe el render. - Logs del servidor: registra el status y parte del cuerpo devuelto cuando hay error HTTP.
- Evita acciones no soportadas: quita
Action.Submit
y prueba nuevamente.
Preguntas frecuentes
¿Puedo enviar una Adaptive Card “a pelo” sin attachments?
No. La tarjeta debe ir dentro de attachments[0].content
.
¿Por qué el texto aparece como literal ${miVariable}
?
Porque Teams no hace el templating. Sustituye tú las variables y envía el JSON final.
¿Puedo capturar datos del usuario con Input.Text
y Action.Submit
?
No con Incoming Webhook. Para acciones que envían datos necesitas un bot o app de Teams.
¿Puedo añadir varias tarjetas en un solo mensaje?
Sí, añade varios elementos en attachments
. Cada uno debe declarar su contentType
y content
.
¿Puedo mencionar usuarios o canales?
Las menciones con webhooks entrantes son limitadas. Si necesitas menciones ricas y confiables, plantéate usar un bot.
Conclusión
Para dejar de ver Adaptive Cards “vacías” al usar webhooks en Teams, céntrate en dos cosas: estructura correcta del payload (sobre de mensaje con adjuntos) y expansión previa de variables dinámicas. Con el patrón mostrado arriba y el checklist, tendrás tarjetas nítidas en tus canales. Si prefieres delegar la construcción de la tarjeta, usa Workflows/Power Automate y envía solo los datos: simplifica tu backend y habilita a los usuarios de negocio a mantener el diseño.
Resumen ejecutivo
- Causa típica: el JSON no usa el sobre de mensaje con
attachments
o manda placeholders sin expandir. - Fórmula ganadora:
type:"message"
→attachments[0].contentType
=application/vnd.microsoft.card.adaptive
→attachments[0].content
contiene la tarjeta completa. - Datos dinámicos: expándelos en tu servidor; el webhook no hace templating.
- Alternativa: Workflows/Power Automate con “Post card in chat or channel”.
- Límites: ~28 KB y sin
Action.Submit
en webhooks.