Skip to content
https://app.outboundrix.io/api/v1JSON over HTTPS, authenticated with an API key: the one way in for scripts and automations.

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.
  • An API key is the only way a script, an n8n flow or any other automation reaches Outboundrix. Your sign-in cookie works only in the app in a browser: sent from a script, the app answers 403 browser_only.
Check a new key
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" }
  ]
}
A sign-in cookie sent from a script
403 Forbidden
{
  "error": "Signed-in sessions work only in the Outboundrix app in your browser. Scripts and automations use an API key …",
  "code": "browser_only"
}

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}, POST /people/import
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). One contact per email: posting an email already there answers 409. 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 while the workspace has no active Lead qualifier rule; with one, the rules set it.
  • One contact per email in a workspace (compared ignoring case and surrounding spaces): an email already there is 409 contact_exists with that contact's id. To add or update in one call, use POST /contacts/bulk.
  • Without company_id the contact is linked to its company: the one at its work email's domain (made from it when there is none), or for a free-mail address one with its company name.
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,
  "changed": 0,
  "skipped": 0,
  "duplicates": 0,
  "capped": 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. updated counts the rows whose email was already a contact; changed, how many of them had an empty field filled.
  • If the batch would exceed the plan's active-contacts cap it is cut to the remaining room; capped counts the rows past the cut. A row repeating an email an earlier row of the batch has is counted in duplicates (the first row is used). inserted + updated + skipped + duplicates + capped is what you sent.
  • The batch is written in one transaction: when it fails, nothing is written and every row is in errors.
  • 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.
POST/api/v1/people/importcontacts:write

Import up to 1,000 people matching a filter set from the contact database into People: saved, made contacts, optionally added to a list and tagged. The same as People's Save in the app.

JSON body

NameTypeRules
filtersobjectrequired, at least one filter; the People search filters (a string, a list, or { "in": [...], "not": [...] } for each value filter; numbers for the employee and revenue ranges; booleans for has_email, has_phone)
only_newbooleandefault true: people the workspace already saved are not taken
limitinteger1–1,000, default 100: the first matches are taken
list_id | list_nameinteger | stringoptional: add them to that list, or to a new list of that name (≤120)
tagsarrayoptional tag names, created when missing
Request
curl -X POST "https://app.outboundrix.io/api/v1/people/import" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"filters":{"industry":"saas","country":["US"],"title_query":"head of sales"},"limit":50,"list_name":"SaaS sales leaders"}'
Response
200 OK
{
  "selected": 50,
  "saved_new": 50,
  "already_saved": 0,
  "people_created": 50,
  "people_updated": 0,
  "people_capped": 0,
  "list": { "id": 7, "name": "SaaS sales leaders" },
  "list_added": 50,
  "tags_added": 0
}
  • Reads from the contact database the platform has switched on: the OpenSearch index, or the data server (docs/architecture/data-server-api.md § People pool). When it is not connected the call answers 503 pool_not_configured; when it does not answer, 503 pool_unreachable.
  • Saving counts against the plan's monthly contact imports: a call that would pass them is refused whole (429 imports_would_exceed, with remaining), nothing written. People past the plan's active-contacts room stay saved and are counted in people_capped.
  • people_created counts new contacts; people_updated, contacts already there whose blank fields were filled. Re-running the same call with only_new takes the next matches.
  • A list_id of another workspace is 404. Tags start the workflows on "A tag was added".
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.
  • 404 merged with merged_into when two contacts with one email were merged (2026-10-10) and this id was the one folded in: read merged_into instead.
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.
  • Changing email to one another contact already has is 409 contact_exists with that contact's id.
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. Their tags and list memberships go too.
  • 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, and ones waiting out an out-of-office reply (they would continue on their own), become paused and send nothing more; both count in stopped. not_active counts ids that were not running 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=maya@yourdomain.com" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX"
