Ask AI about this page

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,
      "reply_to_connected": true,
      "timezone": "Asia/Kolkata",
      "sending_days": [1, 2, 3, 4, 5],
      "sending_hour_start": 9,
      "sending_hour_end": 17,
      "ab_test_enabled": false,
      "ab_split_percent": 50,
      "sending_hour_start_b": null,
      "sending_hour_end_b": null,
      "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, ab_test_enabled, ab_split_percent (1–99, default 50), sending_hour_start_b / sending_hour_end_b (both an integer or both null — variant B's own send-time window; only meaningful with ab_test_enabled), is_shared_to_all (creator/admin only). Returns the updated campaign, including a computed reply_to_connected (whether the effective Reply-To — this field, or the sending account's own address when it's null — is a connected account SubsiMail can poll for replies/bounces; false means no reply/bounce metrics for this 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] }'

A/B testing: enabling ab_test_enabled splits contacts into groups A/B at enrollment (existing enrollments keep whichever variant, if any, they already had), consistently across the whole sequence. Each axis is independently optional — a step with no B variant (see below) just sends A's content to both groups, and a campaign with no B window sends both groups in the same hours. Metrics for A vs. B show side by side in the app's Insights tab; there is no auto-declared winner.

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>",
      "subject_b": "One quick thing, {{name}}", "body_html_b": null,
      "wait_days": 0, "kind": "email" },
    { "id": 8, "step_order": 2, "subject": "",
      "body_html": "<p>Following up…</p>", "subject_b": null, "body_html_b": null, "wait_days": 3, "kind": "email" }
  ]
}

subject_b / body_html_b are this step's A/B content variant (see enabling ab_test_enabled on the campaign) — null means this step isn't testing that axis, so group B gets group A's subject/body_html for it. Only step 1's subject_b is used; later steps ignore it the same way they ignore subject.

POST /campaigns/{id}/steps

Append a step. Body: body_html (required), subject (used only if this is step 1), subject_b / body_html_b (optional A/B variant, step 1 subject only), 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 }'

kind is "email" (the default) or "call". A call step sends no email: when it is due SubsiMail adds a task to the Calls page, and body_html holds the plain-text notes for the caller. A call step cannot be step 1. Create one by sending "kind": "call" to POST /campaigns/{id}/steps. In a rotation group, changes made through the API are copied to the group's other mailboxes.

PATCH /campaigns/{id}/steps/{step_id}

Any of subject, body_html, subject_b, body_html_b (either B field as an explicit null clears that variant), 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 }
  ]
}

The same query language behind the app's Smart Search box, over the API. Returns contacts the same shape as GET /contacts above, so anything downstream that already reads that response works here unchanged.

GET /contacts/search

Query params: q (required — the query text), limit, offset. A bad query (unknown field, malformed date, etc.) returns 400 with the specific reason instead of an empty result.

curl -G "https://your-instance/api/v1/contacts/search" \
  -H "Authorization: Bearer $KEY" \
  --data-urlencode 'q=status:replied AND opened is true' \
  --data-urlencode 'limit=50'
{
  "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": 1, "limit": 50, "offset": 0 }
}

A 400 looks like:

{ "error": { "code": "invalid_request", "message": "unknown field \"statuss\"" } }

Archived contacts are excluded unless the query says archived:true — same default as every other list/search endpoint. Custom fields, list names, and campaign names are matched exactly as documented in the Smart Search field reference; there's no separate field list for the API.

Contact lists

Method & pathBodyReturns
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}}. See Custom fields.

Method & pathBodyReturns
GET /custom-fields—{ "data": [ { id, name, key, field_type, created_at } ] }
POST /custom-fields{ "name": "Plan", "field_type": "text" }201; key is auto-slugged (plan), de-duplicated. field_type is optional, one of text (default), number, email, phone, url, date, datetime, boolean
DELETE /custom-fields/{id}—204 (soft)

When you set custom_fields on a contact (create or update), each value is validated against its field's type — a mismatch or an unknown key is a 400. A url value gains https:// if it has no scheme; a boolean is normalised to "true" / "false".

Suppression list

Addresses that will never be emailed, org-wide.

Method & pathBodyReturns
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 & pathNotes
GET /accountsPaginated. Fields: id, provider, email, from_name, status, daily_send_limit, timezone, send_window_start_hour, send_window_end_hour, send_batch_min, send_batch_max, send_batch_interval_minutes, pause_reason, pause_message, 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

An account's sending window (send_window_start_hour–send_window_end_hour) runs on its own timezone, independent of any campaign's own schedule — the app warns (non-blocking) when they differ. Within that window, every send_batch_interval_minutes the account sends a random count between send_batch_min and send_batch_max, shared fairly across however many campaigns are using it at once, up to daily_send_limit/day (its own local day). pause_reason is null when active, or one of manual, provider_limit (the provider looked like it blocked the mailbox for sending too much — SubsiMail auto-resumes this one once the account's local day changes), provider_error (anything else; see pause_message for the actual error text). These three, plus the sending window/batch/timezone fields, are set from the app's Accounts page — there's no write endpoint for them yet.

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

Users are listed and invited per workspace: GET /users returns the people with access to the key's workspace, and POST /users gives that workspace access to the person — creating them (and emailing an invite) if the address is new, or adding them if they already have an account. role is their role in this workspace. Disable and enable apply to the person everywhere, so they need a role that can act install-wide.

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