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; 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. For an overview of the whole lifecycle, see Campaigns.
1. Create a campaign
In the dashboard
- Sign in at briq.tz/login and open the workspace you want to send from.
- Go to the Campaigns tab and click New Campaign.
- Fill in the name, an optional description, and pick the message channel (SMS, voice, or WhatsApp).
- 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
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).
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.
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
{
"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
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"
}'
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
- Open the campaign you just created.
- Switch to the Audiences tab and click Add contact groups.
- 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
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.
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.
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.
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.
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"]
}'
| 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
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.
curl https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/audiences \
-H "X-API-Key: YOUR_API_KEY"
Detach a single group
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.
curl -X DELETE \
https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/audiences/GROUP_ID \
-H "X-API-Key: YOUR_API_KEY"
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
- Open the campaign and switch to the Content tab.
- Pick the content type your channel supports (Template, Raw, or Rich).
- Fill the form — the dashboard validates as you type (template variables, sender id, required rich fields).
- Save. You can edit or replace the content later as long as the campaign hasn’t launched.
Developer API
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.
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.
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. |
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" }
}
}'
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. |
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"
}
}'
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": "..." }. |
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" }
]
}
}'
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)
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.
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.
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" }
}
}'
Delete content
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.
curl -X DELETE \
https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/content/CONTENT_ID \
-H "X-API-Key: YOUR_API_KEY"
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
- Open the campaign and switch to the Schedule tab.
- Pick Single send or Recurring.
- Set the date, time, and timezone. For recurring runs also set the frequency and an end date.
- Save. The run appears in the runs list at status
ready.
Developer API
POST https://karibu.briq.tz/v1/campaign/{campaign_id}/runs
The body shape depends on run_type. Pick a tab below.
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.
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.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"
}'
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.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.
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"
}'
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.
{
"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
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). |
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"
Delete a run
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, not this endpoint.
curl -X DELETE \
https://karibu.briq.tz/v1/campaign/CAMPAIGN_ID/runs/RUN_ID \
-H "X-API-Key: YOUR_API_KEY"
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.