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

# Building a campaign

> Create a campaign, attach contact groups, set message content, and schedule one or more runs — using the Briq dashboard or the developer API.

Building a campaign in Karibu always follows the same four steps: create the campaign shell, attach the audiences it should reach, set the message content, then schedule when it should run. Most teams do this from the Briq dashboard at [briq.tz/login](https://briq.tz/login); the developer API is for integrators who need to automate the same flow.

This guide walks each step dashboard-first, with the equivalent API call right underneath. Nothing here actually sends messages — for the launch / pause / cancel transitions that turn a built campaign into delivered messages, see [Launch control](/guides/campaigns-launch-control). For an overview of the whole lifecycle, see [Campaigns](/guides/campaigns).

***

## 1. Create a campaign

### In the dashboard

1. Sign in at [briq.tz/login](https://briq.tz/login) and open the workspace you want to send from.
2. Go to the **Campaigns** tab and click **New Campaign**.
3. Fill in the name, an optional description, and pick the message channel (SMS, voice, or WhatsApp).
4. Save. The new campaign appears in the list with status `draft`.

A campaign in `draft` is just a shell — no audiences, no content, no runs yet. Nothing will be sent until you finish the remaining steps and launch it.

### Developer API

```http theme={null}
POST https://karibu.briq.tz/v1/campaign
```

**Headers:** `X-API-Key` (required), `Content-Type: application/json` (required), `X-App-ID` (optional — must match the API key's bound app if both are scoped), `Idempotency-Key` (optional).

<Note>
  **`Idempotency-Key` in Phase 1.** The header is accepted today but not yet enforced — duplicate POSTs may create duplicate campaigns. Send it anyway so SDK retries upgrade cleanly when enforcement ships.
</Note>

#### Request body

| Field                | Type   | Required | Default | Notes                                                                                    |
| -------------------- | ------ | -------- | ------- | ---------------------------------------------------------------------------------------- |
| `name`               | string | yes      | —       | 1–255 chars. Free-form, shown in the dashboard.                                          |
| `workspace_id`       | uuid   | yes      | —       | Must match the API key's workspace binding when one exists.                              |
| `message_channel_id` | uuid   | no       | `null`  | Optional at create time, but required before the campaign can be validated and launched. |
| `description`        | string | no       | `null`  | Free-form description.                                                                   |

#### Success response

```json theme={null}
{
  "success": true,
  "message": "Campaign created.",
  "data": {
    "campaign_id": "9d8c5b1a-2f3e-4a7b-8c9d-0e1f2a3b4c5d",
    "workspace_id": "11111111-2222-3333-4444-555555555555",
    "name": "May newsletter",
    "description": "Monthly product update",
    "message_channel_id": null,
    "status": "draft",
    "created_by": "user_abc123",
    "created_at": "2026-05-12T08:14:22.000Z",
    "updated_at": "2026-05-12T08:14:22.000Z"
  },
  "status_code": 201
}
```

`created_by` is set by the server from the authenticated user and cannot be supplied or spoofed in the request body. New campaigns always start in `draft` regardless of what you send.

#### Code samples

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://karibu.briq.tz/v1/campaign \
    -H "X-API-Key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: campaign-may-newsletter-001" \
    -d '{
      "name": "May newsletter",
      "workspace_id": "11111111-2222-3333-4444-555555555555",
      "description": "Monthly product update"
    }'
  ```

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

  BASE_URL = "https://karibu.briq.tz"
  API_KEY  = "YOUR_API_KEY"

  resp = requests.post(
      f"{BASE_URL}/v1/campaign",
      headers={
          "X-API-Key": API_KEY,
          "Content-Type": "application/json",
          "Idempotency-Key": "campaign-may-newsletter-001",
      },
      json={
          "name": "May newsletter",
          "workspace_id": "11111111-2222-3333-4444-555555555555",
          "description": "Monthly product update",
      },
      timeout=15,
  )
  campaign_id = resp.json()["data"]["campaign_id"]
  print(resp.status_code, campaign_id)
  ```

  ```javascript Node.js theme={null}
  const BASE_URL = "https://karibu.briq.tz";
  const API_KEY  = "YOUR_API_KEY";

  const resp = await fetch(`${BASE_URL}/v1/campaign`, {
    method: "POST",
    headers: {
      "X-API-Key": API_KEY,
      "Content-Type": "application/json",
      "Idempotency-Key": "campaign-may-newsletter-001",
    },
    body: JSON.stringify({
      name: "May newsletter",
      workspace_id: "11111111-2222-3333-4444-555555555555",
      description: "Monthly product update",
    }),
  });
  const body = await resp.json();
  console.log(resp.status, body.data.campaign_id);
  ```
</CodeGroup>

#### Error responses

| HTTP | Code                | Cause                                                                    |
| ---- | ------------------- | ------------------------------------------------------------------------ |
| 400  | `VALIDATION_FAILED` | `name` missing/too long, `workspace_id` missing or not a UUID.           |
| 401  | —                   | Missing or invalid `X-API-Key`.                                          |
| 403  | —                   | `workspace_id` is outside the scope bound to this API key.               |
| 404  | —                   | `workspace_id` does not exist or is not owned by the authenticated user. |
| 500  | —                   | Server error. Safe to retry.                                             |

***

## 2. Attach audiences

A campaign without audiences has no recipients. Audiences are attached as **contact groups** — you don't paste raw phone numbers into a campaign, you reference groups that already live in the workspace.

### In the dashboard

1. Open the campaign you just created.
2. Switch to the **Audiences** tab and click **Add contact groups**.
3. Multi-select existing contact groups from the same workspace and save.

Selected groups appear as chips on the campaign. You can re-open the picker later to add more, or click the **×** on a chip to detach one.

### Developer API

There are three audience endpoints: bulk-attach, list, and detach-one.

#### Bulk attach

```http theme={null}
POST https://karibu.briq.tz/v1/campaign/{campaign_id}/audiences
```

| Field       | Type           | Required | Notes                                                            |
| ----------- | -------------- | -------- | ---------------------------------------------------------------- |
| `group_ids` | array\<string> | yes      | Non-empty. Each id must reference a group in the same workspace. |

The response is the **full set** of currently attached groups (including ones that were already attached before this call). Duplicates in `group_ids` are silently kept rather than rejected — re-attaching is a no-op.

<Note>
  **Same workspace only.** All groups in `group_ids` must live in the same workspace as the campaign. Cross-workspace ids return 404, not 403, to avoid leaking which ids exist elsewhere.
</Note>

<Note>
  **Recipients at launch are the union of all attached groups.** If a contact appears in two attached groups it still only receives one message — deduplication happens at launch time, keyed by phone number per channel.
</Note>

<Warning>
  **Attaching audiences to a launched campaign undoes its launch.** If the campaign is in `ready` or `scheduled`, attaching new audiences drops it back to `configured`. You must re-validate (and re-launch) before it will send. See [Launch control](/guides/campaigns-launch-control).
</Warning>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/audiences \
    -H "X-API-Key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "group_ids": ["group_1", "group_2"]
    }'
  ```

  ```python Python theme={null}
  resp = requests.post(
      f"{BASE_URL}/v1/campaign/{campaign_id}/audiences",
      headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
      json={"group_ids": ["group_1", "group_2"]},
      timeout=15,
  )
  print(resp.status_code, resp.json())
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(
    `${BASE_URL}/v1/campaign/${campaignId}/audiences`,
    {
      method: "POST",
      headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({ group_ids: ["group_1", "group_2"] }),
    },
  );
  console.log(resp.status, await resp.json());
  ```
</CodeGroup>

| HTTP | Cause                                                                           |
| ---- | ------------------------------------------------------------------------------- |
| 400  | `group_ids` missing or empty.                                                   |
| 401  | Missing or invalid `X-API-Key`.                                                 |
| 403  | Campaign or one of the groups is outside the API key's scope.                   |
| 404  | Campaign not found, or one of the `group_ids` does not exist in this workspace. |
| 500  | Server error. Safe to retry.                                                    |

#### List attached audiences

```http theme={null}
GET https://karibu.briq.tz/v1/campaign/{campaign_id}/audiences
```

Returns an array of attached groups. When nothing is attached the response is `data: []` with `200 OK` — not a 404.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/audiences \
    -H "X-API-Key: YOUR_API_KEY"
  ```

  ```python Python theme={null}
  resp = requests.get(
      f"{BASE_URL}/v1/campaign/{campaign_id}/audiences",
      headers={"X-API-Key": API_KEY},
      timeout=10,
  )
  print(resp.json())
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(
    `${BASE_URL}/v1/campaign/${campaignId}/audiences`,
    { headers: { "X-API-Key": API_KEY } },
  );
  console.log(await resp.json());
  ```
</CodeGroup>

#### Detach a single group

```http theme={null}
DELETE https://karibu.briq.tz/v1/campaign/{campaign_id}/audiences/{group_id}
```

Detach one group. Returns `200 OK` on success. If the group was never attached (or has already been detached), the response is `404`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X DELETE \
    https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/audiences/GROUP_ID \
    -H "X-API-Key: YOUR_API_KEY"
  ```

  ```python Python theme={null}
  resp = requests.delete(
      f"{BASE_URL}/v1/campaign/{campaign_id}/audiences/{group_id}",
      headers={"X-API-Key": API_KEY},
      timeout=10,
  )
  print(resp.status_code)
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(
    `${BASE_URL}/v1/campaign/${campaignId}/audiences/${groupId}`,
    { method: "DELETE", headers: { "X-API-Key": API_KEY } },
  );
  console.log(resp.status);
  ```
</CodeGroup>

***

## 3. Set the message content

Every campaign needs at least one **content row** — the actual message that will be delivered. Content comes in three flavours: a reusable **Template**, a one-off **Raw** body, or a channel-specific **Rich** payload.

### In the dashboard

1. Open the campaign and switch to the **Content** tab.
2. Pick the content type your channel supports (Template, Raw, or Rich).
3. Fill the form — the dashboard validates as you type (template variables, sender id, required rich fields).
4. Save. You can edit or replace the content later as long as the campaign hasn't launched.

### Developer API

```http theme={null}
POST https://karibu.briq.tz/v1/campaign/{campaign_id}/content
```

The payload shape depends on `content_type`. Pick a tab below for the exact body and code samples.

<Warning>
  **One content row per `content_type` per campaign.** Posting a second `template` (or second `raw`, or second `rich`) on the same campaign returns `409 CONFLICT`. To change an existing row, `PATCH` it — don't POST again.
</Warning>

<Tabs>
  <Tab title="Template">
    Reuse a saved message template and substitute placeholders at send time. Variables are validated against the template's placeholder set — missing or unknown keys return `400`.

    **Payload fields:**

    | Field                 | Type   | Required | Notes                                                                        |
    | --------------------- | ------ | -------- | ---------------------------------------------------------------------------- |
    | `content_type`        | string | yes      | Fixed value `"template"`.                                                    |
    | `payload.template_id` | uuid   | yes      | Must reference a template owned by the workspace.                            |
    | `payload.variables`   | object | yes      | Key/value map. Keys must exactly match the template's declared placeholders. |

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/content \
        -H "X-API-Key: YOUR_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "content_type": "template",
          "payload": {
            "template_id": "tpl_abc123",
            "variables": { "product": "Karibu", "price": "$9" }
          }
        }'
      ```

      ```python Python theme={null}
      resp = requests.post(
          f"{BASE_URL}/v1/campaign/{campaign_id}/content",
          headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
          json={
              "content_type": "template",
              "payload": {
                  "template_id": "tpl_abc123",
                  "variables": {"product": "Karibu", "price": "$9"},
              },
          },
          timeout=15,
      )
      print(resp.json())
      ```

      ```javascript Node.js theme={null}
      const resp = await fetch(
        `${BASE_URL}/v1/campaign/${campaignId}/content`,
        {
          method: "POST",
          headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
          body: JSON.stringify({
            content_type: "template",
            payload: {
              template_id: "tpl_abc123",
              variables: { product: "Karibu", price: "$9" },
            },
          }),
        },
      );
      console.log(await resp.json());
      ```
    </CodeGroup>
  </Tab>

  <Tab title="Raw">
    A one-off message body. Best for ad-hoc SMS where you don't need template approval and just want to send a short string.

    **Payload fields:**

    | Field               | Type   | Required | Notes                                                                                      |
    | ------------------- | ------ | -------- | ------------------------------------------------------------------------------------------ |
    | `content_type`      | string | yes      | Fixed value `"raw"`.                                                                       |
    | `payload.body`      | string | yes      | Non-empty. The full message text. Channel length limits apply (160 chars per SMS segment). |
    | `payload.sender_id` | string | yes      | Sender id shown to recipients. Must be approved for this workspace.                        |

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/content \
        -H "X-API-Key: YOUR_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "content_type": "raw",
          "payload": {
            "body": "Sale starts tomorrow at 9am. Reply STOP to opt out.",
            "sender_id": "BRIQ"
          }
        }'
      ```

      ```python Python theme={null}
      resp = requests.post(
          f"{BASE_URL}/v1/campaign/{campaign_id}/content",
          headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
          json={
              "content_type": "raw",
              "payload": {
                  "body": "Sale starts tomorrow at 9am. Reply STOP to opt out.",
                  "sender_id": "BRIQ",
              },
          },
          timeout=15,
      )
      print(resp.json())
      ```

      ```javascript Node.js theme={null}
      const resp = await fetch(
        `${BASE_URL}/v1/campaign/${campaignId}/content`,
        {
          method: "POST",
          headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
          body: JSON.stringify({
            content_type: "raw",
            payload: {
              body: "Sale starts tomorrow at 9am. Reply STOP to opt out.",
              sender_id: "BRIQ",
            },
          }),
        },
      );
      console.log(await resp.json());
      ```
    </CodeGroup>
  </Tab>

  <Tab title="Rich">
    Channel-specific structured content. The exact field set depends on the channel — the example below is for email, with subject, HTML body, plain-text fallback, and attachments.

    **Payload fields (email example):**

    | Field                 | Type           | Required | Notes                                                   |
    | --------------------- | -------------- | -------- | ------------------------------------------------------- |
    | `content_type`        | string         | yes      | Fixed value `"rich"`.                                   |
    | `payload.subject`     | string         | yes      | Email subject line.                                     |
    | `payload.html`        | string         | yes      | HTML body.                                              |
    | `payload.text`        | string         | no       | Plain-text fallback for clients that don't render HTML. |
    | `payload.attachments` | array\<object> | no       | Each item: `{ "filename": "...", "url": "..." }`.       |

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/content \
        -H "X-API-Key: YOUR_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "content_type": "rich",
          "payload": {
            "subject": "Your May update",
            "html": "<h1>Hello</h1><p>Whats new this month...</p>",
            "text": "Hello. Whats new this month...",
            "attachments": [
              { "filename": "changelog.pdf", "url": "https://cdn.example.com/changelog.pdf" }
            ]
          }
        }'
      ```

      ```python Python theme={null}
      resp = requests.post(
          f"{BASE_URL}/v1/campaign/{campaign_id}/content",
          headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
          json={
              "content_type": "rich",
              "payload": {
                  "subject": "Your May update",
                  "html": "<h1>Hello</h1><p>Whats new this month...</p>",
                  "text": "Hello. Whats new this month...",
                  "attachments": [
                      {"filename": "changelog.pdf", "url": "https://cdn.example.com/changelog.pdf"}
                  ],
              },
          },
          timeout=15,
      )
      print(resp.json())
      ```

      ```javascript Node.js theme={null}
      const resp = await fetch(
        `${BASE_URL}/v1/campaign/${campaignId}/content`,
        {
          method: "POST",
          headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
          body: JSON.stringify({
            content_type: "rich",
            payload: {
              subject: "Your May update",
              html: "<h1>Hello</h1><p>Whats new this month...</p>",
              text: "Hello. Whats new this month...",
              attachments: [
                { filename: "changelog.pdf", url: "https://cdn.example.com/changelog.pdf" },
              ],
            },
          }),
        },
      );
      console.log(await resp.json());
      ```
    </CodeGroup>
  </Tab>
