Campaigns, leads, mailboxes and unibox threads are clean REST resources at www.numail.ai/api/v1, running on the same services as the app. Bearer auth with a nm_live_ key. HMAC-signed inbound webhooks for Gmail and Microsoft Graph. Honest about what's shipped, ambitious about the DX.
Generate a key in Settings · API Keys, then call https://www.numail.ai/api/v1.
Every request carries an Authorization: Bearer nm_live_••• header. Keys are minted per workspace from Settings · API Keys, carry scopes, and you can revoke and re-issue them at any time. A call outside the key's scopes answers 403.
# List your campaigns curl https://www.numail.ai/api/v1/campaigns \ -H "Authorization: Bearer nm_live_•••" # → { # "data": [ # { "id": "79e425d8-708f-44bc-812b-77930112c9a5", # "name": "Q2 outbound · cohort B", "status": "active", # "totalLeads": 4820, "totalSent": 9311, "totalReplied": 187, … } # ], # "pagination": { "page": 1, "pageSize": 50, "totalPages": 1, "total": 1 } # }
Everything in this reference is live today at https://www.numail.ai/api/v1 and answers a workspace key. The app itself runs on separate session routes, and some of what it does is not on /v1 yet: A/B tests, workspaces and members, campaign archive, duplicate and export, mailbox OAuth, and unibox actions such as one-click AI draft approval. Those sit on the roadmap below, next to an OpenAPI spec and official SDKs, which are not shipped yet either. We'll keep the /v1 shapes backwards-compatible as the contract grows.
Every path below hangs off https://www.numail.ai/api/v1 and answers a Bearer key that holds the scope shown with it. List, get, create and update, plus action sub-routes for the verbs that matter (/start, /pause, /enroll, /reply). Sequences live inside campaigns; there's no separate sequences resource. Ids are UUIDs. Campaign, lead and thread lists return 50 rows a page: pass ?page=2 for the next.
# GET /campaigns · campaigns:read status draft | active | paused | completed | archived search name contains sort newest | oldest | name_asc | sent_desc | reply_rate_desc page 1, 2, 3 … → data[]: id, name, status, totalLeads, totalSent, totalOpened, totalClicked, totalReplied, startedAt, createdAt pagination: page, pageSize, totalPages, total # POST /campaigns · campaigns:write · 201 name string required mailboxIds uuid[] required, one or more of your mailboxes seqSteps step[] required, one or more delayDays number required delayHours number default 0 variants array required: subject, body, variantGroup?, weight? for an A/B test give two or more variants, each with its own variantGroup ("a", "b", ...); left out, the step is not split and only one variant sends. Use the same letters on every step: a lead keeps one letter for the whole sequence leads lead[] optional: email, firstName, lastName, company, title, website, tags, customFields trackOpens boolean default true trackClicks boolean default false stopOnReply boolean default true scheduleJson object optional: sendWindowStart, sendWindowEnd, timezone, skipWeekends and the other campaign options launchNow boolean default false; true also needs campaigns:execute → data: { id } # GET /campaigns/:id · campaigns:read → data: { campaign, stats, sequence, dailyVolume, replyClassifications, recentActivity } # POST /campaigns/:id/start | /pause | /resume · campaigns:execute → { "ok": true } start: a draft with a step, a subject on step 1 and an active or warming mailbox pause: an active campaign, and it still works on a paused workspace resume: a paused campaign
# GET /leads · leads:read status active | unsubscribed | bounced | suppressed search email, name or company contains sort newest | oldest | name_asc | name_desc | company_asc page 1, 2, 3 … → data[]: id, email, firstName, lastName, company, title, source, status, customFields, createdAt pagination: page, pageSize, totalPages, total # POST /leads · leads:write · 201 email string required firstName string optional, and so are lastName, company, title status string default active → data: the lead · 409 conflict when the address is already a lead # GET /leads/:id · leads:read → data: { lead, enrollments, stats, timeline } # PATCH /leads/:id · leads:write status string unsubscribed or suppressed also stops its sends customFields object replaces the stored object → data: the lead # POST /leads/:id/enroll · leads:write · 201 campaignId uuid required; the lead and the campaign must be active → data: { campaignLeadId } · 409 conflict when already enrolled # POST /leads/import · leads:write · 201 rows lead[] required: email, firstName, lastName, company, title, tags (string[]), customFields (object) globalTags string[] optional, added to every row → data: { inserted, skipped } skipped = duplicates, in the batch or already stored
# GET /mailboxes · mailboxes:read · Gmail, Microsoft 365 and SMTP, not paged status pending | active | warming | paused | error | disconnected provider gmail | m365 | smtp tier healthy | watch | risk | unscored email one exact address, to check before you push it sort reputation_desc | reputation_asc | sent_today_desc | connected_newest | email_asc → data: { mailboxes: [{ id, email, displayName, provider, status, dailyLimit, dailySentCount, warmupEnabled, warmupDailyTarget, reputationScore, connectedAt }], summary } # POST /mailboxes · mailboxes:write · snake_case body email string required, on a business domain smtp_host string required smtp_port number required smtp_password string required smtp_username string default: the email imap_host string default: smtp_host with smtp. swapped for imap. imap_port number default 993 imap_username string default: the SMTP username imap_password string default: the SMTP password first_name string optional, and so is last_name daily_limit number 1 to 500, default 30 warmup_enabled boolean optional warmup_daily_target number 1 to 200 → 200 data: { id, email, created } created false = credentials refreshed SMTP and IMAP are tested right after the response 409 conflict when Google or Microsoft already holds the address
# GET /threads · threads:read · real replies, newest first status active (default) | archived | snoozed | all classification positive | negative | question | ooo | unsubscribe | forward | bounce | other mailboxId uuid campaignId uuid search subject, sender or reply text contains warmup only, for the warmup pool's threads instead page 1, 2, 3 … → data[]: id, subject, fromEmail, fromName, latestClassification, latestMessageAt, messageCount, status, preview, mailboxEmail, campaignName, hasAiDraft, hasUnread pagination: page, pageSize, totalPages, total # GET /threads/:id · threads:read → data: { id, subject, status, latestClassification, isWarmup, messages[], lead, campaignLead, aiDraft: { body, inboundMessageId } or null } aiDraft.body is plain text with line breaks, not HTML # POST /threads/:id/reply · threads:reply to string required, the address you are answering bodyHtml string required → data: { providerMessageId } sent as "Re: subject" from the thread's mailbox, threaded on the last reply; a reply sent this way retires the AI draft
# Every refusal has the same body { "error": "validation_error", "message": "mailboxIds: mailboxIds must name at least one mailbox", "field": "mailboxIds" } 400 invalid_json · validation_error · invalid_request 401 unauthorized missing, malformed, revoked or expired key 403 forbidden the key lacks the scope ("Missing scope: leads:write"), or the workspace is at its mailbox cap 403 workspace_inactive a write on a paused workspace; reads and pause still work 403 limit_reached the plan's lead cap 404 not_found no such id in this key's workspace 409 conflict already a lead, already enrolled, or an OAuth mailbox 500 internal_error an error on our side
https://www.numail.ai/api/v1. What the app does that /v1 does not serve yet is on the roadmap.Two distinct things: keys that authenticate your API calls, and OAuth flows that let NuMail send from real mailboxes.
Mint a key in Settings · API Keys and pass it as a Bearer token. Keys are scoped to a single workspace; agencies issue one per client workspace.
Authorization: Bearer nm_live_•••* and Read-only is read:*/api/v1nm_test_ prefix planned for sandbox keysConnecting a Google or Microsoft inbox is a hosted OAuth handoff, and it runs in the app rather than over an API key. SMTP/IMAP inboxes can be pushed in over the API.
POST /api/v1/mailboxesGET /api/v1/mailboxes?email= checks an address firstNuMail receives provider push notifications (Gmail and Microsoft Graph) at dedicated inbound endpoints. Each is verified with a per-workspace HMAC-SHA256 secret before the reply pipeline runs and fires the 2.4s draft.
POST /api/webhooks/gmailGmail Pub/Sub pushPOST /api/webhooks/graphMS Graph change notificationreply detected2.4s median, inbound parsedclassificationpositive · negative · question · ooo · unsubscribe · forward · bounce · otherai_draft readyClaude writes in your voicethread surfacedlands in the unibox to approve# Inbound: Gmail Pub/Sub hits /api/webhooks/gmail # NuMail verifies the per-workspace HMAC, then # parses the reply and emits an internal event: { "event": "reply.received", "workspace_id": "2e68e0ca-04b2-492d-93f7-5aa8a88b4071", "source": "gmail", "created_at": "2026-06-24T13:32:11.043Z", "data": { "thread_id": "fcc1374c-96bd-4c4c-9c06-c76f16117e5d", "campaign_id": "79e425d8-708f-44bc-812b-77930112c9a5", "lead_id": "50784630-3387-4a29-9a62-92ef1c279a80", "classification": "positive", "detected_in_ms": 2400, "ai_draft": { "id": "14594a00-945a-4fa0-a44c-da22b2534a4f", "body": "Hey Sarah, usually a week…" } } } # Signature header: # X-NuMail-Signature: t=1750771931,v1=…
The Gmail and Graph receivers are live and HMAC-verified today. Outbound webhooks are live too: add an endpoint in Settings · Webhooks and NuMail pushes reply.received, reply.positive, campaign.completed, lead.bounced and mailbox.disconnected to your URL, signed with HMAC-SHA256 in an X-Webhook-Signature header. Events carry ids and metadata, not message bodies: fetch the text with GET /api/v1/threads/:id, or poll GET /api/v1/threads instead.
Straight about what's shipped versus what's coming. Every call in the reference above works now; anything marked Planned or Building does not, yet.
Campaigns, leads, SMTP mailboxes and unibox threads at www.numail.ai/api/v1, with scoped keys and one error shape, callable now with a workspace key.
Mint, scope and rotate nm_live_ keys from Settings · API Keys today.
Gmail Pub/Sub and Microsoft Graph receivers, HMAC-verified per workspace, powering 2.4s reply detection.
Not public yet: A/B tests, workspaces and members, campaign archive, duplicate and export, mailbox OAuth and test sends, and unibox actions such as approving an AI draft, snoozing or archiving. They run in the app today on session routes a key cannot call.
A published spec at /v1/openapi.json and generated TypeScript & Python clients.
Signed events pushed to your endpoints, plus 14 MCP tools at www.numail.ai/api/mcp so AI agents can run campaigns conversationally.
Grab a workspace key from Settings · API Keys and start driving campaigns, leads and the unibox over HTTP.