> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sofiachat.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tutorial: tu primera campaña por API

> El flujo completo de una campaña de WhatsApp en v1, de la API key al reporte de entrega, con ejemplos en cURL, Node y Python.

El flujo completo de una campaña de WhatsApp en v1, de la API key al reporte de entrega. Cada paso trae ejemplos copiables en cURL, Node y Python. Los conceptos (plantillas, ventana de 24 h, opt-out, estados) están en la [guía de Outbound](/guia/07-outbound/campanas).

<Note>Trabajaremos con una key en la variable `KEY` y el canal de WhatsApp con `channelId/integrationId` = `9`. Sustituye por los tuyos. Todas las llamadas usan la URL base `https://api-v2.sofiachat.com/api/v1`.</Note>

## 1 · Crea la key y verifica el acceso

Crea una API key con scopes `read` y `write` desde el panel (**Configuración → API**) y confirma que responde y que tu plan incluye campañas con `GET /v1/me`:

<CodeGroup>
  ```bash cURL theme={null}
  export KEY="sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

  curl https://api-v2.sofiachat.com/api/v1/me \
    -H "X-API-Key: $KEY"
  ```

  ```javascript Node theme={null}
  const KEY = process.env.KEY;

  const res = await fetch("https://api-v2.sofiachat.com/api/v1/me", {
    headers: { "X-API-Key": KEY },
  });
  const data = await res.json();
  ```

  ```python Python theme={null}
  import os, requests

  KEY = os.environ["KEY"]

  res = requests.get(
      "https://api-v2.sofiachat.com/api/v1/me",
      headers={"X-API-Key": KEY},
  )
  data = res.json()
  ```
</CodeGroup>

En la respuesta, revisa `plan.limits.conversations` y `plan.usage.conversations` (margen disponible) y que `apiKey.scopes` incluya `write`. Si Outbound no está en tu plan, las llamadas siguientes responden `403 PLAN_FEATURE_REQUIRED`.

## 2 · Consigue una plantilla aprobada

Una campaña envía una plantilla **aprobada**. Lista las del canal con `GET /v1/templates`:

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api-v2.sofiachat.com/api/v1/templates?channelId=9&status=approved" \
    -H "X-API-Key: $KEY"
  ```

  ```javascript Node theme={null}
  const res = await fetch(
    "https://api-v2.sofiachat.com/api/v1/templates?channelId=9&status=approved",
    { headers: { "X-API-Key": KEY } },
  );
  const { data } = await res.json();
  ```

  ```python Python theme={null}
  res = requests.get(
      "https://api-v2.sofiachat.com/api/v1/templates",
      headers={"X-API-Key": KEY},
      params={"channelId": 9, "status": "approved"},
  )
  templates = res.json()["data"]
  ```
</CodeGroup>

Si el arreglo `data` viene vacío, créala con `POST /v1/templates` y envíala a revisión de Meta. Usa placeholders con nombre en el cuerpo:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-v2.sofiachat.com/api/v1/templates \
    -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{
      "integrationId": 9,
      "name": "recordatorio_renovacion",
      "language": "es_MX",
      "category": "UTILITY",
      "bodyText": "Hola {{nombre}}, tu {{producto}} vence el {{fecha}}. ¿Deseas renovarla?",
      "bodyExamples": { "nombre": "Ana", "producto": "póliza de auto", "fecha": "30 de noviembre" }
    }'
  ```

  ```javascript Node theme={null}
  const res = await fetch("https://api-v2.sofiachat.com/api/v1/templates", {
    method: "POST",
    headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({
      integrationId: 9,
      name: "recordatorio_renovacion",
      language: "es_MX",
      category: "UTILITY",
      bodyText: "Hola {{nombre}}, tu {{producto}} vence el {{fecha}}. ¿Deseas renovarla?",
      bodyExamples: { nombre: "Ana", producto: "póliza de auto", fecha: "30 de noviembre" },
    }),
  });
  ```

  ```python Python theme={null}
  res = requests.post(
      "https://api-v2.sofiachat.com/api/v1/templates",
      headers={"X-API-Key": KEY},
      json={
          "integrationId": 9,
          "name": "recordatorio_renovacion",
          "language": "es_MX",
          "category": "UTILITY",
          "bodyText": "Hola {{nombre}}, tu {{producto}} vence el {{fecha}}. ¿Deseas renovarla?",
          "bodyExamples": {"nombre": "Ana", "producto": "póliza de auto", "fecha": "30 de noviembre"},
      },
  )
  ```
