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 & pathBodyReturns
GET /listspaginated 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}/memberspaginated { 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 & pathBodyReturns
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 & pathBodyReturns
GET /suppressionspaginated { 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 & pathNotes
GET /accountsPaginated. 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 · /resumeAlso 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 & pathBodyReturns
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 & pathBodyNotes
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. roleadmin / manager / view_only, and can't exceed your own.
POST /users/{id}/disable · /enabledisable also kills that user's sessions and API keys