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

# Launching and controlling a campaign

> Validate, launch, pause, resume, cancel, and retry campaigns — through the Briq dashboard or the developer API.

Once your campaign has audiences, content, and at least one run, you can validate it, launch it, and control its in-flight state. The dashboard exposes all of this through the campaign view's action buttons; the developer API exposes the same operations as a small set of POST endpoints.

This page covers:

* Validation (pre-flight check)
* Launch (go live)
* Runtime controls — pause, resume, cancel
* Retry (rerun a failed or cancelled run on the still-pending recipients)

***

## 1. Validate (pre-flight)

**Dashboard:** sign in at [https://briq.tz/login](https://briq.tz/login), open the campaign, and click **Validate** in the action bar. The UI shows green for a clean validation, or a list of issues to fix. The **Launch** button runs the same checks automatically, so most teams only invoke Validate explicitly while iterating on content or audience filters.

**Developer API:** `POST /v1/campaign/{campaign_id}/validate`

The endpoint is read-only — it does not mutate campaign state, so it is safe to call repeatedly (for example, from a "Looks good?" preview screen in your own admin tool).

<Warning>
  **Validation failures do NOT return an HTTP error.** This endpoint always returns `200 OK`. A failed validation is signalled by `data.valid: false` and a populated `data.issues[]` array. Branch on `body.data.valid`, never on the HTTP status.
</Warning>

### Success response (valid)

```json theme={null}
{
  "success": true,
  "data": {
    "valid": true,
    "issues": [],
    "estimated_cost": { "SMS": 1500 }
  },
  "errors": null,
  "request_id": "req_01HXXXXXXXXXXXXXXXXXXXXXXX"
}
```

### Response when invalid

Same envelope, with `data.valid: false` and human-readable strings in `data.issues[]`:

```json theme={null}
{
  "success": true,
  "data": {
    "valid": false,
    "issues": [
      "Audience is empty",
      "Run 'run_789' has no scheduled_at"
    ],
    "estimated_cost": {}
  },
  "errors": null,
  "request_id": "req_01HXXXXXXXXXXXXXXXXXXXXXXX"
}
```

<Note>
  **`estimated_cost` is in service units, not currency.** The map is keyed by channel and reports SMS parts, voice minutes, or push notifications — never a money amount. Do not render these numbers with a currency symbol. To show a monetary estimate, multiply by your account's per-unit rates client-side.
</Note>

### Errors

| Status | Code             | When                                |
| ------ | ---------------- | ----------------------------------- |
| 401    | `UNAUTHORIZED`   | Missing or invalid API key.         |
| 403    | `FORBIDDEN`      | API key does not own this campaign. |
| 404    | `NOT_FOUND`      | `campaign_id` does not exist.       |
| 500    | `INTERNAL_ERROR` | Unhandled server error.             |

No 400 or 409 originates from `/validate` itself — surface-level rejections (auth, scope, existence) only.

### Code samples

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

  ```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/camp_123/validate",
      headers={"X-API-Key": API_KEY},
      timeout=15,
  )
  body = resp.json()
  if body["data"]["valid"]:
      print("Ready to launch. Estimated units:", body["data"]["estimated_cost"])
  else:
      print("Fix these first:", body["data"]["issues"])
  ```

  ```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/camp_123/validate`, {
    method: "POST",
    headers: { "X-API-Key": API_KEY },
  });
  const body = await resp.json();
  if (body.data.valid) {
    console.log("Ready to launch. Estimated units:", body.data.estimated_cost);
  } else {
    console.log("Fix these first:", body.data.issues);
  }
  ```
</CodeGroup>

***

## 2. Launch

**Dashboard:** on the campaign view at [https://briq.tz/login](https://briq.tz/login), click **Launch**. The dashboard auto-validates the campaign, marks it ready, and triggers the earliest pending run. If validation fails, you see the same issue list as the Validate panel — fix the issues and click Launch again.

**Developer API:** `POST /v1/campaign/{campaign_id}/launch`

Launch is a composite operation: it validates the campaign, marks it `ready`, and triggers a run in one call.

### Request body

| Field    | Type   | Required | Notes                                                                |
| -------- | ------ | -------- | -------------------------------------------------------------------- |
| `run_id` | string | no       | Trigger a specific run. Omit to auto-pick the earliest eligible run. |

When `run_id` is omitted, the server selects the earliest run whose `status` is `"ready"`, ordered by `scheduled_at` ascending with nulls last.

### Success response

Carries the triggered run with `status: "scheduled"`. The parent campaign flips to `scheduled`.

```json theme={null}
{
  "success": true,
  "data": {
    "run_id": "run_789",
    "campaign_id": "camp_123",
    "status": "scheduled",
    "scheduled_at": "2026-05-12T14:00:00.000000",
    "recipient_count": 1500
  },
  "errors": null,
  "request_id": "req_01HXXXXXXXXXXXXXXXXXXXXXXX"
}
```

After launch returns, the worker's pickup cron claims the run on its next tick — typically within seconds — and begins dispatching in batches of 1000 recipients.

<Note>
  **Recurring template runs.** For a recurring template run (one where `recurrence_index` is `null`), launch materialises the child occurrences and retires the template. From that point on, each occurrence has its own `run_id` and lifecycle.
</Note>

### Errors

| Status | Code                | When                                                                                                        |
| ------ | ------------------- | ----------------------------------------------------------------------------------------------------------- |
| 400    | `VALIDATION_FAILED` | No `ready` run is available: `"No 'ready' run available for this campaign"`.                                |
| 401    | `UNAUTHORIZED`      | Missing or invalid API key.                                                                                 |
| 403    | `FORBIDDEN`         | API key does not own this campaign.                                                                         |
| 404    | `NOT_FOUND`         | `campaign_id` does not exist, or `run_id` does not belong to it.                                            |
| 409    | `CONFLICT`          | Validation failed at launch time. See special envelope below.                                               |
| 409    | `CONFLICT`          | The targeted run is already in `running`, `completed`, `failed`, or `cancelled` and cannot be re-triggered. |
| 500    | `INTERNAL_ERROR`    | Unhandled server error.                                                                                     |

<Warning>
  **Validation-failed `409` uses a special dual envelope.** Both `data` and `errors` are populated — the validation result lives in `data.issues[]`, and `errors[]` is just the protocol-level marker.

  ```json theme={null}
  {
    "success": false,
    "data": {
      "valid": false,
      "issues": ["Audience is empty"],
      "estimated_cost": {}
    },
    "errors": [
      { "code": "CONFLICT", "message": "Validation failed", "field": null }
    ],
    "request_id": "req_01HXXXXXXXXXXXXXXXXXXXXXXX"
  }
  ```

  Inspect `data.issues[]` — that's where each failure shows up. Do not parse `errors[0].message` for the user-facing reason.
</Warning>

### Code samples

<CodeGroup>
  ```bash cURL (auto-pick) theme={null}
  curl -X POST https://karibu.briq.tz/v1/campaign/camp_123/launch \
    -H "X-API-Key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{}'
  ```

  ```bash cURL (explicit run_id) theme={null}
  curl -X POST https://karibu.briq.tz/v1/campaign/camp_123/launch \
    -H "X-API-Key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "run_id": "run_789" }'
  ```

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

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

  # Auto-pick the earliest 'ready' run
  resp = requests.post(
      f"{BASE_URL}/v1/campaign/camp_123/launch",
      headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
      json={},
      timeout=15,
  )
  body = resp.json()

  if body["success"]:
      print("Launched run", body["data"]["run_id"])
  elif body.get("errors") and body["errors"][0]["code"] == "CONFLICT":
      # Validation-failed dual envelope
      print("Cannot launch:", body["data"]["issues"])
  else:
      print("Launch failed:", body)
  ```

  ```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/camp_123/launch`, {
    method: "POST",
    headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({}),  // or { run_id: "run_789" }
  });
  const body = await resp.json();

  if (body.success) {
    console.log("Launched run", body.data.run_id);
  } else if (body.errors?.[0]?.code === "CONFLICT") {
    console.log("Cannot launch:", body.data.issues);
  } else {
    console.log("Launch failed:", body);
  }
  ```
</CodeGroup>

***

## 3. Pause and resume

**Dashboard:** on the running campaign view at [https://briq.tz/login](https://briq.tz/login), use the **Pause** and **Resume** buttons. Pause halts in-flight runs after the current batch; Resume flips them back to scheduled and reschedules them for immediate pickup.

### Pause

`POST /v1/campaign/{campaign_id}/pause`

Stops in-flight runs mid-flight. Internally, the server sets a Redis flag and flips active runs — those in `running` or the internal `claimed` state — to `paused`. The endpoint is idempotent: calling pause on an already-paused campaign returns `200 OK`.

Runs that were `scheduled` or `ready` at pause time are **not** touched — they remain dormant on their own schedule and will pick up normally unless you also cancel.

<Note>
  **Pause is not instantaneous.** The worker finishes the current 1000-recipient batch before halting. After the pause call returns, you may still see a few hundred additional `sent_count` reach recipients. Do not rely on pause as a "stop sending now" guarantee — for that, use cancel.
</Note>

#### Success response

```json theme={null}
{
  "success": true,
  "data": {
    "campaign_id": "camp_123",
    "action": "paused",
    "status": "paused"
  },
  "errors": null,
  "request_id": "req_01HXXXXXXXXXXXXXXXXXXXXXXX"
}
```

#### Errors

| Status | Code             | When                                |
| ------ | ---------------- | ----------------------------------- |
| 401    | `UNAUTHORIZED`   | Missing or invalid API key.         |
| 403    | `FORBIDDEN`      | API key does not own this campaign. |
| 404    | `NOT_FOUND`      | `campaign_id` does not exist.       |
| 500    | `INTERNAL_ERROR` | Unhandled server error.             |

No 400 or 409 from this endpoint — pause is always accepted on a non-terminal campaign.

#### Code samples

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

  ```python Python theme={null}
  resp = requests.post(
      f"{BASE_URL}/v1/campaign/camp_123/pause",
      headers={"X-API-Key": API_KEY},
      timeout=15,
  )
  print(resp.json())
  ```

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

### Resume

`POST /v1/campaign/{campaign_id}/resume`

Clears the pause flag and flips paused runs back to `scheduled`. The endpoint is idempotent: calling resume on a campaign that was never paused is a no-op (the Redis pause key is deleted whether it existed or not).

<Warning>
  **Resume is "go-now" — the original `scheduled_at` is NOT preserved.** Resume rewrites the run's `scheduled_at` to the current time so the worker picks it up on the next tick.

  The rationale: a run paused at noon yesterday with a `scheduled_at` of noon yesterday would otherwise sit idle until noon **tomorrow**, because the worker only claims runs whose `scheduled_at` is in the past but within the pickup window. Go-now is the only safe resume behaviour.

  If you need to honour the original schedule, save it client-side before pausing and re-PATCH it after resume.
</Warning>

#### Success response

```json theme={null}
{
  "success": true,
  "data": {
    "campaign_id": "camp_123",
    "action": "resumed",
    "status": "scheduled"
  },
  "errors": null,
  "request_id": "req_01HXXXXXXXXXXXXXXXXXXXXXXX"
}
```

#### Errors

| Status | Code             | When                                                 |
| ------ | ---------------- | ---------------------------------------------------- |
| 401    | `UNAUTHORIZED`   | Missing or invalid API key.                          |
| 403    | `FORBIDDEN`      | API key does not own this campaign.                  |
| 404    | `NOT_FOUND`      | `campaign_id` does not exist.                        |
| 409    | `CONFLICT`       | Campaign is `cancelled` — sticky, cannot be resumed. |
| 500    | `INTERNAL_ERROR` | Unhandled server error.                              |

#### Code samples

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

  ```python Python theme={null}
  resp = requests.post(
      f"{BASE_URL}/v1/campaign/camp_123/resume",
      headers={"X-API-Key": API_KEY},
      timeout=15,
  )
  print(resp.json())
  ```

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

***

## 4. Cancel

**Dashboard:** on the campaign view at [https://briq.tz/login](https://briq.tz/login), click **Cancel campaign**. A confirmation dialog warns that cancellation is permanent before the action commits.

**Developer API:** `POST /v1/campaign/{campaign_id}/cancel`

<Warning>
  **Cancel is sticky — there is no way back.** Once a campaign is `cancelled`, none of `pause`, `resume`, `launch`, `retry`, or `PATCH` will revive it. To send the remaining recipients you must either retry the individual cancelled runs (see the next section) or create a new campaign.
</Warning>

### What happens internally

1. Sets the Redis cancel flag so the worker stops claiming new batches.
2. Flips every non-terminal run (`ready`, `scheduled`, `claimed`, `running`, `paused`) to `cancelled`.
3. Marks the parent campaign `cancelled`.
4. **Refunds unsent reservations synchronously.** Failures here are logged and retried by an out-of-band reconciler cron — the campaign stays `cancelled` either way.

Messages already dispatched before cancel cannot be unsent. Only the unsent reservation portion is refunded.

### Success response

```json theme={null}
{
  "success": true,
  "data": {
    "campaign_id": "camp_123",
    "action": "cancelled",
    "status": "cancelled",
    "runs_cancelled": 3
  },
  "errors": null,
  "request_id": "req_01HXXXXXXXXXXXXXXXXXXXXXXX"
}
```

### Errors

| Status | Code             | When                                |
| ------ | ---------------- | ----------------------------------- |
| 401    | `UNAUTHORIZED`   | Missing or invalid API key.         |
| 403    | `FORBIDDEN`      | API key does not own this campaign. |
| 404    | `NOT_FOUND`      | `campaign_id` does not exist.       |
| 500    | `INTERNAL_ERROR` | Unhandled server error.             |

### Code samples

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

  ```python Python theme={null}
  resp = requests.post(
      f"{BASE_URL}/v1/campaign/camp_123/cancel",
      headers={"X-API-Key": API_KEY},
      timeout=15,
  )
  body = resp.json()
  print(f"Cancelled {body['data']['runs_cancelled']} run(s)")
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(`${BASE_URL}/v1/campaign/camp_123/cancel`, {
    method: "POST",
    headers: { "X-API-Key": API_KEY },
  });
  const body = await resp.json();
  console.log(`Cancelled ${body.data.runs_cancelled} run(s)`);
  ```
</CodeGroup>

***

## 5. Retry a failed or cancelled run

**Dashboard:** at [https://briq.tz/login](https://briq.tz/login), open the run's detail view (any run in `failed` or `cancelled` state) and click **Retry run**. The dashboard skips recipients whose messages already reached `SENT` or `DELIVERED` and reruns the rest.

**Developer API:** `POST /v1/campaign/{campaign_id}/runs/{run_id}/retry`

Retry creates a **new run row with a new id**, the same content, and a filtered recipient list. The new run is created in `scheduled` state with `scheduled_at = now()`, so the worker's next pickup tick claims it.

<Note>
  **The returned `run_id` is new — not the original.** Keep both for audit. The new run's snapshot includes a `retry_of_run_id` field pointing back at the source run.
</Note>

### Filter mechanism

The retry recipient list is built by joining `campaign_recipients.provider_message_id` to `messages.message_id`. Any recipient whose latest message status is `SENT` or `DELIVERED` is **excluded** from the retry. Everything else — `PENDING`, `FAILED`, never-dispatched — is included.

### Success response

Same shape as `/launch`:

```json theme={null}
{
  "success": true,
  "data": {
    "run_id": "run_790",
    "campaign_id": "camp_123",
    "status": "scheduled",
    "scheduled_at": "2026-05-12T14:32:11.000000",
    "retry_of_run_id": "run_789",
    "recipient_count": 412
  },
  "errors": null,
  "request_id": "req_01HXXXXXXXXXXXXXXXXXXXXXXX"
}
```

### Errors

| Status | Code                | When                                                                                                                |
| ------ | ------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 400    | `VALIDATION_FAILED` | Source run is not `failed` or `cancelled`: `"Only FAILED or CANCELLED runs can be retried. Current status: '<x>'"`. |
| 400    | `VALIDATION_FAILED` | Every recipient on the source run was already `SENT` or `DELIVERED` — nothing left to retry.                        |
| 401    | `UNAUTHORIZED`      | Missing or invalid API key.                                                                                         |
| 403    | `FORBIDDEN`         | API key does not own this campaign.                                                                                 |
| 404    | `NOT_FOUND`         | `campaign_id` does not exist, or `run_id` does not belong to it.                                                    |
| 500    | `INTERNAL_ERROR`    | Unhandled server error.                                                                                             |

### Code samples

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

  ```python Python theme={null}
  resp = requests.post(
      f"{BASE_URL}/v1/campaign/camp_123/runs/run_789/retry",
      headers={"X-API-Key": API_KEY},
      timeout=15,
  )
  body = resp.json()
  if body["success"]:
      print("New retry run:", body["data"]["run_id"],
            "from:", body["data"]["retry_of_run_id"])
  else:
      print("Cannot retry:", body["errors"])
  ```

  ```javascript Node.js theme={null}
  const resp = await fetch(
    `${BASE_URL}/v1/campaign/camp_123/runs/run_789/retry`,
    { method: "POST", headers: { "X-API-Key": API_KEY } },
  );
  const body = await resp.json();
  if (body.success) {
    console.log(
      "New retry run:", body.data.run_id,
      "from:", body.data.retry_of_run_id,
    );
  } else {
    console.log("Cannot retry:", body.errors);
  }
  ```
</CodeGroup>

***

## Quick reference

| Operation | Endpoint                       | Reversible?                | Side effects                                        |
| --------- | ------------------------------ | -------------------------- | --------------------------------------------------- |
| Validate  | `POST .../validate`            | n/a — read-only            | None                                                |
| Launch    | `POST .../launch`              | No (terminal once running) | Validates, marks `ready`, triggers a run            |
| Pause     | `POST .../pause`               | Yes (via resume)           | Stops in-flight runs after current batch            |
| Resume    | `POST .../resume`              | n/a                        | **Resets `scheduled_at` to now** — go-now semantics |
| Cancel    | `POST .../cancel`              | **No — sticky**            | Refunds unsent reservations                         |
| Retry     | `POST .../runs/{run_id}/retry` | n/a — creates a new run    | New run id, filters out already-sent recipients     |

## Next

* Track in-flight progress, per-run counters, and webhook signals in [Campaign observability](/guides/campaigns-observability).