</CodeGroup>

<Warning>**Ojo con el nombrado.** Este endpoint y `POST /v1/campaigns` reciben `integrationId` (no `channelId`), y `category` / `buttons[].type` van en MAYÚSCULAS.</Warning>

La aprobación de Meta suele tardar minutos, pero **puede tomar hasta 24 h**. Sincroniza con `POST /v1/templates/sync` para traer el estado actualizado y repite el `GET` hasta ver `status: "approved"`:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-v2.sofiachat.com/api/v1/templates/sync \
    -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{ "channelId": 9 }'
  ```

  ```javascript Node theme={null}
  await fetch("https://api-v2.sofiachat.com/api/v1/templates/sync", {
    method: "POST",
    headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ channelId: 9 }),
  });
  ```

  ```python Python theme={null}
  requests.post(
      "https://api-v2.sofiachat.com/api/v1/templates/sync",
      headers={"X-API-Key": KEY},
      json={"channelId": 9},
  )
  ```
</CodeGroup>

Guarda el `id` de la plantilla aprobada (lo usaremos como `templateId`).

## 3 · Define los campos personalizados

Las variables de la plantilla que no son datos estándar del contacto viven en `custom_fields`. Declara su clave *antes* de importar contactos con `POST /v1/contact-fields`:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-v2.sofiachat.com/api/v1/contact-fields \
    -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{ "key": "producto", "label": "Producto", "type": "text" }'

  curl -X POST https://api-v2.sofiachat.com/api/v1/contact-fields \
    -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{ "key": "vencimiento", "label": "Fecha de vencimiento", "type": "date" }'
  ```

  ```javascript Node theme={null}
  const fields = [
    { key: "producto", label: "Producto", type: "text" },
    { key: "vencimiento", label: "Fecha de vencimiento", type: "date" },
  ];

  for (const field of fields) {
    await fetch("https://api-v2.sofiachat.com/api/v1/contact-fields", {
      method: "POST",
      headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
      body: JSON.stringify(field),
    });
  }
  ```

  ```python Python theme={null}
  fields = [
      {"key": "producto", "label": "Producto", "type": "text"},
      {"key": "vencimiento", "label": "Fecha de vencimiento", "type": "date"},
  ]

  for field in fields:
      requests.post(
          "https://api-v2.sofiachat.com/api/v1/contact-fields",
          headers={"X-API-Key": KEY},
          json=field,
      )
  ```
</CodeGroup>

## 4 · Carga los contactos

Crea contactos uno a uno con `POST /v1/contacts` y sus `custom_fields`…

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-v2.sofiachat.com/api/v1/contacts \
    -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{
      "phone": "+50255551234",
      "first_name": "Ana", "last_name": "Pérez",
      "custom_fields": { "producto": "póliza de auto", "vencimiento": "2026-11-30" }
    }'
  ```

  ```javascript Node theme={null}
  await fetch("https://api-v2.sofiachat.com/api/v1/contacts", {
    method: "POST",
    headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({
      phone: "+50255551234",
      first_name: "Ana",
      last_name: "Pérez",
      custom_fields: { producto: "póliza de auto", vencimiento: "2026-11-30" },
    }),
  });
  ```

  ```python Python theme={null}
  requests.post(
      "https://api-v2.sofiachat.com/api/v1/contacts",
      headers={"X-API-Key": KEY},
      json={
          "phone": "+50255551234",
          "first_name": "Ana",
          "last_name": "Pérez",
          "custom_fields": {"producto": "póliza de auto", "vencimiento": "2026-11-30"},
      },
  )
  ```
</CodeGroup>

…o impórtalos en lote desde un XLSX/CSV con `POST /v1/contacts/import` (mismas columnas que la plantilla de importación del panel):

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-v2.sofiachat.com/api/v1/contacts/import \
    -H "X-API-Key: $KEY" -F "file=@contactos.xlsx"
  ```

  ```javascript Node theme={null}
  import { readFile } from "node:fs/promises";

  const form = new FormData();
  form.append("file", new Blob([await readFile("contactos.xlsx")]), "contactos.xlsx");

  await fetch("https://api-v2.sofiachat.com/api/v1/contacts/import", {
    method: "POST",
    headers: { "X-API-Key": KEY },
    body: form,
  });
  ```

  ```python Python theme={null}
  with open("contactos.xlsx", "rb") as f:
      res = requests.post(
          "https://api-v2.sofiachat.com/api/v1/contacts/import",
          headers={"X-API-Key": KEY},
          files={"file": f},
      )
  ```