</Tabs>

#### POST content errors

| HTTP | Cause                                                                                          |
| ---- | ---------------------------------------------------------------------------------------------- |
| 400  | Invalid payload: empty `body`, missing `template_id`, unknown/missing template variables, etc. |
| 401  | Missing or invalid `X-API-Key`.                                                                |
| 403  | Campaign or referenced template is outside the API key's scope.                                |
| 404  | Campaign not found, or `template_id` not found.                                                |
| 409  | A content row of this `content_type` already exists on the campaign — `PATCH` it instead.      |
| 500  | Server error. Safe to retry.                                                                   |

#### Update content (PATCH)

```http theme={null}
PATCH https://karibu.briq.tz/v1/campaign/{campaign_id}/content/{content_id}
```

PATCH does a **shallow top-level merge** of `payload`. Keys you include overwrite the previous values; keys you omit are left alone. Nested objects (like `variables`) are **replaced wholesale** rather than merged recursively. `content_type` is immutable — to switch from `raw` to `template`, delete the row and POST a new one.

<Warning>
  **`PATCH` does not deep-merge.** Sending `{"payload": {"variables": {"name": "Alice"}}}` replaces the entire `variables` object. Any keys you had before (e.g. `product`, `price`) are gone. To preserve them, send the full object back.
</Warning>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PATCH \
    https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/content/CONTENT_ID \
    -H "X-API-Key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "payload": {
        "variables": { "product": "Karibu", "price": "$12" }
      }
    }'
  ```

  ```python Python theme={null}
  resp = requests.patch(
      f"{BASE_URL}/v1/campaign/{campaign_id}/content/{content_id}",
      headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
      json={"payload": {"variables": {"product": "Karibu", "price": "$12"}}},
      timeout=15,
  )
  print(resp.json())
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(
    `${BASE_URL}/v1/campaign/${campaignId}/content/${contentId}`,
    {
      method: "PATCH",
      headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({
        payload: { variables: { product: "Karibu", price: "$12" } },
      }),
    },
  );
  console.log(await resp.json());
  ```
</CodeGroup>

#### Delete content

```http theme={null}
DELETE https://karibu.briq.tz/v1/campaign/{campaign_id}/content/{content_id}
```

Only allowed while the campaign is in a pre-launch status: `draft`, `configured`, `validated`, or `ready`. Deleting in `scheduled`, `running`, or any post-launch state returns `409`.

Deleting the **last** content row on a campaign drops it back to `draft` — the campaign can no longer be validated or launched until new content is attached.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X DELETE \
    https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/content/CONTENT_ID \
    -H "X-API-Key: YOUR_API_KEY"
  ```

  ```python Python theme={null}
  resp = requests.delete(
      f"{BASE_URL}/v1/campaign/{campaign_id}/content/{content_id}",
      headers={"X-API-Key": API_KEY},
      timeout=10,
  )
  print(resp.status_code)
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(
    `${BASE_URL}/v1/campaign/${campaignId}/content/${contentId}`,
    { method: "DELETE", headers: { "X-API-Key": API_KEY } },
  );
  console.log(resp.status);
  ```
