Resources

API documentation

A JSON REST surface authenticated with workspace API keys. Every key is scoped, rate-limited on its own and bound to one workspace: a call can only ever read or write that workspace's data.

https://app.outboundrix.io/api/v1JSON over HTTPS. Separate from the session-cookie APIs the dashboard uses.

Authentication

Send the key as a bearer token on every request. Keys look like obx_live_sk_<32 hex>, are shown once at creation and stored only as a SHA-256 hash. Revoking a key takes effect on the next call.

  • The key decides the workspace. There is no workspace header or parameter.
  • Keys are created in Settings → Access, where you tick the scopes the key gets; a key with no scopes is rejected with 403 on every endpoint. You can also mint a scoped key from a signed-in session with POST /api/keys, as shown.
Create a scoped key (dashboard session)
curl -X POST "https://app.outboundrix.io/api/keys" \
  -H "Cookie: obx_session=<your session cookie>" \
  -H "Content-Type: application/json" \
  -d '{"name":"n8n pump","scopes":["contacts:read","contacts:write","status:read"]}'
Response — the only time the key is returned
201 Created
{
  "key": "obx_live_sk_3f9c…",
  "id": 12,
  "name": "n8n pump",
  "key_prefix": "obx_live_sk_3f9c0a1b",
  "scopes": ["contacts:read", "contacts:write", "status:read"],
  "created_at": "2026-10-02T09:20:00.000Z",
  "warning": "Store this key now — it will not be shown again."
}

Scopes

A key carries any subset of these. A call without the scope its endpoint requires gets 403 with required_scope in the body.

ScopeUnlocks
contacts:readGET /contacts, GET /contacts/{id}
contacts:writePOST /contacts, POST /contacts/bulk, PATCH /contacts/{id}, DELETE /contacts/{id}
sequences:readGET /sequences
sequences:writePOST /sequences/{id}/enroll, POST /sequences/{id}/unenroll
campaigns:readGET /campaigns
campaigns:writePOST /campaigns/{id}/contacts
status:readGET /status
mailboxes:readGET /inbox
mailboxes:writePOST /inbox/add, GET /inbox/add

Endpoints

GET/api/v1/contactscontacts:read

List the workspace's contacts, newest first. Offset pagination; no cursor.

Query parameters

NameTypeRules
limitinteger1–200, default 50 (out-of-range values are clamped)
offsetinteger≥0, default 0
querystringcase-insensitive substring match on first_name, last_name, email, company
Request
curl "https://app.outboundrix.io/api/v1/contacts?limit=50&offset=0&query=acme" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX"
Response
200 OK
{
  "count": 1,
  "rows": [
    {
      "id": 1042,
      "workspace_id": "7",
      "company_id": null,
      "first_name": "Dana",
      "last_name": "Ruiz",
      "email": "dana@acme.io",
      "email_status": "valid",
      "phone": null,
      "title": "Head of Sales",
      "company": "Acme",
      "industry": "Software",
      "location": "Austin, US",
      "employees": "51-200",
      "score": 82,
      "status": "new",
      "notes": null,
      "custom": {},
      "created_at": "2026-10-02T09:20:00.000Z",
      "updated_at": "2026-10-02T09:20:00.000Z"
    }
  ]
}
  • count is the number of rows in this page, not the workspace total. Keep paging until a page returns fewer than limit.
  • Order is created_at then id, newest first, so pages never repeat or skip rows that share a timestamp (a bulk import writes many at once).
  • Rows are the full contacts record, so columns added by later migrations appear without notice.
POST/api/v1/contactscontacts:write

Create one contact. Every field is optional; unknown fields are rejected (strict schema). No dedupe: posting the same email twice creates two rows. Use /contacts/bulk to upsert.

JSON body