</CodeGroup>

Guarda los `id` de los contactos creados.

## 5 · Crea el grupo y añade los contactos

El grupo es la audiencia reutilizable de la campaña. Créalo con `POST /v1/contact-groups` y llénalo con `POST /v1/contact-groups/:id/contacts`:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-v2.sofiachat.com/api/v1/contact-groups \
    -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{ "name": "Renovaciones noviembre" }'

  curl -X POST https://api-v2.sofiachat.com/api/v1/contact-groups/2/contacts \
    -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{ "contactIds": [1, 2, 3] }'
  ```

  ```javascript Node theme={null}
  const grupo = await (
    await fetch("https://api-v2.sofiachat.com/api/v1/contact-groups", {
      method: "POST",
      headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ name: "Renovaciones noviembre" }),
    })
  ).json();

  await fetch("https://api-v2.sofiachat.com/api/v1/contact-groups/2/contacts", {
    method: "POST",
    headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ contactIds: [1, 2, 3] }),
  });
  ```

  ```python Python theme={null}
  grupo = requests.post(
      "https://api-v2.sofiachat.com/api/v1/contact-groups",
      headers={"X-API-Key": KEY},
      json={"name": "Renovaciones noviembre"},
  ).json()

  requests.post(
      "https://api-v2.sofiachat.com/api/v1/contact-groups/2/contacts",
      headers={"X-API-Key": KEY},
      json={"contactIds": [1, 2, 3]},
  )
  ```
</CodeGroup>

## 6 · Crea la campaña y mapea cada variable

Crea la campaña con `POST /v1/campaigns`. Sin `scheduledAt`, queda en `draft` (buen momento para revisar antes de enviar):

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-v2.sofiachat.com/api/v1/campaigns \
    -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
    -d '{
      "name": "Renovaciones noviembre",
      "integrationId": 9,
      "templateId": 1,
      "groupIds": [2],
      "parameterMapping": {
        "body": [
          { "source": "contact_field", "field": "first_name" },
          { "source": "custom_field",  "key": "producto" },
          { "source": "custom_field",  "key": "vencimiento" }
        ]
      },
      "conversationTagId": 18
    }'
  ```

  ```javascript Node theme={null}
  const campana = await (
    await fetch("https://api-v2.sofiachat.com/api/v1/campaigns", {
      method: "POST",
      headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
      body: JSON.stringify({
        name: "Renovaciones noviembre",
        integrationId: 9,
        templateId: 1,
        groupIds: [2],
        parameterMapping: {
          body: [
            { source: "contact_field", field: "first_name" },
            { source: "custom_field", key: "producto" },
            { source: "custom_field", key: "vencimiento" },
          ],
        },
        conversationTagId: 18,
      }),
    })
  ).json();
  ```

  ```python Python theme={null}
  campana = requests.post(
      "https://api-v2.sofiachat.com/api/v1/campaigns",
      headers={"X-API-Key": KEY},
      json={
          "name": "Renovaciones noviembre",
          "integrationId": 9,
          "templateId": 1,
          "groupIds": [2],
          "parameterMapping": {
              "body": [
                  {"source": "contact_field", "field": "first_name"},
                  {"source": "custom_field", "key": "producto"},
                  {"source": "custom_field", "key": "vencimiento"},
              ]
          },
          "conversationTagId": 18,
      },
  ).json()
  ```
</CodeGroup>

El cuerpo de la plantilla era `"Hola {{nombre}}, tu {{producto}} vence el {{fecha}}."`. El arreglo `parameterMapping.body` se lee **en orden**, un elemento por variable:

| Variable       | Elemento del mapping                                   | De dónde sale                             |
| -------------- | ------------------------------------------------------ | ----------------------------------------- |
| `{{nombre}}`   | `{ "source": "contact_field", "field": "first_name" }` | Dato estándar del contacto.               |
| `{{producto}}` | `{ "source": "custom_field", "key": "producto" }`      | `custom_fields.producto` del contacto.    |
| `{{fecha}}`    | `{ "source": "custom_field", "key": "vencimiento" }`   | `custom_fields.vencimiento` del contacto. |

