API reference
Base URL https://your-instance/api/v1. Every request needs Authorization: Bearer sk_live_…. See the overview for auth, pagination, and error conventions. In the paths below, {id} is always an integer.
Campaigns
GET /campaigns
List campaigns you can see. Query params: archived (true/false, default false), status (draft / active / paused / completed), limit, offset.
curl "https://your-instance/api/v1/campaigns?status=active&limit=20" \
-H "Authorization: Bearer $KEY"
{
"data": [
{
"id": 42,
"name": "Q3 outbound",
"status": "active",
"account_id": 1,
"account_email": "sales@acme.com",
"reply_to": null,
"timezone": "Asia/Kolkata",
"sending_days": [1, 2, 3, 4, 5],
"sending_hour_start": 9,
"sending_hour_end": 17,
"starts_at": "2026-09-10 09:00:00",
"is_archived": false,
"is_shared_to_all": true,
"step_count": 3,
"contact_count": 240,
"created_at": "2026-08-01 08:52:10",
"updated_at": "2026-09-02 11:04:55"
}
],
"pagination": { "total": 6, "limit": 20, "offset": 0 }
}POST /campaigns
Create a draft campaign. Body: name (required), account_id (required — a live sending account). Returns 201 with the campaign object above.
curl -X POST https://your-instance/api/v1/campaigns \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "name": "September nudge", "account_id": 1 }'
GET /campaigns/{id}
One campaign. 404 if it doesn't exist or you can't see it.
PATCH /campaigns/{id}
Partial update. Any of: name, account_id, reply_to (string or null), timezone, sending_days (array of 1–7, Mon–Sun), sending_hour_start, sending_hour_end, is_shared_to_all (creator/admin only). Returns the updated campaign.
curl -X PATCH https://your-instance/api/v1/campaigns/42 \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "reply_to": "inbound@acme.com", "sending_days": [1,2,3,4,5] }'
POST /campaigns/{id}/activate
Start sending. Requires at least one step and one enrolled contact, or you get 400. Optional body { "starts_at": "2026-09-10T14:00:00Z" } to schedule (RFC 3339, must be future); omit it to start now. Returns the campaign with status: "active".
POST /campaigns/{id}/pause
Stop sending. Returns the campaign with status: "paused".
POST /campaigns/{id}/archive · POST /campaigns/{id}/unarchive
Archive hides the campaign from the default list and pauses it; unarchive brings it back. Both return the campaign object.
DELETE /campaigns/{id}
Soft-delete. 204, no body. The campaign stops sending and disappears from all lists; an admin can still restore it.
Campaign steps
Steps are the ordered messages in a sequence. Step 1 has its own subject; every later step is a reply in the same thread and its subject is ignored.
GET /campaigns/{id}/steps
{
"data": [
{ "id": 7, "step_order": 1, "subject": "Quick question, {{name}}",
"body_html": "<p>Hi {{name}}…</p>", "wait_days": 0 },
{ "id": 8, "step_order": 2, "subject": "",
"body_html": "<p>Following up…</p>", "wait_days": 3 }
]
}POST /campaigns/{id}/steps
Append a step. Body: body_html (required), subject (used only if this is step 1), wait_days (days after the previous step; default 0). Returns 201 with the step.
curl -X POST https://your-instance/api/v1/campaigns/42/steps \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "subject": "Quick question, {{name}}",
"body_html": "<p>Hi {{name}}, saw that {{company}}…</p>",
"wait_days": 0 }'
PATCH /campaigns/{id}/steps/{step_id}
Any of subject, body_html, wait_days. Returns the step.
DELETE /campaigns/{id}/steps/{step_id}
204.
Enrollment
GET /campaigns/{id}/contacts
Contacts enrolled in this campaign and where each one is in the sequence. Paginated.
{
"data": [
{ "contact_id": 918, "name": "Jane Doe", "email": "jane@acme.com",
"status": "active", "current_step": 1, "next_send_at": "2026-09-05 09:12:00" }
],
"pagination": { "total": 240, "limit": 50, "offset": 0 }
}status is one of active, replied, stopped, bounced, completed.
POST /campaigns/{id}/contacts
Enrol contacts. Body: contact_ids (array) and/or list_ids (array — every live contact on those lists). Already-enrolled and deleted contacts are skipped silently.
curl -X POST https://your-instance/api/v1/campaigns/42/contacts \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "list_ids": [3, 4], "contact_ids": [918] }'
# → { "enrolled": 57 }
Campaign events & stats
GET /campaigns/{id}/events
The raw event log for one campaign. Query params: type (sent / opened / clicked / replied / bounced / failed / enrolled / unsubscribed), limit, offset. Newest first.
{
"data": [
{ "id": 5521, "contact_id": 918, "type": "opened", "step_order": 1,
"url": null, "timestamp": "2026-09-03 14:22:07" },
{ "id": 5518, "contact_id": 918, "type": "clicked", "step_order": 1,
"url": "https://acme.com/pricing", "timestamp": "2026-09-03 14:22:31" }
],
"pagination": { "total": 1290, "limit": 50, "offset": 0 }
}GET /campaigns/{id}/stats
Headline numbers. sent is total emails; the rest are distinct contacts.
{
"contacts": 240, "steps": 3, "sent": 610,
"opened": 188, "clicked": 44, "replied": 12, "bounced": 3, "failed": 1
}Contacts
GET /contacts
Query params: q (matches name / email / company), list_id, archived, limit, offset.
{
"data": [
{
"id": 918,
"name": "Jane Doe",
"email": "jane@acme.com",
"company": "Acme",
"custom_fields": { "plan": "pro", "region": "APAC" },
"is_archived": false,
"is_shared_to_all": true,
"created_at": "2026-08-14 10:03:00",
"updated_at": "2026-09-01 09:20:11"
}
],
"pagination": { "total": 5120, "limit": 50, "offset": 0 }
}POST /contacts
Body: email (required), name, company, custom_fields (object — keys are custom-field keys), list_ids (array). 409 if a live contact with that email already exists. Returns 201.
curl -X POST https://your-instance/api/v1/contacts \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "name": "Jane Doe", "email": "jane@acme.com", "company": "Acme",
"custom_fields": { "plan": "pro" }, "list_ids": [3] }'
GET /contacts/{id} · PATCH /contacts/{id} · DELETE /contacts/{id}
PATCH accepts name, email, company (string or null), custom_fields (replaces the whole object), is_shared_to_all. DELETE is a soft delete (204).
POST /contacts/{id}/archive · POST /contacts/{id}/unarchive
GET /contacts/{id}/campaigns
Which campaigns this contact is enrolled in, and their status in each.
{
"data": [
{ "campaign_id": 42, "campaign_name": "Q3 outbound",
"status": "replied", "current_step": 2, "next_send_at": null }
]
}Contact lists
| Method & path | Body | Returns |
|---|---|---|
GET /lists | — | paginated lists with contact_count |
POST /lists | { "name": "…" } | 201 list |
GET /lists/{id} | — | list |
PATCH /lists/{id} | { "name": "…" } | list |
DELETE /lists/{id} | — | 204 (soft; members kept) |
GET /lists/{id}/members | — | paginated { contact_id, name, email } |
POST /lists/{id}/members | { "contact_ids": [1,2] } | { "added": 2 } |
DELETE /lists/{id}/members/{contact_id} | — | 204 |
// GET /lists
{
"data": [
{ "id": 3, "name": "APAC prospects", "contact_count": 412,
"created_at": "2026-07-01 12:00:00", "updated_at": null }
],
"pagination": { "total": 8, "limit": 50, "offset": 0 }
}Custom fields
Define extra per-contact attributes you can merge into emails as {{key}}.
| Method & path | Body | Returns |
|---|---|---|
GET /custom-fields | — | { "data": [ { id, name, key, created_at } ] } |
POST /custom-fields | { "name": "Plan" } | 201; key is auto-slugged (plan), de-duplicated |
DELETE /custom-fields/{id} | — | 204 (soft) |
Suppression list
Addresses that will never be emailed, org-wide.
| Method & path | Body | Returns |
|---|---|---|
GET /suppressions | — | paginated { id, email, reason, created_at } |
POST /suppressions | { "email": "x@y.com", "reason": "bounced" } | 201; re-adding a removed one revives it |
DELETE /suppressions | { "email": "x@y.com" } | 204 |
Templates
Reusable email bodies and signatures.
GET /templates
Query params: type (template / signature), archived, limit, offset.
{
"data": [
{ "id": 5, "name": "Cold intro", "type": "template",
"subject": "{{company}} + us?", "body_html": "<p>…</p>",
"is_archived": false, "is_shared_to_all": true,
"created_at": "2026-06-10 09:00:00", "updated_at": "2026-08-30 14:12:00" }
],
"pagination": { "total": 11, "limit": 50, "offset": 0 }
}POST /templates
Body: name (required), body_html (required), type (template default, or signature), subject (ignored for signatures). Returns 201.
GET /templates/{id} · PATCH /templates/{id} · DELETE /templates/{id}
PATCH: name, subject (string or null), body_html, is_shared_to_all. DELETE is soft (204).
POST /templates/{id}/archive · POST /templates/{id}/unarchive
Sending accounts
Read-only plus lifecycle. Connecting a Gmail/Outlook/SMTP account is an interactive OAuth/credential flow and stays in the browser — the API never returns or accepts credentials.
| Method & path | Notes |
|---|---|
GET /accounts | Paginated. Fields: id, provider, email, from_name, status, daily_send_limit, send_interval_min_seconds, send_interval_max_seconds, is_archived, connected_at |
GET /accounts/{id} | One account |
POST /accounts/{id}/pause · /resume | Also pauses / resumes that account's active campaigns |
POST /accounts/{id}/archive · /unarchive | |
DELETE /accounts/{id} | Soft-delete (204); pauses its campaigns |
Event feed
GET /events
Every event across every campaign you can see — the firehose, for syncing into a warehouse or dashboard. Query params: campaign_id, contact_id, type, since (RFC 3339 — only events at/after this instant), limit, offset. Newest first.
curl "https://your-instance/api/v1/events?since=2026-09-01T00:00:00Z&type=replied&limit=100" \
-H "Authorization: Bearer $KEY"
{
"data": [
{ "id": 5599, "campaign_id": 42, "contact_id": 918, "type": "replied",
"step_order": 2, "url": null, "timestamp": "2026-09-03 15:40:12" }
],
"pagination": { "total": 87, "limit": 100, "offset": 0 }
}Sharing
By default every resource is visible to everyone (is_shared_to_all: true). Set that to false via PATCH on the resource to scope it to its creator, admins, and an explicit list of users managed here. {type} is one of campaigns, contacts, templates, accounts.
| Method & path | Body | Returns |
|---|---|---|
GET /{type}/{id}/shares | — | { "data": [ { user_id, name, email, granted_by, granted_at } ] } |
POST /{type}/{id}/shares | { "user_id": 7 } | the updated share list |
DELETE /{type}/{id}/shares/{user_id} | — | 204 |
Only the resource's creator or an admin can manage its shares; anyone else gets 404.
Users
Admin keys only (a non-admin key gets 403).
| Method & path | Body | Notes |
|---|---|---|
GET /users | — | { "data": [ { id, name, email, role, disabled, pending_invite, created_at } ] } |
GET /users/{id} | — | one user |
POST /users | { "name": "…", "email": "…", "role": "manager" } | 201; sends an invite email if system email is configured. role ∈ admin / manager / view_only, and can't exceed your own. |
POST /users/{id}/disable · /enable | — | disable also kills that user's sessions and API keys |