Outbird

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

Quick 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:readGET /v1/leads, GET /v1/leads/{id}
leads:writePOST /v1/leads, PATCH /v1/leads/{id}
campaigns:readGET /v1/campaigns, GET /v1/campaigns/{id}
campaigns:writePOST /v1/campaigns, PATCH /v1/campaigns/{id}
campaigns:startPOST /v1/campaigns/{id}/{start|pause|resume|stop}
analytics:readGET /v1/analytics, GET /v1/campaigns/{id}/analytics
emails:readGET /v1/emails, GET /v1/emails/{id}
emails:sendPOST /v1/emails/send
account:readGET /v1/me
account:writePOST /v1/company
webhooks:readGET /v1/webhooks
webhooks:writePOST /v1/webhooks, DELETE /v1/webhooks/{id}

Leads

POST/v1/leadsleads:write

Create/dedup leads, optionally link to a campaign

›Request & response

Request

FieldTypeDescription
leadsarrayArray of lead objects. Omit and pass lead fields at the top level for a single lead
leads[].email*stringMust pass RFC-5321 format or that lead is skipped (not a hard error)
leads[].name, .phone, .company, .location, .linkedin, .websitestringStandard fields
any other field on a lead object—Auto-promoted into that lead's metadata
campaignIdstringIf given, every created/existing lead is also linked into this campaign. Validated to exist first — fails fast with 404
forceUpdatebooleantrue = 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

FieldTypeDescription
totalProcessednumberCount of lead entries in the request
leadsCreatednumberNewly created leads
leadsUpdatednumberExisting leads updated (only if forceUpdate:true)
leadsDuplicateSkippednumberExisting leads left untouched
createdLeadIdsstring[]IDs of newly created leads
duplicateLeadsarray[{ email, existingLeadId }]
invalidEmailSkippednumberCount of malformed emails skipped
skippedInvalidEmailsstring[]The actual malformed email strings
campaignIdstring | nullEcho of the request field
campaignLinksAddednumberNew campaign links created
GET/v1/leadsleads:read

List leads (paginated)

›Request & response

Request

FieldTypeDescription
limitnumberMax 100. Default 20
lastKeystringOpaque cursor from a previous response's nextCursor

Response

FieldTypeDescription
leadsLead[]Newest-first
countnumberItems on this page
hasMorebooleanWhether another page exists
nextCursorstring | nullPass as ?lastKey= to fetch the next page
GET/v1/leads/{id}leads:read

Get a single lead

›Request & response
{ "lead": { "leadId": "...", "email": "jane@acme.com", "..." : "..." } }
PATCH/v1/leads/{id}leads:write

Update a lead

›Request & response

Request

FieldTypeDescription
name, phone, company, location, linkedin, website, leadTypestringAll optional, allowlisted
metadataobjectMerged key-by-key, existing keys not in the request are preserved
email—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

POST/v1/campaignscampaigns:write

Create a campaign

›Request & response

Request

FieldTypeDescription
name*string—
campaignTypestringAI | AUTOMATION (default AUTOMATION)
campaignMotostringMARKETING | OUTREACH (default MARKETING)
campaignTagstringFree-text label (default "")
trigger.typestringIMMEDIATE | AFTER_HOURS | CUSTOM_DATETIME (default IMMEDIATE)
schedule.daysstring[]e.g. ["MON","TUE",...] (default [])
schedule.dailyLimitnumberDefault 100
schedule.timeWindow.start / .endstring HH:mmDefault 09:00 / 18:00
campaignQuestionsobjectFree-form key/value map (default {})
emailIndexstringA 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
followUpEmailIndexstringSame 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

FieldTypeDescription
campaignCampaign201 Created, status:"inactive" initially
GET/v1/campaignscampaigns:read

List campaigns (paginated)

›Request & response

Request

FieldTypeDescription
limitnumberDefault 20, max 100
lastKeystringPagination cursor

Response

FieldTypeDescription
campaignsCampaign[]—
count, hasMore, nextCursor—Same pagination shape as GET /v1/leads
GET/v1/campaigns/{id}campaigns:read

Get a single campaign

PATCH/v1/campaigns/{id}campaigns:write

Update 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

FieldTypeDescription
emailIndex, followUpEmailIndexstringRuns the same connected-mailbox validation as POST /v1/campaigns — a bad value returns 400 INVALID_SENDER, nothing is written
POST/v1/campaigns/{id}/startcampaigns:start

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