Response
200 OK
{
  "count": 1,
  "mailboxes": [
    {
      "id": "34", "email": "maya@yourdomain.com", "display_name": "Maya Reyes", "provider": "smtp",
      "auth_status": "authenticated", "verify_status": "verified", "sendable": true, "sendable_reason": null,
      "last_error": null, "daily_limit": 25, "sent_today": 4, "warmup_enabled": true, "warmup_status": "warming",
      "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_limitintegerat least 1, default 25; above 25 is stored as 25 (the most any mailbox sends a day)
warmupbooleanignored: every mailbox follows the send schedule, 7 / 10 / 20 a day in weeks 1-3, then 25
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":"maya@yourdomain.com","app_password":"abcd efgh ijkl mnop","first_name":"Maya","daily_limit":25}'
Response
200 OK
{"saved":1,"verified":1,"failed":0,"results":[
  {"email":"maya@yourdomain.com","status":"created","mailbox_id":"34","msp":"google",
   "verified":true,"sendable":true,"daily_limit":25,"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.
  • The plan caps how many mailboxes a workspace connects (Free 1, every paid plan any number): a new address past it is status error with code mailbox_limit and is not saved; an address the workspace has still rotates its credentials.
  • 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.

MCP server

The endpoints above as tools for your own AI agents: Claude Code, Cursor, n8n, or an agent you build with the Claude Agent SDK or the OpenAI API. One URL, https://app.outboundrix.io/api/mcp, and the same API keys: the key decides the workspace and which tools work, exactly as it does on the REST API.

  • Send the key as Authorization: Bearer … on every request. Give the assistant a key of its own with only the scopes it needs: a tool outside them answers forbidden, with the scope it needs.
  • A tool call runs the endpoint's own handler: the same checks, the same errors (returned as a tool error with the status and code, so the model can read them), the same rate limit (each request takes one token) and the same audit trail.
  • Streamable HTTP without sessions: POST JSON-RPC to the URL; there is no event stream, so GET answers 405. Protocol versions 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05.
  • POST /api/v1/inbox/add: Not a tool: a mailbox password would pass through the assistant and its model provider. Connect mailboxes in the app or with this endpoint.
Connect Claude Code
claude mcp add --transport http outboundrix https://app.outboundrix.io/api/mcp \
  --header "Authorization: Bearer obx_live_sk_XXXXXXXX"
A client that takes a URL and headers (.mcp.json)
{
  "mcpServers": {
    "outboundrix": {
      "type": "http",
      "url": "https://app.outboundrix.io/api/mcp",
      "headers": { "Authorization": "Bearer obx_live_sk_XXXXXXXX" }
    }
  }
}
A tool call by hand
curl -X POST "https://app.outboundrix.io/api/mcp" \
  -H "Authorization: Bearer obx_live_sk_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_contacts","arguments":{"query":"acme","limit":5}}}'
Response
200 OK
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [{ "type": "text", "text": "{\"count\":1,\"rows\":[…]}" }],
    "structuredContent": { "count": 1, "rows": [ … ] }
  }
}

Tools

ToolWhat it doesSame asScope
list_contactsList contactsGET /contactscontacts:read
create_contactCreate a contactPOST /contactscontacts:write
upsert_contactsAdd or update contacts in bulkPOST /contacts/bulkcontacts:write
import_peopleImport people from the contact databasePOST /people/importcontacts:write
get_contactGet a contactGET /contacts/{id}contacts:read
update_contactUpdate a contactPATCH /contacts/{id}contacts:write
delete_contactDelete a contactDELETE /contacts/{id}contacts:write
list_sequencesList sequencesGET /sequencessequences:read
enroll_in_sequenceEnroll contacts in a sequencePOST /sequences/{id}/enrollsequences:write
unenroll_from_sequenceStop a sequence for contactsPOST /sequences/{id}/unenrollsequences:write
list_campaignsList campaignsGET /campaignscampaigns:read
add_campaign_recipientsAdd contacts to a campaignPOST /campaigns/{id}/contactscampaigns:write
get_statusCheck the workspace statusGET /statusstatus:read
list_mailboxesList sending mailboxesGET /inboxmailboxes:read

Webhooks

Add an HTTP(S) endpoint under Integrations → Webhooks and choose its events; Send test there posts a signed webhook.test to it. Each delivery is a POST signed with HMAC-SHA256 over the raw body, using the whsec_ secret shown once 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

EventWhenSent by
contact.createdA person was addedPeople (by hand, CSV import, the lead scraper, a database save), inbound endpoints, POST /api/v1/contacts
contact.updatedA person was changedPeople, a deal stage automation, PATCH /api/v1/contacts/{id}
contact.deletedA person was deletedDELETE /api/v1/contacts/{id}
contact.unsubscribedA person unsubscribedThe unsubscribe link or one-click unsubscribe in an email
sequence.startedA person was added to a sequenceA campaign launch, Add to sequence, a workflow, POST /api/v1/sequences/{id}/enroll
email.sentAn email was sentThe sender
email.openedAn email was openedThe open pixel (once per email per day)
email.clickedA link was clickedA tracked link (link scanners are left out)
reply.receivedA reply came inReply reading from Gmail, Microsoft 365 and IMAP mailboxes
reply.categorizedA reply was labelledReply labels: the rules and your AI key when a reply comes in, the reply agent, a person in the Inbox (each time the label is set or changed)
email.bouncedAn email bouncedThe sender (a permanent recipient failure)
email.send_failedA send failedThe sender (each failed attempt)
sequence.completedA person finished a sequenceThe sender, after the last step
sequence.failedA person was stopped after failed sendsThe sender, when the retries run out
company.createdA company was addedCompanies
company.updatedA company was changedCompanies
deal.createdA deal was createdPipeline
deal.updatedA deal was changedPipeline
deal.wonA deal was wonPipeline
deal.lostA deal was lostPipeline

Inbound leads

The other direction: a URL per workspace that any form, Zapier, Make, n8n or script POSTs leads to. Each lead becomes a contact with the endpoint's tags, list and campaign. The URL carries its own token, so no key or header is needed; create and map endpoints under Inbound → Inbound leads.

  • The body is JSON: one lead as an object, a list of leads, or leads under the key the endpoint names (rows_path, e.g. data.leads); up to 500 leads a request, 256 KB. A form post (application/x-www-form-urlencoded or multipart) is one lead.
  • Fields come from the endpoint's mapping (payload key → contact field, dotted paths, [0] for lists; full_name, city / state / country and website fold into a field; "tags" adds tags; custom:<key> keeps a value as a custom field). With no mapping the keys are matched by name — email, first_name, "Company Name", firstName all work — and every other simple value is kept as a custom field.
  • Every lead gets the endpoint's tags, list and campaign; people already in the workspace are not added twice (existed) and still get them. New contacts fire "A contact was added" (source inbound) for workflows.
  • No code at all: switch on the endpoint's hosted form (Inbound leads) for a page of ours at /f/<link> to link to or embed with an iframe. It takes one lead per answer with only its fields, puts it through this endpoint (its tags, list and campaign) and never shows this URL.
  • Answers: 201 with the import's numbers (errors lists rows without a valid email); 400 bad_payload / no_leads; 402 active_contacts_cap_reached; 403 endpoint_disabled; 404 not_found (unknown, rotated or deleted token); 413 too_large; 429 rate_limited (the workspace's write limit, retry_after_ms in the body).
Post a lead
curl -X POST "https://app.outboundrix.io/api/inbound/obx_in_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -d '{"email":"dana@acme.io","first_name":"Dana","company":"Acme","tags":["webinar"]}'
Response
201 Created
{
  "ok": true, "received": 1, "inserted": 1, "existed": 0, "capped": 0, "errors": [],
  "contact_ids": [1042], "list": null, "campaign": null, "tags": null, "mapped": "guessed"
}

Workflow webhooks

A URL per workflow: another server POSTs to it and that workflow runs (trigger A webhook was received), with the request body in its steps. Make the URL in the workflow's Details; like an inbound endpoint, it carries its own token.

  • The body is JSON (an object or a list), a form post (application/x-www-form-urlencoded) or text ({{trigger.body.text}}); at most 64 KB. Later steps read it as {{trigger.body.<field>}}, dotted for nested fields ({{trigger.body.data.email}}).
  • The run is about a contact when the trigger's "Contact email field" names a field of the body holding the email of someone already in the workspace; nobody is created. Steps that need a contact fail without one.
  • Idempotency-Key: a repeat with the same key within a day answers 200 { "duplicate": true } and starts nothing, so a sender's retries are safe.
  • Answers: 202 with the run; 400 bad_payload; 402 plan_required; 403 workflow_off; 404 not_found (unknown or replaced URL); 409 not_a_webhook_workflow; 413 too_large; 415 for multipart; 429 rate_limited (the workspace's write limit).
Start the workflow
curl -X POST "https://app.outboundrix.io/api/hooks/obx_wh_XXXXXXXX" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001" \
  -d '{"data":{"email":"dana@acme.io"},"order_id":1001,"amount":250}'
Response
202 Accepted
{ "ok": true, "event_id": 699, "run_id": 41, "contact_id": 1042 }

Managing keys, webhooks, inbound endpoints and apps

These are managed in the app, signed in, for the workspace you are in. They are not part of the API: the routes behind those pages answer only the app in a browser.

WhatWhere in the appWhat you can do
API keysSettings → AccessCreate a key with the scopes it needs (the secret is shown once and stored only as a SHA-256 hash), see when each key was last used, revoke one: the next call with it gets 401.
WebhooksIntegrations → WebhooksAdd an endpoint and the events it receives; the whsec_ signing secret is shown once. Send it a signed test, read its last deliveries, pause it, change its events or delete it there.
Inbound lead endpointsInbound → Inbound leadsMint a URL for a form or tool, map its fields, see its last deliveries, rotate its URL (the old one stops at once), switch on its hosted form to share or embed, or delete it.
Workflow webhook URLsWorkflows → a workflow → DetailsFor a workflow that starts on "A webhook was received": make its URL (shown once), see how many requests it took, replace it (the old one stops at once).
Connected appsIntegrations → Connected appsConnect an app with its API key (stored encrypted, never shown again) for workflow App action steps; remove it there.

Not in the API yet

Planned

Building sequences and campaigns (steps, mailboxes, launch) and the reply inbox are dashboard-only today, over the API and the MCP server alike. 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