</CodeGroup>

***

## 4. Schedule a run

A **run** is when the campaign actually sends. A campaign can have many runs — a single send tonight, a daily recurring run for a month, or both. Until a run exists the campaign has nothing to do at launch time.

### In the dashboard

1. Open the campaign and switch to the **Schedule** tab.
2. Pick **Single send** or **Recurring**.
3. Set the date, time, and timezone. For recurring runs also set the frequency and an end date.
4. Save. The run appears in the runs list at status `ready`.

### Developer API

```http theme={null}
POST https://karibu.briq.tz/v1/campaign/{campaign_id}/runs
```

The body shape depends on `run_type`. Pick a tab below.

<Note>
  **New runs start at `status: "ready"`, not `scheduled`.** They only move to `scheduled` after you `launch` the campaign. A campaign with audiences, content, and a `ready` run **will not send anything** until you call launch — see [Launch control](/guides/campaigns-launch-control).
</Note>

<Tabs>
  <Tab title="Single run">
    One-off send at a specific moment.

    **Body fields:**

    | Field          | Type     | Required | Default | Notes                                                                           |
    | -------------- | -------- | -------- | ------- | ------------------------------------------------------------------------------- |
    | `run_type`     | string   | yes      | —       | Fixed value `"single"`.                                                         |
    | `scheduled_at` | ISO-8601 | yes      | —       | Must be in the future. A 2-minute grace window is allowed for clock drift.      |
    | `timezone`     | string   | no       | `"UTC"` | IANA name (`Africa/Dar_es_Salaam`, etc.). Used for display and recurrence math. |

    Naive datetimes (no `Z` and no `+HH:MM`) are interpreted as UTC. Always send an offset to avoid surprises.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/runs \
        -H "X-API-Key: YOUR_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "run_type": "single",
          "scheduled_at": "2026-05-20T09:00:00+03:00",
          "timezone": "Africa/Dar_es_Salaam"
        }'
      ```

      ```python Python theme={null}
      resp = requests.post(
          f"{BASE_URL}/v1/campaign/{campaign_id}/runs",
          headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
          json={
              "run_type": "single",
              "scheduled_at": "2026-05-20T09:00:00+03:00",
              "timezone": "Africa/Dar_es_Salaam",
          },
          timeout=15,
      )
      print(resp.json())
      ```

      ```javascript Node.js theme={null}
      const resp = await fetch(
        `${BASE_URL}/v1/campaign/${campaignId}/runs`,
        {
          method: "POST",
          headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
          body: JSON.stringify({
            run_type: "single",
            scheduled_at: "2026-05-20T09:00:00+03:00",
            timezone: "Africa/Dar_es_Salaam",
          }),
        },
      );
      console.log(await resp.json());
      ```
    </CodeGroup>
  </Tab>

  <Tab title="Recurring run">
    Repeat the send on a schedule until an end date.

    **Body fields:**

    | Field          | Type     | Required | Default | Notes                                                                             |
    | -------------- | -------- | -------- | ------- | --------------------------------------------------------------------------------- |
    | `run_type`     | string   | yes      | —       | Fixed value `"recurring"`.                                                        |
    | `scheduled_at` | ISO-8601 | yes      | —       | First occurrence. Must be in the future (2-minute grace).                         |
    | `timezone`     | string   | no       | `"UTC"` | IANA name. Used for occurrence math (e.g. "every day at 9am Dar es Salaam time"). |
    | `frequency`    | string   | yes      | —       | One of `daily`, `weekly`, `monthly`.                                              |
    | `end_date`     | ISO-8601 | yes      | —       | Must be strictly after `scheduled_at`.                                            |

    **Hard caps:** `end_date - scheduled_at` must be ≤ 365 days **and** the total number of materialised occurrences must be ≤ 366. Either cap rejects the request with `400`.

    <Warning>
      **The API enum exposes `once` and `hourly` too — do not use them.** They are accepted by the schema for forward-compatibility but are not supported on the materialised path and will fail at launch. Stick to `daily`, `weekly`, or `monthly`.
    </Warning>

    <CodeGroup>
      ```bash cURL theme={null}
      curl -X POST https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/runs \
        -H "X-API-Key: YOUR_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "run_type": "recurring",
          "scheduled_at": "2026-05-20T09:00:00+03:00",
          "timezone": "Africa/Dar_es_Salaam",
          "frequency": "daily",
          "end_date": "2026-06-20T09:00:00+03:00"
        }'
      ```

      ```python Python theme={null}
      resp = requests.post(
          f"{BASE_URL}/v1/campaign/{campaign_id}/runs",
          headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
          json={
              "run_type": "recurring",
              "scheduled_at": "2026-05-20T09:00:00+03:00",
              "timezone": "Africa/Dar_es_Salaam",
              "frequency": "daily",
              "end_date": "2026-06-20T09:00:00+03:00",
          },
          timeout=15,
      )
      print(resp.json())
      ```

      ```javascript Node.js theme={null}
      const resp = await fetch(
        `${BASE_URL}/v1/campaign/${campaignId}/runs`,
        {
          method: "POST",
          headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
          body: JSON.stringify({
            run_type: "recurring",
            scheduled_at: "2026-05-20T09:00:00+03:00",
            timezone: "Africa/Dar_es_Salaam",
            frequency: "daily",
            end_date: "2026-06-20T09:00:00+03:00",
          }),
        },
      );
      console.log(await resp.json());
      ```
    </CodeGroup>
  </Tab>
</Tabs>

#### Response shape

The response is **always a list**, even when you created a single run. For `recurring`, the returned row is the **template** — its `recurrence_index` is `null` and it represents the recurrence definition, not a concrete occurrence. Children (with `recurrence_index` 0, 1, 2, ...) are materialised at launch time, not at schedule time, so you will see exactly one row per call until the campaign launches.

```json theme={null}
{
  "success": true,
  "message": "Run scheduled.",
  "data": [
    {
      "run_id": "run_9d8c5b1a",
      "campaign_id": "9d8c5b1a-2f3e-4a7b-8c9d-0e1f2a3b4c5d",
      "run_type": "recurring",
      "frequency": "daily",
      "scheduled_at": "2026-05-20T06:00:00.000Z",
      "end_date": "2026-06-20T06:00:00.000Z",
      "timezone": "Africa/Dar_es_Salaam",
      "recurrence_group_id": "rg_2a3b4c5d",
      "recurrence_index": null,
      "status": "ready"
    }
  ],
  "status_code": 201
}
```

#### Schedule errors

| HTTP | Cause                                                                                                                                                                     |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `scheduled_at` in the past (outside the 2-minute grace), `frequency` missing/invalid, `end_date` missing/before `scheduled_at`, or over the 365-day / 366-occurrence cap. |
| 401  | Missing or invalid `X-API-Key`.                                                                                                                                           |
| 403  | Campaign is outside the API key's scope.                                                                                                                                  |
| 404  | Campaign not found.                                                                                                                                                       |
| 409  | Campaign is in a non-mutable status (e.g. `running`, `completed`, `cancelled`).                                                                                           |
| 500  | Server error. Safe to retry.                                                                                                                                              |

#### List runs

```http theme={null}
GET https://karibu.briq.tz/v1/campaign/{campaign_id}/runs
```

Returns all runs for a campaign, ordered by `scheduled_at ASC`. Supports filtering:

| Query param           | Repeatable | Notes                                                                                               |
| --------------------- | ---------- | --------------------------------------------------------------------------------------------------- |
| `status`              | yes        | Filter by run status. Repeating the param ORs the values — e.g. `?status=scheduled&status=running`. |
| `recurrence_group_id` | no         | Return only the rows that belong to a specific recurrence group (template + its children).          |

<CodeGroup>
  ```bash cURL theme={null}
  curl -G https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/runs \
    -H "X-API-Key: YOUR_API_KEY" \
    --data-urlencode "status=scheduled" \
    --data-urlencode "status=running"
  ```

  ```python Python theme={null}
  resp = requests.get(
      f"{BASE_URL}/v1/campaign/{campaign_id}/runs",
      headers={"X-API-Key": API_KEY},
      params=[("status", "scheduled"), ("status", "running")],
      timeout=10,
  )
  print(resp.json())
  ```

  ```javascript Node.js theme={null}
  const params = new URLSearchParams();
  params.append("status", "scheduled");
  params.append("status", "running");
  const resp = await fetch(
    `${BASE_URL}/v1/campaign/${campaignId}/runs?${params}`,
    { headers: { "X-API-Key": API_KEY } },
  );
  console.log(await resp.json());
  ```
</CodeGroup>

#### Delete a run

```http theme={null}
DELETE https://karibu.briq.tz/v1/campaign/{campaign_id}/runs/{run_id}
```

Only `ready` and `scheduled` runs are deletable. Any other status (`running`, `completed`, `cancelled`, `failed`) returns `409`. To stop a `running` run, use the launch-control transitions in [Launch control](/guides/campaigns-launch-control), not this endpoint.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X DELETE \
    https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/runs/RUN_ID \
    -H "X-API-Key: YOUR_API_KEY"
  ```

  ```python Python theme={null}
  resp = requests.delete(
      f"{BASE_URL}/v1/campaign/{campaign_id}/runs/{run_id}",
      headers={"X-API-Key": API_KEY},
      timeout=10,
  )
  print(resp.status_code)
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(
    `${BASE_URL}/v1/campaign/${campaignId}/runs/${runId}`,
    { method: "DELETE", headers: { "X-API-Key": API_KEY } },
  );
  console.log(resp.status);
  ```
</CodeGroup>

***

## Common gotchas

* **One content per channel.** POSTing a duplicate `content_type` returns 409 — `PATCH` the existing row instead of trying to add another.
* **Editing a launched campaign undoes its launch state.** Adding audiences, editing content, or adding a run on a `ready` or `scheduled` campaign drops it back to `configured`. You must re-validate (and re-launch) before it will send.
* **PATCH content does a shallow merge.** Top-level keys merge; nested objects (like `variables`) are replaced wholesale. To preserve fields, send the full object back.
* **`run_type: "recurring"` returns a template row, not a concrete occurrence.** Children with `recurrence_index` 0, 1, 2, ... are materialised at launch time. Until then you see exactly one row per call.
* **`scheduled_at` is parsed as UTC if naive.** Always send an ISO-8601 datetime with offset (`Z` or `+HH:MM`) — otherwise a "9am" you meant locally will fire at "9am UTC".

***

## Next

Now that the campaign has audiences, content, and at least one run, it's ready to be validated and launched. Continue with [Launch control](/guides/campaigns-launch-control).