POST/v1/campaigns/{id}/pausecampaigns:start

Pause a campaign

running/active/starting → paused. Simple status transition only in this version — does not do the dashboard's full cleanup.

POST/v1/campaigns/{id}/resumecampaigns:start

Resume a paused campaign

paused → running. Simple status transition only.

POST/v1/campaigns/{id}/stopcampaigns:start

Stop a campaign

Any active state → stopped. Simple status transition only.

Analytics

GET/v1/analyticsanalytics:read

Account-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 }
}
GET/v1/campaigns/{id}/analyticsanalytics:read

Single-campaign analytics

›Request & response
{ "campaignId":"...", "name":"...", "status":"...", "analytics": {"...":"..."}, "usage": {"leadCount":1} }

Emails (outbox + inbox + send)

GET/v1/emailsemails:read

List emails

›Request & response

Request

FieldTypeDescription
statusstringLifecycle: Scheduled|Sent|Failed|Stopped|GenerationFailed|LowScore. Engagement: Opened|Clicked|Replied|Bounced|Unsubscribed. Omit for the full outbox
box"inbox"Convenience shorthand for ?status=Replied
limitnumberDefault 20, max 100
lastKeystringPagination cursor

Response

FieldTypeDescription
emailsEmail[]—
count, status, hasMore, nextCursor—status echoes the applied filter, "all" if none
GET/v1/emails/{id}emails:read

Get a single email

POST/v1/emails/sendemails:send

Send/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

FieldTypeDescription
to*stringRecipient email address
subject*string—
html*stringHTML body content
emailIndexstringRequired unless the account has exactly one connected mailbox. Numeric position ("0") or the literal connected address — from GET /v1/me → emailConnect[]
tagstringFree-text label for later filtering via GET /v1/emails
leadIdstringAssociates this send with an existing lead
scheduleAtstring (ISO 8601)Omit to send immediately. If given, must be in the future
Idempotency-KeyheaderOptional 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

FieldTypeDescription
messagestring"Email sent"
emailIdstringUse this with GET /v1/emails/{id} to check status later
to, subject, tag, leadId—Echo of the request
scheduledTimeUTCstring | nullnull for an immediate send

Account

GET/v1/meaccount:read

Get account info

›Request & response
{
  "userId": "...", "email": "...", "name": "...",
  "company_website": "https://...", "company_intelligence": { "...": "..." },
  "emailConnect": [ { "email": "...", "displayName": "..." } ],
  "usage": { "containerCount": 1, "leadCount": 2 }, "acceptedTerms": true
}
POST/v1/companyaccount:write

Set/refresh company URL + trigger intelligence extraction

Persists the URL then invokes the same company-intelligence pipeline the dashboard onboarding uses.

›Request & response

Request

FieldTypeDescription
url*stringA 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.

POST/v1/email-accountsaccount:write

Connect a mailbox

›Request & response

Request

FieldTypeDescription
provider*stringgmail | outlook | zoho | yahoo | other
email*stringThe mailbox address itself
password (alias appPassword)*stringNot 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, smtpPortstring, numberRequired when provider:"other" — your mail server's SMTP host/port
securebooleanprovider:"other" only (default false)
displayNamestringShown in the dashboard's sender picker; defaults to the email address
referencestringFree-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 }
GET/v1/email-accountsaccount:read

List 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": "..." } ] }
DELETE/v1/email-accounts/{email}account:write

Disconnect 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

FieldTypeDescription
email*string (path)The connected mailbox address, URL-encoded (e.g. %40 for @)
actionstring (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)
targetEmailstring (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.

POST/v1/webhookswebhooks:write

Register a webhook

›Request & response

Request

FieldTypeDescription
url*stringMust 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."
}
GET/v1/webhookswebhooks:read

List your webhooks

›Request & response
{ "webhooks": [ { "webhookId": "...", "url": "...", "events": ["..."], "status": "active", "secretPrefix": "..." } ] }
DELETE/v1/webhooks/{id}webhooks:write

Remove a webhook

›Request & response
{ "webhookId": "...", "status": "deleted" }

Data models

Lead

Fields

FieldTypeDescription
leadIdstringDeterministic hash of userId:email — same email always maps to the same id
namestringEmpty string if not provided
emailstringLowercased, normalized
phone, company, location, linkedin, websitestringEmpty string if not provided
leadTypestring"api" for API-created leads, "manual" for dashboard-created
metadataobjectFree-form key/value map — any unrecognized field sent on create/update lands here
campaignIdsstring[]Every campaign this lead has been linked into
createdAtstring (ISO 8601)—
sourcestring | null"public_api" for API-created leads, null/other for dashboard-created