NameTypeRules
first_namestring1–120 chars
last_namestring≤120
emailstringvalid email, ≤254
email_statusstring≤40
phonestring≤40
titlestring≤160
companystring≤200
company_idintegerpositive, id of a workspace company
industrystring≤120
locationstring≤160
employeesstring≤40, free text e.g. "51-200"
scoreinteger0–100
statusstring≤40, defaults to "new"
notesstring≤5000
Request
curl -X POST "https://app.outboundrix.io/api/v1/contacts" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"first_name":"Dana","last_name":"Ruiz","email":"dana@acme.io","company":"Acme","title":"Head of Sales"}'
Response
201 Created
{
  "row": {
    "id": 1042,
    "workspace_id": "7",
    "company_id": null,
    "first_name": "Dana",
    "last_name": "Ruiz",
    "email": "dana@acme.io",
    "email_status": "valid",
    "phone": null,
    "title": "Head of Sales",
    "company": "Acme",
    "industry": "Software",
    "location": "Austin, US",
    "employees": "51-200",
    "score": 82,
    "status": "new",
    "notes": null,
    "custom": {},
    "created_at": "2026-10-02T09:20:00.000Z",
    "updated_at": "2026-10-02T09:20:00.000Z"
  }
}
  • Fires the contact.created webhook after the insert. Delivery is out of band and never delays the response.
  • Refused with 402 cap_reached when the workspace is at its plan's active-contacts cap.
  • company_id must be one of your workspace's companies (400 validation_error otherwise). score is stored as sent.
POST/api/v1/contacts/bulkcontacts:write

Upsert up to 500 contacts, keyed on lower(email) within the workspace. Built for scheduled lead pumps (n8n and similar).

JSON body

NameTypeRules
contactsarrayrequired, 0–500 objects
contacts[].emailstringrequired per row; rows without an "@" are skipped, not errored
contacts[].first_name … notesstringfirst_name, last_name, email_status, phone, title, company, industry, location, employees, notes; truncated to the same lengths as POST /contacts
sourcestring≤40, default "external"; echoed back, not stored
Request
curl -X POST "https://app.outboundrix.io/api/v1/contacts/bulk" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"source":"n8n","contacts":[{"email":"dana@acme.io","first_name":"Dana","company":"Acme"}]}'
Response
200 OK
{
  "inserted": 1,
  "updated": 0,
  "skipped": 0,
  "errors": [],
  "error_count": 0,
  "source": "n8n"
}
  • Updates only fill empty fields (COALESCE): a value already on the contact is never overwritten, so re-sending the same rows is safe.
  • If the batch would exceed the plan's active-contacts cap it is cut to the remaining room, and the rows past the cut are not counted in inserted, updated or skipped. Compare inserted + updated + skipped with what you sent.
  • At the cap the whole call returns 402 cap_reached.
  • errors lists at most the first 20 failed rows as { index, email, message }; error_count has the total.
  • Does not fire webhooks.
GET/api/v1/contacts/{id}contacts:read

One contact, plus the sequences it is in.

Request
curl "https://app.outboundrix.io/api/v1/contacts/1042" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX"
Response
200 OK
{
  "row": {
    "id": 1042,
    "workspace_id": "7",
    "company_id": null,
    "first_name": "Dana",
    "last_name": "Ruiz",
    "email": "dana@acme.io",
    "email_status": "valid",
    "phone": null,
    "title": "Head of Sales",
    "company": "Acme",
    "industry": "Software",
    "location": "Austin, US",
    "employees": "51-200",
    "score": 82,
    "status": "new",
    "notes": null,
    "custom": {},
    "created_at": "2026-10-02T09:20:00.000Z",
    "updated_at": "2026-10-02T09:20:00.000Z"
  },
  "enrollments": [
    {
      "sequence_id": "12",
      "sequence_name": "Series A founders",
      "campaign_id": null,
      "status": "active",
      "current_step": 2,
      "enrolled_at": "2026-10-02T09:21:00.000Z",
      "next_action_at": "2026-10-05T09:21:00.000Z",
      "completed_at": null,
      "last_activity_at": "2026-10-03T09:21:00.000Z"
    }
  ]
}
  • enrollments: newest first, at most 100. status is active, paused, completed, replied or unsubscribed.
  • 404 not_found when the id is not one of your contacts.
PATCH/api/v1/contacts/{id}contacts:write

Change some fields of one contact. Send only the fields to change; null clears a field (except status). Same fields and limits as POST /contacts.

JSON body

