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

# Monitoring a campaign

> Watch a running campaign's progress and read its final analytics — via the Briq dashboard or the developer API endpoints.

Once a campaign launches, you can watch its progress in real time and inspect the final delivery breakdown when it completes. The Briq dashboard shows all of this on the campaign view — counters, per-batch status, recipient list, delivery rate. The developer API exposes the same data through three read-only endpoints.

* [`GET /v1/campaign/{id}/status`](#1-live-status--for-polling-loops) — latest run's progress, polled while live.
* [`GET /v1/campaign/{id}/analytics`](#2-aggregate-analytics) — campaign-wide aggregate rollup.
* [`GET /v1/campaign/{id}/runs/{run_id}/snapshot`](#run-snapshot-read-only-pii-gated) — frozen execution snapshot (content, sender, channel, cost, recipients).

<Note>
  **Status translation.** Every run-status field in the responses below translates the engine's internal `claimed` sentinel to `running`. As an API consumer you will never see `claimed` — treat `running` as the in-flight state.
</Note>

***

## 1. Live status — for polling loops

**Dashboard.** The campaign view updates automatically as batches complete. You see the current batch / total batches and live counters for sent, failed, skipped, and delivered.

**Developer API.**

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

* Returns the **latest run's** execution status plus batch progress counters.
* Mirrors the user-facing `/campaigns/{id}/status` endpoint shape — the same fields whether you call the developer or the user surface.
* This is the endpoint to poll while a campaign is active.

### Success response (run exists)

```json theme={null}
{
  "success": true,
  "data": {
    "campaign_id": "8d2e1f6a-...",
    "campaign_run_id": "run_789",
    "status": "running",
    "current_batch": 1,
    "total_batches": 2,
    "recipient_count": 1500,
    "sent": 1000,
    "failed": 5,
    "skipped": 0,
    "delivered": 800,
    "updated_at": "2026-05-12T09:14:02.218Z"
  },
  "errors": null,
  "request_id": "req_01J..."
}
```

### Response when no run yet

```json theme={null}
{
  "success": true,
  "data": {
    "campaign_id": "8d2e1f6a-...",
    "status": "NO_RUN"
  },
  "errors": null,
  "request_id": "req_01J..."
}
```

<Note>
  **`NO_RUN` is a sentinel, not a run status.** When the campaign has zero runs, the endpoint returns the literal string `"NO_RUN"` in `status`. This value is **not** part of the normal run-status enum (`scheduled`, `running`, `completed`, `failed`, `cancelled`). Check for `status === "NO_RUN"` before treating `status` as a run state.
</Note>

### Field reference

| Field             | Type              | Notes                                                                           |
| ----------------- | ----------------- | ------------------------------------------------------------------------------- |
| `campaign_id`     | string (uuid)     | The campaign you queried.                                                       |
| `campaign_run_id` | string \| null    | Latest run's id. Absent / null when `status === "NO_RUN"`.                      |
| `status`          | string            | One of `scheduled`, `running`, `completed`, `failed`, `cancelled`, or `NO_RUN`. |
| `current_batch`   | int               | Count of **completed** batches, not the batch currently in flight.              |
| `total_batches`   | int               | Total batch count for the latest run.                                           |
| `recipient_count` | int               | Recipients on this run.                                                         |
| `sent`            | int               | Per-recipient submit successes recorded so far.                                 |
| `failed`          | int               | Per-recipient submit failures recorded so far.                                  |
| `skipped`         | int               | Recipients skipped (e.g. invalid number, dedupe).                               |
| `delivered`       | int               | Delivery-report confirmations recorded so far (lags `sent`).                    |
| `updated_at`      | string (ISO 8601) | Last time the counters or status were touched.                                  |

### Errors

| HTTP | When                                                         |
| ---- | ------------------------------------------------------------ |
| 401  | Missing or invalid `X-API-Key`.                              |
| 403  | API key is valid but does not own this campaign's workspace. |
| 404  | `campaign_id` does not exist.                                |
| 500  | Server error — safe to retry with backoff.                   |

### Code samples

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

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

  BASE_URL = "https://karibu.briq.tz"
  API_KEY  = "YOUR_API_KEY"
  CAMPAIGN_ID = "8d2e1f6a-..."

  resp = requests.get(
      f"{BASE_URL}/v1/campaign/{CAMPAIGN_ID}/status",
      headers={"X-API-Key": API_KEY},
      timeout=10,
  )
  print(resp.json())
  ```

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

  const resp = await fetch(`${BASE_URL}/v1/campaign/${CAMPAIGN_ID}/status`, {
    headers: { "X-API-Key": API_KEY },
  });
  console.log(await resp.json());
  ```
</CodeGroup>

### Polling cadence

<Note>
  **A reasonable polling rhythm.**

  * **5–15 seconds** is fine while `status` is `scheduled` or `running`.
  * **Stop polling** as soon as `status` flips to `completed`, `failed`, or `cancelled` — counters are terminal at that point.
  * Then call [`/analytics`](#2-aggregate-analytics) **once** to grab the final rollup. Do not keep polling `/status` after the terminal transition.
</Note>

***

## 2. Aggregate analytics

**Dashboard.** The campaign's "Analytics" tab shows total recipients, sent / delivered / failed / skipped counts, plus delivery and failure rates as percentages.

**Developer API.**

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

* Returns campaign-wide aggregate analytics across all runs (delivery rate, failure rate, counts, timing).
* When no analytics row has been written yet (e.g. a campaign created but never run, or polled in the first seconds after launch), the endpoint returns a **zeroed-out payload** with `null` rate fields. **HTTP status is still 200** — check `delivery_rate !== null` to know whether percentages are renderable.

### Success response (populated)

```json theme={null}
{
  "success": true,
  "data": {
    "campaign_id": "8d2e1f6a-...",
    "campaign_run_id": "run_789",
    "workspace_id": "ws_01J...",
    "total_recipients": 1500,
    "sent_count": 1495,
    "failed_count": 5,
    "skipped_count": 0,
    "cancelled_count": 0,
    "delivered_count": 1400,
    "failed_delivery_count": 95,
    "pending_delivery_count": 0,
    "delivery_rate": 0.9398,
    "failure_rate": 0.0033,
    "first_sent_at": "2026-05-12T09:12:01.001Z",
    "last_sent_at":  "2026-05-12T09:14:48.812Z",
    "completed_at":  "2026-05-12T09:14:59.044Z"
  },
  "errors": null,
  "request_id": "req_01J..."
}
```

### Field reference

| Field                    | Type           | Notes                                                                             |
| ------------------------ | -------------- | --------------------------------------------------------------------------------- |
| `campaign_id`            | string (uuid)  | The campaign you queried.                                                         |
| `campaign_run_id`        | string \| null | Latest run referenced by the rollup, or `null` before any run completes.          |
| `workspace_id`           | string         | Workspace that owns the campaign.                                                 |
| `total_recipients`       | int            | Aggregate recipient count across runs.                                            |
| `sent_count`             | int            | Submit successes.                                                                 |
| `failed_count`           | int            | Submit failures.                                                                  |
| `skipped_count`          | int            | Recipients skipped before submit.                                                 |
| `cancelled_count`        | int            | Recipients on cancelled runs.                                                     |
| `delivered_count`        | int            | DLR-confirmed deliveries.                                                         |
| `failed_delivery_count`  | int            | DLR-confirmed delivery failures.                                                  |
| `pending_delivery_count` | int            | Sent but no terminal DLR yet.                                                     |
| `delivery_rate`          | float \| null  | `delivered_count / sent_count` as a **0.0–1.0 float**. `null` before any data.    |
| `failure_rate`           | float \| null  | `failed_count / total_recipients` as a **0.0–1.0 float**. `null` before any data. |
| `first_sent_at`          | string \| null | ISO 8601 UTC. First successful submit across all runs.                            |
| `last_sent_at`           | string \| null | ISO 8601 UTC. Last successful submit across all runs.                             |
| `completed_at`           | string \| null | ISO 8601 UTC. When the latest run reached a terminal state.                       |

<Warning>
  **Rates are 0.0–1.0 floats — multiply by 100 for display.** A `delivery_rate` of `0.9398` is **93.98%**, not 0.94%. Never display the raw value as a percentage. Always check for `null` before rendering.
</Warning>

<Note>
  **Analytics rows are written after the first batch completes.** There is a small delay (seconds) between a campaign starting and analytics being non-zero. Do not hit `/analytics` immediately after `/launch` and expect populated counters — poll `/status` until it reports activity, or wait for the terminal transition.
</Note>

### Errors

| HTTP | When                                            |
| ---- | ----------------------------------------------- |
| 401  | Missing or invalid `X-API-Key`.                 |
| 403  | API key does not own this campaign's workspace. |
| 404  | `campaign_id` does not exist.                   |
| 500  | Server error — safe to retry with backoff.      |

### Code samples

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

  ```python Python theme={null}
  resp = requests.get(
      f"{BASE_URL}/v1/campaign/{CAMPAIGN_ID}/analytics",
      headers={"X-API-Key": API_KEY},
      timeout=10,
  )
  data = resp.json()["data"]
  if data["delivery_rate"] is not None:
      print(f"Delivery rate: {data['delivery_rate'] * 100:.2f}%")
  else:
      print("No analytics yet.")
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(`${BASE_URL}/v1/campaign/${CAMPAIGN_ID}/analytics`, {
    headers: { "X-API-Key": API_KEY },
  });
  const { data } = await resp.json();
  if (data.delivery_rate !== null) {
    console.log(`Delivery rate: ${(data.delivery_rate * 100).toFixed(2)}%`);
  } else {
    console.log("No analytics yet.");
  }
  ```
</CodeGroup>

***

## 3. Run detail — composite payload

**Dashboard.** Click any run in the campaign's "Runs" tab to open a detail modal — the run row, channel + provider + sender metadata, per-batch progress, and a per-recipient list with live message status.

**Developer API.**

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

Returns the composite "run detail" payload — a single round-trip that gives you everything for a run-detail UI:

| Field                  | Type    | What it contains                                                                                                  |
| ---------------------- | ------- | ----------------------------------------------------------------------------------------------------------------- |
| `run`                  | object  | The run row — translated `status`, counters, timestamps.                                                          |
| `campaign_name`        | string  | Display name of the parent campaign.                                                                              |
| `channel_name`         | string  | Display name of the channel used (e.g. `SMS`).                                                                    |
| `channel_provider`     | string  | Provider that fulfilled the run (e.g. `mock`, carrier name).                                                      |
| `snapshot`             | object  | Execution snapshot summary: `provider`, `sender_id`, `channel`, `template`, `personalized`, `frozen_at`.          |
| `batches[]`            | array   | Per-batch progress (see fields below).                                                                            |
| `recipients[]`         | array   | Joined recipient list with `recipient_status`, `message_id`, **`message_status`**, `sent_at`. Capped at 500 rows. |
| `recipient_summary`    | object  | `{ total, pending, sent, delivered, failed }` — always accurate even when `recipients[]` is truncated.            |
| `recipients_truncated` | boolean | `true` when the run has more than 500 recipients and `recipients[]` was clipped.                                  |

### Batch fields

| Field             | Type           | Notes                                        |
| ----------------- | -------------- | -------------------------------------------- |
| `id`              | string (uuid)  | Batch identifier.                            |
| `batch_sequence`  | int            | 1-indexed position within the run.           |
| `status`          | string         | `pending`, `running`, `completed`, `failed`. |
| `recipient_count` | int            | Recipients in this batch.                    |
| `sent_count`      | int            | Submit successes within the batch.           |
| `failed_count`    | int            | Submit failures within the batch.            |
| `started_at`      | string \| null | ISO 8601 UTC.                                |
| `completed_at`    | string \| null | ISO 8601 UTC.                                |
| `error_detail`    | object \| null | Populated when `status === "failed"`.        |

### Recipient fields

| Field              | Type           | Notes                                                                                         |
| ------------------ | -------------- | --------------------------------------------------------------------------------------------- |
| `recipient_id`     | string         | Per-run recipient row id.                                                                     |
| `phone_number`     | string         | E.164 digits.                                                                                 |
| `recipient_status` | string         | Per-run projection; **may lag** the live message state by seconds.                            |
| `message_id`       | string \| null | The dispatched message; `null` until submit lands.                                            |
| `message_status`   | string \| null | **Live status** from the `messages` table — the authoritative state of the message right now. |
| `sent_at`          | string \| null | ISO 8601 UTC.                                                                                 |

<Note>
  **`recipients[]` is capped at 500 rows per response.** When more exist, the API sets `recipients_truncated: true` and clips the array. **Use `recipient_summary` for the aggregate counts** — do not derive totals by counting `recipients[]`. If you need every recipient for a large run, page through the run via the dashboard or contact Briq for a bulk export.
</Note>

<Note>
  **`message_status` may diverge from `recipient_status`.** The message status is the live truth pulled from the `messages` table; `recipient_status` is a slightly stale projection rolled up onto the per-run recipient row. Prefer `message_status` when you display the current state of a single message.
</Note>

### Errors

| HTTP | When                                                                        |
| ---- | --------------------------------------------------------------------------- |
| 401  | Missing or invalid `X-API-Key`.                                             |
| 403  | API key does not own this campaign's workspace.                             |
| 404  | `campaign_id` does not exist, or `run_id` does not belong to that campaign. |
| 500  | Server error — safe to retry with backoff.                                  |

### Code samples

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

  ```python Python theme={null}
  resp = requests.get(
      f"{BASE_URL}/v1/campaign/{CAMPAIGN_ID}/runs/run_789",
      headers={"X-API-Key": API_KEY},
      timeout=15,
  )
  detail = resp.json()["data"]
  print("recipients shown:", len(detail["recipients"]))
  print("recipients total:", detail["recipient_summary"]["total"])
  if detail["recipients_truncated"]:
      print("(list was truncated — use recipient_summary for totals)")
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(
    `${BASE_URL}/v1/campaign/${CAMPAIGN_ID}/runs/run_789`,
    { headers: { "X-API-Key": API_KEY } },
  );
  const { data } = await resp.json();
  console.log("recipients shown:", data.recipients.length);
  console.log("recipients total:", data.recipient_summary.total);
  if (data.recipients_truncated) {
    console.log("(list was truncated — use recipient_summary for totals)");
  }
  ```
</CodeGroup>

### Run snapshot (read-only, PII-gated)

**Developer API.**

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

* Returns the engine's `execution_snapshot` — the **frozen, point-in-time state** used to dispatch this run: content, sender, channel, provider, cost, recipient count, and optionally the recipient list.
* Query param `?include_recipients=true` opts into returning the **recipient phone-number list**. Stripped by default.

<Warning>
  **`?include_recipients=true` returns PII.** The recipient list contains phone numbers. **Never call this variant from end-user-facing surfaces** (e.g. a customer-supportable web app, an unauthenticated dashboard view). Treat it as a server-to-server, admin-tier read. The caller is responsible for handling returned phone numbers under their own compliance regime.
</Warning>

<Warning>
  **`cost` is in service units, not currency.** `total_cost` is denominated in `currency_units` — `sms_parts`, voice minutes, etc. — matching the units convention used by `/validate`. **Do not display this value as money.** Convert to your billing currency client-side if you need to show a price.
</Warning>

<Note>
  **Snapshot may be partial before dispatch.** For a run still in `ready` status (queued but not yet picked up by the worker), the engine may not have frozen the full snapshot yet — some fields may be `null`. Re-read after the run transitions to `running` or later.
</Note>

### Success response (default, without recipients)

```json theme={null}
{
  "success": true,
  "data": {
    "run_id": "run_789",
    "campaign_id": "8d2e1f6a-...",
    "recipient_count": 1500,
    "content": {
      "channel": "SMS",
      "provider": "mock",
      "sender_id": "BRIQ",
      "body": "Hello, thanks for joining."
    },
    "cost": {
      "total_cost": 1500,
      "currency_units": "sms_parts"
    },
    "status": "running",
    "started_at": "2026-05-12T09:12:00.118Z",
    "completed_at": null
  },
  "errors": null,
  "request_id": "req_01J..."
}
```

### Success response (with `?include_recipients=true`)

The same payload, plus a `recipients` array of phone numbers (E.164 digits):

```json theme={null}
{
  "success": true,
  "data": {
    "run_id": "run_789",
    "campaign_id": "8d2e1f6a-...",
    "recipient_count": 1500,
    "content": { "...": "..." },
    "cost":    { "...": "..." },
    "recipients": ["255712345678", "255713000111", "..."],
    "status": "running",
    "started_at": "2026-05-12T09:12:00.118Z",
    "completed_at": null
  },
  "errors": null,
  "request_id": "req_01J..."
}
```

### Errors

| HTTP | When                                                                                                         |
| ---- | ------------------------------------------------------------------------------------------------------------ |
| 401  | Missing or invalid `X-API-Key`.                                                                              |
| 403  | API key does not own this campaign's workspace.                                                              |
| 404  | `campaign_id` does not exist, `run_id` does not belong to that campaign, or no snapshot has been frozen yet. |
| 500  | Server error — safe to retry with backoff.                                                                   |

### Code samples

<CodeGroup>
  ```bash cURL theme={null}
  # Default — no PII
  curl -X GET "https://karibu.briq.tz/v1/campaign/8d2e1f6a-.../runs/run_789/snapshot" \
    -H "X-API-Key: YOUR_API_KEY"

  # Opt in to the recipient list (server-side only)
  curl -X GET "https://karibu.briq.tz/v1/campaign/8d2e1f6a-.../runs/run_789/snapshot?include_recipients=true" \
    -H "X-API-Key: YOUR_API_KEY"
  ```

  ```python Python theme={null}
  # Default — no PII
  resp = requests.get(
      f"{BASE_URL}/v1/campaign/{CAMPAIGN_ID}/runs/run_789/snapshot",
      headers={"X-API-Key": API_KEY},
      timeout=10,
  )
  print(resp.json())

  # Opt in to the recipient list (server-side only)
  resp = requests.get(
      f"{BASE_URL}/v1/campaign/{CAMPAIGN_ID}/runs/run_789/snapshot",
      headers={"X-API-Key": API_KEY},
      params={"include_recipients": "true"},
      timeout=10,
  )
  print(resp.json())
  ```

  ```javascript Node.js theme={null}
  // Default — no PII
  let resp = await fetch(
    `${BASE_URL}/v1/campaign/${CAMPAIGN_ID}/runs/run_789/snapshot`,
    { headers: { "X-API-Key": API_KEY } },
  );
  console.log(await resp.json());

  // Opt in to the recipient list (server-side only)
  resp = await fetch(
    `${BASE_URL}/v1/campaign/${CAMPAIGN_ID}/runs/run_789/snapshot?include_recipients=true`,
    { headers: { "X-API-Key": API_KEY } },
  );
  console.log(await resp.json());
  ```
</CodeGroup>

***

## What happens after launch

A short narrative of the system behaviour that drives the numbers you see above. Useful if you are building a poller and want to understand the timing.

1. **Pickup (≤ 30 seconds).** A separate worker microservice runs a cron query of `campaign_runs WHERE status = 'scheduled' AND scheduled_at <= now()`. It claims the row by flipping its status to the internal `claimed` sentinel. The API translates `claimed` to `running` in every response, so you will only ever see `running`.
2. **Batches of 1000.** The engine partitions the run's recipients into batches of up to 1000 and writes a `campaign_batches` row per partition. Each batch is dispatched through the campaign's channel + provider; per-recipient rows land in `campaign_recipients` and `messages`.
3. **Counters roll up.** As batches complete, `sent_count`, `failed_count`, `skipped_count`, and `delivered_count` on the run row are incremented. The parent campaign's aggregate counters are recomputed off the run's totals — this is what populates the next `/status` and `/analytics` response.
4. **Terminal state and analytics.** When all batches finish, the run flips to `completed` (or `failed`). The engine then writes a `campaign_analytics` row — at this point `/analytics` returns its populated payload.
5. **Optional refund.** If a run failed partway through, or the campaign was cancelled, the engine refunds the unsent portion of the credit reservation. Synchronous on `/cancel`; reconciler-driven for other failure paths.

***

## Practical cadence

A 4-bullet recap of what to call when:

* **During `scheduled` / `running`:** poll [`/status`](#1-live-status--for-polling-loops) every 5–15 seconds.
* **On terminal transition** (`completed`, `failed`, or `cancelled`): stop polling, call [`/analytics`](#2-aggregate-analytics) **once** for the final rollup.
* **For a run-level UI** (drilldown modal, per-recipient list): call [`/runs/{run_id}`](#3-run-detail--composite-payload) for the composite payload. Use the snapshot endpoint only when you need the frozen dispatch state.
* **Prefer the dashboard for ad-hoc inspection.** Build a poller only when you need automation — e.g. piping data into your own analytics, alerting on failure rates, or driving a custom operator console.

***

## Related guides

* [Campaigns overview](/guides/campaigns) — concepts, lifecycle, and the full endpoint map.
* [Launch and control](/guides/campaigns-launch-control) — scheduling, cancelling, and the run lifecycle that this page reports on.
