Teams: publicar automáticamente una Adaptive Card con datos dinámicos (Python + Webhook)

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

Índice

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 y content.
  • La tarjeta completa ($schema, type, version, body) vive dentro de attachments[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 y False se serializan correctamente a true y false en JSON cuando usas json=... con requests.
  • Evita pasar una cadena JSON preformateada en data=; preferiblemente pasa el objeto con json=.
  • Usa variables de entorno para el webhook; nunca lo subas al repositorio.

Comparativa: correcto vs. incorrecto

ProblemaPayload incorrectoCorrecció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 expandirTextBlock con "text":"${releaseTitle}"Reemplaza previamente a "text":"Versión 3.2"
Uso de propiedades de otros esquemasMezclar potentialAction de Office 365 ConnectorsUsa actions de Adaptive Cards (p. ej. Action.OpenUrl)
Se usa Action.SubmitAcciones interactivas que requieren respuestaIncoming Webhook no procesa Action.Submit; usa Action.OpenUrl o ToggleVisibility
Tamaño excedidoAdjuntos 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 de attachments[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:

  1. Crea un flujo con el desencadenador “When a Teams webhook request is received”.
  2. En el desencadenador define el esquema JSON de entrada (por ejemplo, releaseTitle y description).
  3. Añade la acción “Post card in chat or channel”.
  4. En la acción, pega tu Adaptive Card y usa el panel de contenido dinámico para insertar los campos del trigger.
  5. 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. Evita Action.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

PropiedadDónde vaNotas
type (message)RaízObligatorio
attachmentsRaízLista de adjuntos; al menos 1
contentTypeattachments[i]Debe ser application/vnd.microsoft.card.adaptive
contentattachments[i]Aquí vive la Adaptive Card
$schema, type (AdaptiveCard), version, body, actionsattachments[i].contentPropiedades 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

  1. Valida la forma: ¿existen type:"message", attachments (lista), contentType, content?
  2. Comprueba el tamaño: si se acerca a 28 KB, reduce texto o divide en varias tarjetas.
  3. Elimina placeholders: asegura que no viajen ${...} al webhook.
  4. Simplifica: empieza con dos TextBlock; si eso funciona, añade piezas hasta encontrar qué rompe el render.
  5. Logs del servidor: registra el status y parte del cuerpo devuelto cuando hay error HTTP.
  6. 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.adaptiveattachments[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.
Índice