NameTypeRules
first_namestring1–120 chars
last_namestring≤120
emailstringvalid email, ≤254
email_statusstring≤40
phonestring≤40
titlestring≤160
companystring≤200
company_idintegerpositive, id of a workspace company
industrystring≤120
locationstring≤160
employeesstring≤40, free text e.g. "51-200"
scoreinteger0–100
statusstring≤40, defaults to "new"
notesstring≤5000
Request
curl -X PATCH "https://app.outboundrix.io/api/v1/contacts/1042" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"title":"VP Sales","phone":null}'
Response
200 OK
{
  "row": {
    "id": 1042,
    "workspace_id": "7",
    "company_id": null,
    "first_name": "Dana",
    "last_name": "Ruiz",
    "email": "dana@acme.io",
    "email_status": "valid",
    "phone": null,
    "title": "Head of Sales",
    "company": "Acme",
    "industry": "Software",
    "location": "Austin, US",
    "employees": "51-200",
    "score": 82,
    "status": "new",
    "notes": null,
    "custom": {},
    "created_at": "2026-10-02T09:20:00.000Z",
    "updated_at": "2026-10-02T09:20:00.000Z"
  }
}
  • Fires the contact.updated webhook with the updated row.
  • An empty body is 400 validation_error; unknown fields are rejected.
DELETE/api/v1/contacts/{id}contacts:write

Delete one contact's CRM record.

Request
curl -X DELETE "https://app.outboundrix.io/api/v1/contacts/1042" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX"
Response
200 OK
{
  "deleted": 1042
}
  • Fires the contact.deleted webhook with the deleted row.
  • Its sequence enrollments stop: the sender pauses an enrollment whose contact is gone.
  • This is not a GDPR erasure. To also remove messages, activity, notes and consent records, use Settings → Privacy → Right to be forgotten.
GET/api/v1/sequencessequences:read

Your sequences, newest first (at most 500), to find the id to enroll contacts into.

Request
curl "https://app.outboundrix.io/api/v1/sequences" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX"
Response
200 OK
{
  "count": 1,
  "sequences": [
    {
      "id": "12",
      "name": "Series A founders",
      "description": "",
      "status": "active",
      "total_enrolled": 210,
      "step_count": 3,
      "active_enrollments": 148,
      "created_at": "2026-09-20T10:00:00.000Z",
      "updated_at": "2026-10-02T09:21:00.000Z"
    }
  ]
}
  • step_count counts active steps. A sequence's status is a label; enrollments send when they are due whatever it says.
POST/api/v1/sequences/{id}/enrollsequences:write

Put contacts into a sequence. The same path as "Add to sequence" in the app.

JSON body

NameTypeRules
contact_idsarrayrequired, 1–1000 contact ids (duplicates ignored)
Request
curl -X POST "https://app.outboundrix.io/api/v1/sequences/12/enroll" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"contact_ids":[1042,1043,1044]}'
Response
200 OK
{
  "requested": 3,
  "enrolled": 1,
  "already_enrolled": 1,
  "skipped_not_found": 0,
  "skipped_no_email": 1,
  "skipped_suppressed": 0,
  "enrolled_ids": [1042]
}
  • Only your workspace's contacts are enrolled; other ids count as skipped_not_found.
  • Skipped without an error: contacts with no email address, addresses on your do-not-email list, and addresses already in the sequence (also under another contact id, so a person is never emailed twice per step).
  • A contact is in a sequence once. Enrolling it again, also after unenroll or completion, counts as already_enrolled.
  • The first email goes after step 1's delay, from your connected mailboxes. Each enrollment records its GDPR lawful basis (legitimate interest, or consent when an opt-in is on file).
  • 404 not_found when the sequence is not yours.
POST/api/v1/sequences/{id}/unenrollsequences:write

Stop sending to contacts in a sequence, for example when they reply on another channel or book a meeting.

JSON body

NameTypeRules
contact_idsarrayrequired, 1–1000 contact ids
Request
curl -X POST "https://app.outboundrix.io/api/v1/sequences/12/unenroll" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"contact_ids":[1042]}'
Response
200 OK
{
  "requested": 1,
  "stopped": 1,
  "not_active": 0,
  "stopped_ids": [1042]
}
  • Active enrollments become paused and send nothing more. not_active counts ids that were not active in this sequence (never enrolled, already finished or stopped).
GET/api/v1/campaignscampaigns:read

Your campaigns, newest first (at most 500).