Cada variable admite tres orígenes: `contact_field` con `field` (`first_name`, `last_name`, `email`, `phone`, `full_name`), `custom_field` con `key`, o `static` con `value` (mismo texto para todos). Guarda el `id` de la campaña que devuelve la respuesta.

## 7 · Dispara el envío

Inícialo con `POST /v1/campaigns/:id/send`:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-v2.sofiachat.com/api/v1/campaigns/2/send \
    -H "X-API-Key: $KEY"
  ```

  ```javascript Node theme={null}
  await fetch("https://api-v2.sofiachat.com/api/v1/campaigns/2/send", {
    method: "POST",
    headers: { "X-API-Key": KEY },
  });
  ```

  ```python Python theme={null}
  requests.post(
      "https://api-v2.sofiachat.com/api/v1/campaigns/2/send",
      headers={"X-API-Key": KEY},
  )
  ```
</CodeGroup>

El envío es asíncrono: la campaña pasa a `sending` y los mensajes salen de forma progresiva. (Si programaste `scheduledAt`, saldrá sola a esa hora; `send` la fuerza de inmediato.) Para detener una campaña en `draft` o `scheduled`, usa `POST /v1/campaigns/:id/cancel`.

## 8 · Sigue el avance y revisa los fallos

Consulta los contadores en el detalle de la campaña con `GET /v1/campaigns/:id`:

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api-v2.sofiachat.com/api/v1/campaigns/2 \
    -H "X-API-Key: $KEY"
  ```

  ```javascript Node theme={null}
  const res = await fetch("https://api-v2.sofiachat.com/api/v1/campaigns/2", {
    headers: { "X-API-Key": KEY },
  });
  const { data } = await res.json();
  ```

  ```python Python theme={null}
  res = requests.get(
      "https://api-v2.sofiachat.com/api/v1/campaigns/2",
      headers={"X-API-Key": KEY},
  )
  data = res.json()["data"]
  ```
</CodeGroup>

`recipientCounts` resume el estado: `{ total, pending, sending, sent, delivered, read, failed, skipped }`. Cuando `pending` y `sending` llegan a 0, terminó. Lista los destinatarios que fallaron con `GET /v1/campaigns/:id/recipients` para depurar tus listas:

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api-v2.sofiachat.com/api/v1/campaigns/2/recipients?status=failed" \
    -H "X-API-Key: $KEY"
  ```

  ```javascript Node theme={null}
  const res = await fetch(
    "https://api-v2.sofiachat.com/api/v1/campaigns/2/recipients?status=failed",
    { headers: { "X-API-Key": KEY } },
  );
  const { data } = await res.json();
  ```

  ```python Python theme={null}
  res = requests.get(
      "https://api-v2.sofiachat.com/api/v1/campaigns/2/recipients",
      headers={"X-API-Key": KEY},
      params={"status": "failed"},
  )
  data = res.json()["data"]
  ```
</CodeGroup>

Cada destinatario incluye el contacto, su `status`, el `wa_message_id` y el error si lo hubo.

## 9 · Interpreta los fallos más comunes

| Qué ves                                    | Qué significa y qué hacer                                                                                                                                                  |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `skipped`                                  | El contacto tiene **opt-out**: se omite de toda campaña por política de Meta. Es esperado, no un error. Reactívalo con `POST /v1/contacts/:id/opt-in` solo si corresponde. |
| `CHANNEL_BILLING_LIMITED` · error `131042` | El WABA no tiene método de pago válido en Meta; los envíos no se entregan. Agrega el método de pago en Meta Business Suite y reintenta.                                    |
| Envío rechazado por plantilla              | La plantilla dejó de estar `approved` (Meta la *pausó*, la rechazó o la recategorizó). Ejecuta `POST /v1/templates/sync`, verifica `status` y usa una plantilla aprobada.  |
| `failed` por número                        | Número inválido o sin WhatsApp. Corrige el teléfono del contacto (formato internacional E.164) y vuelve a incluirlo.                                                       |

Cuando un destinatario **responde**, se abre la ventana de 24 h y su conversación entra al panel bajo el `conversationTagId` que definiste, para que la atienda la IA o un agente. Para automatizar este ciclo (renovaciones diarias, reintentos), mira las [recetas de integración](/desarrolladores/recetas).
