REST Bearer keys · versioned /api/v1 · per-workspace webhooks

The dashboard's engine,
exposed as an HTTP API.

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.

Get an API key →Resource reference
▸ Quickstart

One curl. First campaign.

Generate a key in Settings · API Keys, then call https://www.numail.ai/api/v1.

Authentication

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.

  • Base URL www.numail.ai/api/v1
  • Auth scheme Bearer nm_live_•••
  • Scope Per workspace
  • Content type application/json
  • Page size 50 rows
  • Errors { error, message }
  • Webhook signing HMAC-SHA256
# 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 }
# }
# Create a draft campaign (sequence steps live inside it)
curl -X POST https://www.numail.ai/api/v1/campaigns \
  -H "Authorization: Bearer nm_live_•••" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Q2 outbound · cohort B",
    "mailboxIds": ["0f07a2e1-9409-4682-ac36-661bd8799208"],
    "seqSteps": [
      { "delayDays": 0, "variants": [{ "subject": "Quick q on {{company}}", "body": "Hey {{first_name}}…" }] },
      { "delayDays": 3, "variants": [{ "subject": "", "body": "Bumping this up…" }] }
    ]
  }'

# → 201 { "data": { "id": "79e425d8-708f-44bc-812b-77930112c9a5" } }
# A blank subject sends that step as a reply in the same thread.

# Then start it (needs campaigns:execute)
CAMPAIGN=79e425d8-708f-44bc-812b-77930112c9a5
curl -X POST https://www.numail.ai/api/v1/campaigns/$CAMPAIGN/start \
  -H "Authorization: Bearer nm_live_•••"

# → { "ok": true }
# Import leads in a batch (an address already here is skipped)
curl -X POST https://www.numail.ai/api/v1/leads/import \
  -H "Authorization: Bearer nm_live_•••" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "email": "sarah@mercer-group.com", "firstName": "Sarah", "company": "Mercer Group" }
    ],
    "globalTags": ["q2-cohort-b"]
  }'

# → 201 { "data": { "inserted": 1, "skipped": 0 } }
# GET /api/v1/leads?search=sarah@mercer-group.com finds the lead's id.

# Enroll that lead into an active campaign
LEAD=50784630-3387-4a29-9a62-92ef1c279a80
curl -X POST https://www.numail.ai/api/v1/leads/$LEAD/enroll \
  -H "Authorization: Bearer nm_live_•••" \
  -H "Content-Type: application/json" \
  -d '{ "campaignId": "79e425d8-708f-44bc-812b-77930112c9a5" }'

# → 201 { "data": { "campaignLeadId": "264415db-6472-4a8b-9889-f916bce459b2" } }
# Pull positive replies from the unibox
curl "https://www.numail.ai/api/v1/threads?classification=positive" \
  -H "Authorization: Bearer nm_live_•••"

# Open one: every message, the lead, and aiDraft if NuMail wrote one
THREAD=fcc1374c-96bd-4c4c-9c06-c76f16117e5d
curl https://www.numail.ai/api/v1/threads/$THREAD \
  -H "Authorization: Bearer nm_live_•••"

# Reply in the same thread, from the mailbox it came in on (needs threads:reply).
# aiDraft.body is plain text: wrap it in <p> and turn its line breaks
# into <br>, as the app does, or send your own HTML.
curl -X POST https://www.numail.ai/api/v1/threads/$THREAD/reply \
  -H "Authorization: Bearer nm_live_•••" \
  -H "Content-Type: application/json" \
  -d '{ "to": "sarah@mercer-group.com", "bodyHtml": "<p>Tuesday at 10 works.</p>" }'

# → { "data": { "providerMessageId": "…" } }
Honest

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.

▸ Resource reference

Four resources. One pattern.

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.

Campaigns sequences live here
GET/campaignsList with filters
POST/campaignsCreate (with steps)
GET/campaigns/:idGet one + stats
POST/campaigns/:id/startDraft → active
POST/campaigns/:id/pauseHalt sending
POST/campaigns/:id/resumePaused → active
# 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
Leads import + enroll
GET/leadsList with filters
POST/leadsCreate one lead
GET/leads/:idFull lead + activity
PATCH/leads/:idStatus, custom fields
POST/leads/:id/enrollEnroll into a campaign
POST/leads/importBulk import rows
# 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
Mailboxes list all · push SMTP
GET/mailboxesList + health
POST/mailboxesAdd an SMTP/IMAP mailbox
# 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
Unibox threads + AI drafts
GET/threadsList with classification filter
GET/threads/:idFull thread + AI draft
POST/threads/:id/replySend a reply
# 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
Errors one envelope
# 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
Paths are relative to https://www.numail.ai/api/v1. What the app does that /v1 does not serve yet is on the roadmap.
▸ Auth & connect

Bearer keys. OAuth for inboxes.

Two distinct things: keys that authenticate your API calls, and OAuth flows that let NuMail send from real mailboxes.

API keys nm_live_

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.

  • Header Authorization: Bearer nm_live_•••
  • One key, one workspace: clean blast radius on rotation
  • Each call needs the scope the reference names for it
  • Full access is * and Read-only is read:*
  • MCP-only keys reach the MCP server, not /api/v1
  • Revoke & re-issue from the settings screen anytime
  • Separate nm_test_ prefix planned for sandbox keys

Mailbox OAuth Google · Microsoft

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

  • Google and Microsoft consent starts from the Mailboxes page in the app
  • SMTP/IMAP inboxes skip OAuth via POST /api/v1/mailboxes
  • GET /api/v1/mailboxes?email= checks an address first
  • Tokens are per-workspace and refreshed automatically
▸ Inbound webhooks

Gmail & Graph. Signed, per workspace.

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

Inbound endpoints

  • POST /api/webhooks/gmailGmail Pub/Sub push
  • POST /api/webhooks/graphMS Graph change notification

What a reply triggers

  • reply detected2.4s median, inbound parsed
  • classificationpositive · negative · question · ooo · unsubscribe · forward · bounce · other
  • ai_draft readyClaude writes in your voice
  • thread surfacedlands in the unibox to approve
reply pipeline · internal event shapeHMAC verified
# 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=…
Note

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.

▸ Roadmap

Where the public surface is headed.

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.

Live

REST /v1 endpoints

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.

Live

Bearer key auth

Mint, scope and rotate nm_live_ keys from Settings · API Keys today.

Live

Inbound webhooks

Gmail Pub/Sub and Microsoft Graph receivers, HMAC-verified per workspace, powering 2.4s reply detection.

Planned

The rest of the app on /v1

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.

Building

OpenAPI 3.1 + SDKs

A published spec at /v1/openapi.json and generated TypeScript & Python clients.

Live

Outbound webhooks & MCP

Signed events pushed to your endpoints, plus 14 MCP tools at www.numail.ai/api/mcp so AI agents can run campaigns conversationally.

Build on the
same engine.

Grab a workspace key from Settings · API Keys and start driving campaigns, leads and the unibox over HTTP.