Request
curl "https://app.outboundrix.io/api/v1/campaigns" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX"
Response
200 OK
{
  "count": 1,
  "campaigns": [
    {
      "id": "7",
      "name": "Q4 founders",
      "description": "",
      "status": "active",
      "sequence_id": "12",
      "total_recipients": 240,
      "start_at": null,
      "launched_at": "2026-10-01T08:00:00.000Z",
      "created_at": "2026-09-28T15:00:00.000Z",
      "updated_at": "2026-10-02T09:21:00.000Z",
      "enrolled": 236
    }
  ]
}
  • status is draft, active, paused or completed. enrolled counts the campaign's enrollments now; email stats stay in the dashboard (Analytics).
POST/api/v1/campaigns/{id}/contactscampaigns:write

Add contacts to a campaign's recipients. Draft or paused: added as recipients and enrolled at launch. Active: enrolled now with the campaign's sequence and mailboxes.

JSON body

NameTypeRules
contact_idsarrayrequired, 1–1000 contact ids
Request
curl -X POST "https://app.outboundrix.io/api/v1/campaigns/7/contacts" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"contact_ids":[1042,1043]}'
Response
200 OK
{
  "campaign": { "id": 7, "name": "Q4 founders", "status": "active" },
  "added": 2,
  "already_recipients": 0,
  "skipped_not_found": 0,
  "recipients": 242,
  "enrolled": 2,
  "enroll": { "already_enrolled": 0, "skipped_no_email": 0, "skipped_suppressed": 0, "enrolled_ids": [1042, 1043] }
}
  • enroll is null unless the campaign is active. The enroll rules are the same as POST /sequences/{id}/enroll.
  • 400 campaign_closed for a completed campaign; 404 not_found when the campaign is not yours.
GET/api/v1/statusstatus:read

Check that your key works and the platform database answers.

Request
curl "https://app.outboundrix.io/api/v1/status" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX"
Response
200 OK
{
  "platform": "Outboundrix",
  "workspace_id": "7",
  "overall": "ok",
  "timestamp": "2026-10-02T09:20:00.000Z",
  "services": [
    { "name": "db", "status": "ok", "detail": "connection healthy" }
  ]
}
  • overall is ok, degraded (database error, returned as 503) or partial (database not configured, returned as 200).
GET/api/v1/inboxmailboxes:read

Your sending mailboxes and their state, newest first (at most 500). Never returns credentials.

Query parameters

NameTypeRules
emailstringoptional; only that mailbox
Request
curl "https://app.outboundrix.io/api/v1/inbox?email=ada@yourdomain.com" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX"
Response
200 OK
{
  "count": 1,
  "mailboxes": [
    {
      "id": "34", "email": "ada@yourdomain.com", "display_name": "Ada Lovelace", "provider": "smtp",
      "auth_status": "authenticated", "verify_status": "verified", "sendable": true, "sendable_reason": null,
      "last_error": null, "daily_limit": 30, "sent_today": 4, "warmup_enabled": true, "warmup_status": "active",
      "health_status": "healthy", "last_verified_at": "2026-10-02T09:20:00.000Z", "…": "…"
    }
  ]
}
POST/api/v1/inbox/addmailboxes:write

Add or update sending mailboxes from an inbox vendor or a script: one per call, or up to 25 as { mailboxes: [...] }. Each is saved encrypted, then its SMTP login is tested (no email is sent).

Query parameters

NameTypeRules
mspstringgoogle | microsoft | zoho | mailjet | smtp (also accepted in the body, per mailbox)
verifybooleandefault true; false skips the login test

JSON body

