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, 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.
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.
Scope
Unlocks
contacts:read
GET /contacts, GET /contacts/{id}
contacts:write
POST /contacts, POST /contacts/bulk, PATCH /contacts/{id}, DELETE /contacts/{id}, POST /people/import
sequences:read
GET /sequences
sequences:write
POST /sequences/{id}/enroll, POST /sequences/{id}/unenroll
campaigns:read
GET /campaigns
campaigns:write
POST /campaigns/{id}/contacts
status:read
GET /status
mailboxes:read
GET /inbox
mailboxes:write
POST /inbox/add, GET /inbox/add
Endpoints
GET/api/v1/contactscontacts:read
List the workspace's contacts, newest first. Offset pagination; no cursor.
Query parameters
Name
Type
Rules
limit
integer
1–200, default 50 (out-of-range values are clamped)
offset
integer
≥0, default 0
query
string
case-insensitive substring match on first_name, last_name, email, company
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
Name
Type
Rules
first_name
string
1–120 chars
last_name
string
≤120
email
string
valid email, ≤254
email_status
string
≤40
phone
string
≤40
title
string
≤160
company
string
≤200
company_id
integer
positive, id of a workspace company
industry
string
≤120
location
string
≤160
employees
string
≤40, free text e.g. "51-200"
score
integer
0–100
status
string
≤40, defaults to "new"
notes
string
≤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"}'
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
Name
Type
Rules
contacts
array
required, 0–500 objects
contacts[].email
string
required per row; rows without an "@" are skipped, not errored
contacts[].first_name … notes
string
first_name, last_name, email_status, phone, title, company, industry, location, employees, notes; truncated to the same lengths as POST /contacts
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
Name
Type
Rules
filters
object
required, 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_new
boolean
default true: people the workspace already saved are not taken
limit
integer
1–1,000, default 100: the first matches are taken
list_id | list_name
integer | string
optional: add them to that list, or to a new list of that name (≤120)
tags
array
optional 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"}'
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".
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).
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).
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.
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
Name
Type
Rules
msp
string
google | microsoft | zoho | mailjet | smtp (also accepted in the body, per mailbox)
verify
boolean
default true; false skips the login test
JSON body
Name
Type
Rules
email
string
required; a custom domain (free-mail domains are refused)
app_password / password
string
required; Google Workspace needs an app password; msp=mailjet: the secret key
username
string
SMTP login, defaults to email; msp=mailjet: the API key (required)
first_name, last_name / display_name
string
sender name
daily_limit
integer
at least 1, default 25; above 25 is stored as 25 (the most any mailbox sends a day)
warmup
boolean
ignored: every mailbox follows the send schedule, 7 / 10 / 20 a day in weeks 1-3, then 25
smtp_host, smtp_port, imap_host, imap_port
string / integer
override the preset; smtp_host required for msp=smtp; IMAP is TLS on 993 (143 is refused)
imap_user, imap_pass
string
the 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)
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.
Status
code
When
400
bad_request / validation_error
Body is not JSON, or fails the schema. validation_error carries fieldErrors (company_id that is not one of your companies included).
400
campaign_closed
Adding contacts to a campaign that is completed (only draft, paused and active take recipients).
401
unauthorized
Authorization header missing, not obx_live_sk_…, unknown, or revoked.
402
cap_reached
Create or bulk import refused: the workspace is at its plan's active-contacts cap. Body carries used, cap, plan.
403
forbidden
Key is valid but lacks the scope. Body carries required_scope.
404
not_found
The contact, sequence or campaign id is not in your workspace (another workspace's id looks the same as one that does not exist).
413
too_large
Bulk batch over 500 contacts, more than 1000 contact_ids in one call, or more than 25 mailboxes.
429
rate_limited
Per-key token bucket empty.
503
unavailable
Database 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.
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
Event
When
Sent by
contact.created
A person was added
People (by hand, CSV import, the lead scraper, a database save), inbound endpoints, POST /api/v1/contacts
contact.updated
A person was changed
People, a deal stage automation, PATCH /api/v1/contacts/{id}
contact.deleted
A person was deleted
DELETE /api/v1/contacts/{id}
contact.unsubscribed
A person unsubscribed
The unsubscribe link or one-click unsubscribe in an email
sequence.started
A person was added to a sequence
A campaign launch, Add to sequence, a workflow, POST /api/v1/sequences/{id}/enroll
email.sent
An email was sent
The sender
email.opened
An email was opened
The open pixel (once per email per day)
email.clicked
A link was clicked
A tracked link (link scanners are left out)
reply.received
A reply came in
Reply reading from Gmail, Microsoft 365 and IMAP mailboxes
reply.categorized
A reply was labelled
Reply 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.bounced
An email bounced
The sender (a permanent recipient failure)
email.send_failed
A send failed
The sender (each failed attempt)
sequence.completed
A person finished a sequence
The sender, after the last step
sequence.failed
A person was stopped after failed sends
The sender, when the retries run out
company.created
A company was added
Companies
company.updated
A company was changed
Companies
deal.created
A deal was created
Pipeline
deal.updated
A deal was changed
Pipeline
deal.won
A deal was won
Pipeline
deal.lost
A deal was lost
Pipeline
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).
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).
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.
What
Where in the app
What you can do
API keys
Settings → Access
Create 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.
Webhooks
Integrations → Webhooks
Add 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 endpoints
Inbound → Inbound leads
Mint 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 URLs
Workflows → a workflow → Details
For 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 apps
Integrations → Connected apps
Connect 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.