API reference
Build on the execution layer.
The Outbird API exposes prospects, campaigns and sending so you can drive outreach from your own systems. Authenticate with a bearer token, scope it to what you need, and every response comes back as JSON.
Authorization: Bearer aw_live_…
Base URL: https://api.outbird.devQuick start
Create a key in the dashboard, scope it, and send your first request.
curl -X POST "https://api.outbird.dev/v1/leads" \
-H "Authorization: Bearer aw_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{"leads":[{"email":"jane@acme.com","name":"Jane Doe"}]}'Scopes
Keys carry only the scopes you grant. A request outside its scope returns 403.
| leads:read | GET /v1/leads, GET /v1/leads/{id} |
| leads:write | POST /v1/leads, PATCH /v1/leads/{id} |
| campaigns:read | GET /v1/campaigns, GET /v1/campaigns/{id} |
| campaigns:write | POST /v1/campaigns, PATCH /v1/campaigns/{id} |
| campaigns:start | POST /v1/campaigns/{id}/{start|pause|resume|stop} |
| analytics:read | GET /v1/analytics, GET /v1/campaigns/{id}/analytics |
| emails:read | GET /v1/emails, GET /v1/emails/{id} |
| emails:send | POST /v1/emails/send |
| account:read | GET /v1/me |
| account:write | POST /v1/company |
| webhooks:read | GET /v1/webhooks |
| webhooks:write | POST /v1/webhooks, DELETE /v1/webhooks/{id} |
Leads
/v1/leadsleads:writeCreate/dedup leads, optionally link to a campaign
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| leads | array | Array of lead objects. Omit and pass lead fields at the top level for a single lead |
| leads[].email* | string | Must pass RFC-5321 format or that lead is skipped (not a hard error) |
| leads[].name, .phone, .company, .location, .linkedin, .website | string | Standard fields |
| any other field on a lead object | — | Auto-promoted into that lead's metadata |
| campaignId | string | If given, every created/existing lead is also linked into this campaign. Validated to exist first — fails fast with 404 |
| forceUpdate | boolean | true = overwrite name/phone/company on an existing (duplicate-email) lead. Default false = skip duplicates silently |
{
"leads": [
{ "email": "jane@acme.com", "name": "Jane Doe", "company": "Acme", "phone": "+1234567890" }
],
"campaignId": "optional-existing-campaign-id",
"forceUpdate": false
}Response
| Field | Type | Description |
|---|---|---|
| totalProcessed | number | Count of lead entries in the request |
| leadsCreated | number | Newly created leads |
| leadsUpdated | number | Existing leads updated (only if forceUpdate:true) |
| leadsDuplicateSkipped | number | Existing leads left untouched |
| createdLeadIds | string[] | IDs of newly created leads |
| duplicateLeads | array | [{ email, existingLeadId }] |
| invalidEmailSkipped | number | Count of malformed emails skipped |
| skippedInvalidEmails | string[] | The actual malformed email strings |
| campaignId | string | null | Echo of the request field |
| campaignLinksAdded | number | New campaign links created |
/v1/leadsleads:readList leads (paginated)
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| limit | number | Max 100. Default 20 |
| lastKey | string | Opaque cursor from a previous response's nextCursor |
Response
| Field | Type | Description |
|---|---|---|
| leads | Lead[] | Newest-first |
| count | number | Items on this page |
| hasMore | boolean | Whether another page exists |
| nextCursor | string | null | Pass as ?lastKey= to fetch the next page |
/v1/leads/{id}leads:readGet a single lead
›Request & response
{ "lead": { "leadId": "...", "email": "jane@acme.com", "..." : "..." } }/v1/leads/{id}leads:writeUpdate a lead
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| name, phone, company, location, linkedin, website, leadType | string | All optional, allowlisted |
| metadata | object | Merged key-by-key, existing keys not in the request are preserved |
| — | Immutable via PATCH (it's the dedup key) — 400 EMAIL_IMMUTABLE if included |
{ "name": "New Name", "phone": "+1...", "company": "...", "location": "...",
"linkedin": "...", "website": "...", "leadType": "...", "metadata": { "custom_field": "x" } }Campaigns
/v1/campaignscampaigns:writeCreate a campaign
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| name* | string | — |
| campaignType | string | AI | AUTOMATION (default AUTOMATION) |
| campaignMoto | string | MARKETING | OUTREACH (default MARKETING) |
| campaignTag | string | Free-text label (default "") |
| trigger.type | string | IMMEDIATE | AFTER_HOURS | CUSTOM_DATETIME (default IMMEDIATE) |
| schedule.days | string[] | e.g. ["MON","TUE",...] (default []) |
| schedule.dailyLimit | number | Default 100 |
| schedule.timeWindow.start / .end | string HH:mm | Default 09:00 / 18:00 |
| campaignQuestions | object | Free-form key/value map (default {}) |
| emailIndex | string | A connected mailbox address from GET /v1/me's emailConnect[], or its numeric array index. Required before start will succeed — can also be set later via PATCH. 400 INVALID_SENDER if not a real connected mailbox |
| followUpEmailIndex | string | Same rules as emailIndex; defaults to emailIndex internally if never set |
{
"name": "Q3 Outreach",
"campaignType": "AUTOMATION",
"campaignMoto": "OUTREACH",
"campaignTag": "",
"trigger": { "type": "IMMEDIATE" },
"schedule": { "days": ["MON","TUE","WED","THU","FRI"], "dailyLimit": 100,
"timeWindow": { "start": "09:00", "end": "18:00" } },
"campaignQuestions": {},
"emailIndex": "you@yourdomain.com"
}Response
| Field | Type | Description |
|---|---|---|
| campaign | Campaign | 201 Created, status:"inactive" initially |
/v1/campaignscampaigns:readList campaigns (paginated)
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| limit | number | Default 20, max 100 |
| lastKey | string | Pagination cursor |
Response
| Field | Type | Description |
|---|---|---|
| campaigns | Campaign[] | — |
| count, hasMore, nextCursor | — | Same pagination shape as GET /v1/leads |
/v1/campaigns/{id}campaigns:readGet a single campaign
/v1/campaigns/{id}campaigns:writeUpdate a campaign
Allowlisted body fields: name, campaignTag, campaignMoto, emailIndex, followUpEmailIndex (schedule/trigger internals still aren't PATCHable via v1). This is how to fix a campaign that was created before emailIndex was set — PATCH { emailIndex: "you@yourdomain.com" }, then retry start.
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| emailIndex, followUpEmailIndex | string | Runs the same connected-mailbox validation as POST /v1/campaigns — a bad value returns 400 INVALID_SENDER, nothing is written |
/v1/campaigns/{id}/startcampaigns:startStart a campaign
Invokes the real campaign-start pipeline (same as the dashboard) — begins drafting/sending real emails to every lead in the campaign. Requires emailIndex to be set first (via POST/PATCH) or it fails with 400.
/v1/campaigns/{id}/pausecampaigns:startPause a campaign
running/active/starting → paused. Simple status transition only in this version — does not do the dashboard's full cleanup.
/v1/campaigns/{id}/resumecampaigns:startResume a paused campaign
paused → running. Simple status transition only.
/v1/campaigns/{id}/stopcampaigns:startStop a campaign
Any active state → stopped. Simple status transition only.
Analytics
/v1/analyticsanalytics:readAccount-wide analytics
›Request & response
{
"analytics": { "emailsSent":0,"opens":0,"uniqueOpens":0,"clicks":0,"uniqueClicks":0,
"replies":0,"bounces":0,"unsubscribes":0,"lastActivityAt":null },
"emailStats": { "total":0,"scheduled":0,"sent":0,"failed":0,"stopped":0,
"generationFailed":0,"lowScore":0,"opened":0,"clicked":0,"replied":0,"bounced":0,
"unsubscribed":0 },
"usage": { "containerCount":1,"leadCount":2 }
}/v1/campaigns/{id}/analyticsanalytics:readSingle-campaign analytics
›Request & response
{ "campaignId":"...", "name":"...", "status":"...", "analytics": {"...":"..."}, "usage": {"leadCount":1} }Emails (outbox + inbox + send)
/v1/emailsemails:readList emails
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| status | string | Lifecycle: Scheduled|Sent|Failed|Stopped|GenerationFailed|LowScore. Engagement: Opened|Clicked|Replied|Bounced|Unsubscribed. Omit for the full outbox |
| box | "inbox" | Convenience shorthand for ?status=Replied |
| limit | number | Default 20, max 100 |
| lastKey | string | Pagination cursor |
Response
| Field | Type | Description |
|---|---|---|
| emails | Email[] | — |
| count, status, hasMore, nextCursor | — | status echoes the applied filter, "all" if none |
/v1/emails/{id}emails:readGet a single email
/v1/emails/sendemails:sendSend/schedule a one-off email
Real send pipeline — a real email was sent and delivered during testing. The only endpoint with Idempotency-Key support today: pass an Idempotency-Key header (any client-generated unique string, e.g. a UUID) to make retries safe — a retry with the same key after the first completed returns the exact original response verbatim (Idempotency-Replayed: true header, no new email sent); a retry while the first is still executing gets 409 IDEMPOTENCY_KEY_IN_PROGRESS. Claims expire after 24 hours.
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| to* | string | Recipient email address |
| subject* | string | — |
| html* | string | HTML body content |
| emailIndex | string | Required unless the account has exactly one connected mailbox. Numeric position ("0") or the literal connected address — from GET /v1/me → emailConnect[] |
| tag | string | Free-text label for later filtering via GET /v1/emails |
| leadId | string | Associates this send with an existing lead |
| scheduleAt | string (ISO 8601) | Omit to send immediately. If given, must be in the future |
| Idempotency-Key | header | Optional but recommended — any client-generated unique string (e.g. a UUID). Makes retries of this exact request safe from duplicate sends |
{
"to": "jane@acme.com",
"subject": "Quick question",
"html": "<p>Hi Jane...</p>",
"emailIndex": "your-connected@gmail.com",
"tag": "api-outreach",
"leadId": "optional-lead-id",
"scheduleAt": "2026-09-01T10:00:00.000Z"
}Response
| Field | Type | Description |
|---|---|---|
| message | string | "Email sent" |
| emailId | string | Use this with GET /v1/emails/{id} to check status later |
| to, subject, tag, leadId | — | Echo of the request |
| scheduledTimeUTC | string | null | null for an immediate send |
Account
/v1/meaccount:readGet account info
›Request & response
{
"userId": "...", "email": "...", "name": "...",
"company_website": "https://...", "company_intelligence": { "...": "..." },
"emailConnect": [ { "email": "...", "displayName": "..." } ],
"usage": { "containerCount": 1, "leadCount": 2 }, "acceptedTerms": true
}/v1/companyaccount:writeSet/refresh company URL + trigger intelligence extraction
Persists the URL then invokes the same company-intelligence pipeline the dashboard onboarding uses.
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| url* | string | A fully-qualified URL |
{ "url": "https://example.com" }Email Accounts
Connect/list/disconnect the sending mailboxes used by POST /v1/emails/send and emailIndex/followUpEmailIndex on campaigns. Credentials are live-verified against the real provider before anything is saved — a real SMTP login (+ a real test email send), then a real IMAP login. No plaintext password is ever returned in any response.
/v1/email-accountsaccount:writeConnect a mailbox
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| provider* | string | gmail | outlook | zoho | yahoo | other |
| email* | string | The mailbox address itself |
| password (alias appPassword)* | string | Not your normal login password for gmail/outlook/yahoo/zoho — generate a provider app password and use that. For provider:"other", this is the mailbox's real SMTP/IMAP password |
| smtpHost, smtpPort | string, number | Required when provider:"other" — your mail server's SMTP host/port |
| secure | boolean | provider:"other" only (default false) |
| displayName | string | Shown in the dashboard's sender picker; defaults to the email address |
| reference | string | Free-text label for your own bookkeeping |
{ "provider": "gmail", "email": "you@yourdomain.com", "password": "<app password>" }{ "message": "gmail account connected successfully", "email": "you@yourdomain.com", "totalAccounts": 2 }/v1/email-accountsaccount:readList connected mailboxes
Passwords/app-passwords are never included — this endpoint only ever returns connection metadata.
›Request & response
{ "emailAccounts": [ { "email": "...", "provider": "gmail", "displayName": "...", "smtpHost": "...", "smtpPort": 465, "imapHost": "...", "imapPort": 993, "connectedAt": "..." } ] }/v1/email-accounts/{email}account:writeDisconnect a mailbox
Removes a connected sending mailbox. If it's still in use by an active campaign, the request is rejected unless you tell it how to handle those campaigns (see the action query parameter below).
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| email* | string (path) | The connected mailbox address, URL-encoded (e.g. %40 for @) |
| action | string (query) | Only needed if the mailbox is in use by an active campaign (409 CAMPAIGNS_ACTIVE). One of: cancel (abort, mailbox stays connected), migrate (requires targetEmail, reassigns those campaigns first, then disconnects), continue (disconnect anyway; those campaigns fail at their next send until reassigned) |
| targetEmail | string (query) | Required when action=migrate — another connected mailbox address to reassign the affected campaigns to |
{ "email": "you@yourdomain.com", "status": "disconnected" }Webhooks
Subscribe a URL to be notified in near-real-time when events happen on your account, instead of polling GET /v1/emails / GET /v1/campaigns/{id}. Delivery is asynchronous (via an internal queue) — expect a delay of a few seconds between the event happening and your endpoint receiving the POST, not instant delivery.
/v1/webhookswebhooks:writeRegister a webhook
›Request & response
Request
| Field | Type | Description |
|---|---|---|
| url* | string | Must be https:// — plain http:// is rejected with 400 INVALID_URL |
| events* | string[] | Array of event types, or ["*"]. Empty array → 400 MISSING_EVENTS |
{ "url": "https://your-server.example.com/webhooks/outbird", "events": ["email.sent", "campaign.started"] }{
"webhookId": "...", "url": "...", "events": ["email.sent", "campaign.started"],
"status": "active", "createdAt": "...",
"secret": "<64 hex chars — SHOWN ONLY THIS ONCE>",
"warning": "Store this secret now — it will not be shown again. Use it to verify the X-AutoWorkx-Signature header on incoming deliveries."
}/v1/webhookswebhooks:readList your webhooks
›Request & response
{ "webhooks": [ { "webhookId": "...", "url": "...", "events": ["..."], "status": "active", "secretPrefix": "..." } ] }/v1/webhooks/{id}webhooks:writeRemove a webhook
›Request & response
{ "webhookId": "...", "status": "deleted" }Data models
Lead
Fields
| Field | Type | Description |
|---|---|---|
| leadId | string | Deterministic hash of userId:email — same email always maps to the same id |
| name | string | Empty string if not provided |
| string | Lowercased, normalized | |
| phone, company, location, linkedin, website | string | Empty string if not provided |
| leadType | string | "api" for API-created leads, "manual" for dashboard-created |
| metadata | object | Free-form key/value map — any unrecognized field sent on create/update lands here |
| campaignIds | string[] | Every campaign this lead has been linked into |
| createdAt | string (ISO 8601) | — |
| source | string | null | "public_api" for API-created leads, null/other for dashboard-created |
Campaign
Fields
| Field | Type | Description |
|---|---|---|
| campaignId | string (UUID) | — |
| name | string | — |
| status | string | inactive | starting | running | active | paused | scheduled | completed | failed | stopped. NOTE: starting/running/active are currently treated as the SAME "in progress" state — don't branch on which one you see, check for any of the three. |
| campaignType | string | AI | AUTOMATION |
| campaignMoto | string | MARKETING | OUTREACH |
| campaignTag | string | Free-text label |
| trigger | object | { type, delayHours, startAt } |
| schedule | object | { days[], timezoneType, timezone, dailyLimit, timeWindow:{start,end} } |
| analytics | Analytics | See Analytics object |
| usage | object | { leadCount } at minimum |
| emailIndex | string | null | The connected mailbox this campaign sends from. Must be set (via POST/PATCH) before start will work |
| followUpEmailIndex | string | null | Connected mailbox used for follow-up rounds; defaults to emailIndex if never set |
| createdAt | string (ISO 8601) | — |
Analytics
Fields
| Field | Type | Description |
|---|---|---|
| emailsSent | number | — |
| opens, uniqueOpens | number | Total opens vs. distinct recipients who opened |
| clicks, uniqueClicks | number | Same distinction for link clicks |
| replies | number | — |
| bounces | number | — |
| unsubscribes | number | — |
| lastActivityAt | string (ISO 8601) | null | Most recent engagement event of any kind |
Fields
| Field | Type | Description |
|---|---|---|
| emailId | string | — |
| to, cc, bcc, replyTo | string | null | — |
| from | string | null | The connected mailbox the email was/will be sent from |
| subject | string | null | — |
| body | string | null | Raw content (HTML or plain text depending on source) |
| status | string | Priority order bounced > unsubscribed > replied > clicked > opened > <base lifecycle status> |
| reply | object | null | { subject, body, from } if the recipient replied, else null |
| generatedAt, scheduledAt, sentAt | string (ISO 8601) | null | Lifecycle timestamps |
| campaignId, leadId | string | null | null for one-off sends not tied to a campaign/lead |
| tag | string | null | Free-text label, one-off sends only |
| source | string | "single" (one-off send) or "history" (campaign-originated) |
Account
Fields
| Field | Type | Description |
|---|---|---|
| userId | string | The unique identifier for the authenticated account |
| email, name | string | Account owner's identity |
| company_website | string | null | snake_case — inconsistent with the rest of this API (everything else is camelCase). Known naming inconsistency, not a typo; kept as-is for backward compatibility. |
| company_intelligence | object | null | Same snake_case naming note as company_website. Onboarding Q&A + campaign questions |
| emailConnect | array | [{ email, displayName }] — connected sending mailboxes |
| usage | object | { containerCount, leadCount } |
| acceptedTerms | boolean | — |
Webhook
Fields
| Field | Type | Description |
|---|---|---|
| webhookId | string (UUID) | — |
| url | string | Always https:// — enforced at creation |
| events | string[] | Subscribed event types, or ["*"] for all |
| status | string | Always "active" today (no pause/disable action — delete + recreate instead) |
| createdAt | string (ISO 8601) | — |
| secret | string | Only present in the POST create response, exactly once. Never returned again — store it immediately |
| secretPrefix | string | First 8 chars of the secret, returned on GET list instead of the full secret |
EmailAccount
Fields
| Field | Type | Description |
|---|---|---|
| string | The connected mailbox address | |
| provider | string | gmail | outlook | zoho | yahoo | other |
| displayName | string | Shown in the dashboard's sender picker; defaults to the email address |
| smtpHost, smtpPort | string, number | Resolved automatically for gmail/outlook/zoho/yahoo, or as given for other |
| imapHost, imapPort | string, number | Same resolution rules as SMTP |
| connectedAt | string (ISO 8601) | — |
Status codes
| 200 | Successful GET/POST (non-creating) |
| 201 | Resource created (POST /v1/leads, /v1/campaigns, /v1/emails/send) |
| 400 | Validation failure — malformed input, missing required field |
| 401 | Missing/malformed/expired/revoked API key, or wrong scope for the route |
| 403 | Reserved / observed in one rare authorizer edge case |
| 404 | Resource doesn't exist, or isn't owned by this key's account |
| 405 | Method not allowed on that path |
| 409 | Campaign action attempted from an incompatible status, or a duplicate in-flight Idempotency-Key |
| 422 | POST /v1/company only — bad/unreachable URL (user input, not a bug) |
| 429 | Per-key rate limit exceeded — includes a Retry-After header, honor it before retrying |
| 500 | Server error — the requestId/X-Request-Id on this response identifies it for support |
| 502 | POST /v1/company only — genuine scrape failure |
Rate limits
| Layer | Limit | Scope |
|---|---|---|
| Per-API-key | 60 requests / minute (default) | Each individual key has its own independent budget |
| Stage-wide (API Gateway) | 25 requests/sec steady-state, burst 50 | Shared across all keys combined |