NameTypeRules
emailstringrequired; a custom domain (free-mail domains are refused)
app_password / passwordstringrequired; Google Workspace needs an app password; msp=mailjet: the secret key
usernamestringSMTP login, defaults to email; msp=mailjet: the API key (required)
first_name, last_name / display_namestringsender name
daily_limitinteger1–2000, default 50
warmupbooleandefault true
smtp_host, smtp_port, imap_host, imap_portstring / integeroverride the preset; smtp_host required for msp=smtp; IMAP is TLS on 993 (143 is refused)
imap_user, imap_passstringthe inbox login replies are read with, when it is not the SMTP login; required with imap_host for msp=mailjet (the From address's own mailbox)
Request
curl -X POST "https://app.outboundrix.io/api/v1/inbox/add?msp=google" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@yourdomain.com","app_password":"abcd efgh ijkl mnop","first_name":"Ada","daily_limit":30}'
Response
200 OK
{"saved":1,"verified":1,"failed":0,"results":[
  {"email":"ada@yourdomain.com","status":"created","mailbox_id":"34","msp":"google",
   "verified":true,"sendable":true,"daily_limit":30,"warmup_enabled":true}]}
  • 200 when every mailbox saved, 207 when some failed, 400 when none did. status is created, updated (same email again: credentials rotated) or error (with field and error).
  • A mailbox that fails the login check is saved with verified:false and verify_error, and does not send until it verifies.
  • With an IMAP host, the inbox is read every 2 minutes: a reply stops that contact's sequence and a hard bounce suppresses that contact. The login check here tests SMTP only; the first read records whether the IMAP login works.
  • GET /inbox/add (same scope) lists the msp presets and the batch limit.

Errors and limits

Errors are JSON: { "error": "<message>", "code": "<code>", … }. Branch on code and the status, not on the message text.

StatuscodeWhen
400bad_request / validation_errorBody is not JSON, or fails the schema. validation_error carries fieldErrors (company_id that is not one of your companies included).
400campaign_closedAdding contacts to a campaign that is completed (only draft, paused and active take recipients).
401unauthorizedAuthorization header missing, not obx_live_sk_…, unknown, or revoked.
402cap_reachedCreate or bulk import refused: the workspace is at its plan's active-contacts cap. Body carries used, cap, plan.
403forbiddenKey is valid but lacks the scope. Body carries required_scope.
404not_foundThe contact, sequence or campaign id is not in your workspace (another workspace's id looks the same as one that does not exist).
413too_largeBulk batch over 500 contacts, more than 1000 contact_ids in one call, or more than 25 mailboxes.
429rate_limitedPer-key token bucket empty.
503unavailableDatabase not configured on this deployment (GET /status reports it as degraded instead).

Rate limit

Token bucket per key: bursts of 120 requests, refilling at 2 per second. An empty bucket returns 429 with no Retry-After header; back off about 500 ms per request you want to make. The bucket lives in each app process and resets on deploy, so treat it as a ceiling, not a quota you can budget against.

Webhooks

Register an HTTP(S) endpoint per workspace and choose its events. Each delivery is a POST signed with HMAC-SHA256 over the raw body, using the whsec_ secret returned when the endpoint was created.

  • One attempt per event, 5 second timeout, redirects not followed. There are no retries; reconcile with GET /api/v1/contacts if you miss one.
  • Any 2xx counts as delivered. Every attempt is logged per endpoint.
  • URLs that resolve to private, loopback or link-local addresses are refused at registration and again at send time.
Delivery
POST <your url>
content-type: application/json
x-obx-event: contact.created
x-obx-signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with your whsec_ secret>

{
  "event": "contact.created",
  "workspace_id": "7",
  "created_at": "2026-10-02T09:20:01.114Z",
  "data": { …the contact row… }
}
Verify the signature (Node)
import crypto from 'node:crypto';

// rawBody: the exact bytes received, before JSON.parse
function verify(rawBody, header, secret) {
    const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
    const a = Buffer.from(expected);
    const b = Buffer.from(header || '');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Events

EventEmitted by
contact.createdPOST /api/v1/contacts
contact.updatedPATCH /api/v1/contacts/{id}
contact.deletedDELETE /api/v1/contacts/{id}
sequence.startedSubscribable, not emitted yet
sequence.completedSubscribable, not emitted yet

Key and webhook management

These run on the signed-in dashboard session, not on an API key, and act on the session's current workspace.

MethodPathDoes
GET/api/keysList the workspace's keys: id, name, key_prefix, scopes, last_used_at, created_by, created_at, revoked_at. Never the secret.
POST/api/keysBody { name?, scopes?: [...] }. Returns the plaintext key once, in "key".
DELETE/api/keys?id=<id>Revoke. The next call with that key gets 401.
GET/api/webhooksList endpoints. Returns secret_prefix, never the secret.
POST/api/webhooksBody { url, events: [...], active? }. Returns the whsec_ signing secret once.
PATCH/api/webhooksBody { id, url?, events?, active? }.
DELETE/api/webhooks?id=<id>Delete an endpoint and its delivery log.

Not in the API yet

Planned

Building sequences and campaigns (steps, mailboxes, launch) and the reply inbox are dashboard-only today, and an MCP server is planned. Anything not listed on this page is not part of the documented API.

Build on the same system your team uses

Questions about the API go straight to the people who build it. See also how it works.

Start freeNo credit card required
Developer supportReplies within one business day