Campaign

Fields

FieldTypeDescription
campaignIdstring (UUID)—
namestring—
statusstringinactive | 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.
campaignTypestringAI | AUTOMATION
campaignMotostringMARKETING | OUTREACH
campaignTagstringFree-text label
triggerobject{ type, delayHours, startAt }
scheduleobject{ days[], timezoneType, timezone, dailyLimit, timeWindow:{start,end} }
analyticsAnalyticsSee Analytics object
usageobject{ leadCount } at minimum
emailIndexstring | nullThe connected mailbox this campaign sends from. Must be set (via POST/PATCH) before start will work
followUpEmailIndexstring | nullConnected mailbox used for follow-up rounds; defaults to emailIndex if never set
createdAtstring (ISO 8601)—

Analytics

Fields

FieldTypeDescription
emailsSentnumber—
opens, uniqueOpensnumberTotal opens vs. distinct recipients who opened
clicks, uniqueClicksnumberSame distinction for link clicks
repliesnumber—
bouncesnumber—
unsubscribesnumber—
lastActivityAtstring (ISO 8601) | nullMost recent engagement event of any kind

Email

Fields

FieldTypeDescription
emailIdstring—
to, cc, bcc, replyTostring | null—
fromstring | nullThe connected mailbox the email was/will be sent from
subjectstring | null—
bodystring | nullRaw content (HTML or plain text depending on source)
statusstringPriority order bounced > unsubscribed > replied > clicked > opened > <base lifecycle status>
replyobject | null{ subject, body, from } if the recipient replied, else null
generatedAt, scheduledAt, sentAtstring (ISO 8601) | nullLifecycle timestamps
campaignId, leadIdstring | nullnull for one-off sends not tied to a campaign/lead
tagstring | nullFree-text label, one-off sends only
sourcestring"single" (one-off send) or "history" (campaign-originated)

Account

Fields

FieldTypeDescription
userIdstringThe unique identifier for the authenticated account
email, namestringAccount owner's identity
company_websitestring | nullsnake_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_intelligenceobject | nullSame snake_case naming note as company_website. Onboarding Q&A + campaign questions
emailConnectarray[{ email, displayName }] — connected sending mailboxes
usageobject{ containerCount, leadCount }
acceptedTermsboolean—

Webhook

Fields

FieldTypeDescription
webhookIdstring (UUID)—
urlstringAlways https:// — enforced at creation
eventsstring[]Subscribed event types, or ["*"] for all
statusstringAlways "active" today (no pause/disable action — delete + recreate instead)
createdAtstring (ISO 8601)—
secretstringOnly present in the POST create response, exactly once. Never returned again — store it immediately
secretPrefixstringFirst 8 chars of the secret, returned on GET list instead of the full secret

EmailAccount

Fields

FieldTypeDescription
emailstringThe connected mailbox address
providerstringgmail | outlook | zoho | yahoo | other
displayNamestringShown in the dashboard's sender picker; defaults to the email address
smtpHost, smtpPortstring, numberResolved automatically for gmail/outlook/zoho/yahoo, or as given for other
imapHost, imapPortstring, numberSame resolution rules as SMTP
connectedAtstring (ISO 8601)—

Status codes

200Successful GET/POST (non-creating)
201Resource created (POST /v1/leads, /v1/campaigns, /v1/emails/send)
400Validation failure — malformed input, missing required field
401Missing/malformed/expired/revoked API key, or wrong scope for the route
403Reserved / observed in one rare authorizer edge case
404Resource doesn't exist, or isn't owned by this key's account
405Method not allowed on that path
409Campaign action attempted from an incompatible status, or a duplicate in-flight Idempotency-Key
422POST /v1/company only — bad/unreachable URL (user input, not a bug)
429Per-key rate limit exceeded — includes a Retry-After header, honor it before retrying
500Server error — the requestId/X-Request-Id on this response identifies it for support
502POST /v1/company only — genuine scrape failure

Rate limits

LayerLimitScope
Per-API-key60 requests / minute (default)Each individual key has its own independent budget
Stage-wide (API Gateway)25 requests/sec steady-state, burst 50Shared across all keys combined