api reference · v1
API Reference
Everything you can do in the dashboard, you can do over JSON. The API is REST-ish, predictable, and designed to be driven by both humans and agents. Authenticate with a workspace-scoped bearer token; we return JSON for everything.
Authentication
All requests must include a bearer token in the Authorization header. Tokens are workspace-scoped — a token can only see and act on its own workspace. There are no per-scope permissions; possession of the token grants full access to that workspace’s resources.
token format
Two namespaces share the same surface and are accepted interchangeably:
| adbtd_live_… | Issued from the dashboard (Settings → API keys). |
| adbtd_mcp_… | Auto-minted by the MCP OAuth flow at mcp.adbtd.com (see ADR 0031). |
The plaintext is shown once at creation. Rotate by revoking the old key and issuing a new one.
curl https://adbtd.com/api/v1/me \ -H "Authorization: Bearer adbtd_live_4f8c…"
Errors
Conventional HTTP status codes; the body is always JSON with a stable code and a human message.
| Status | Meaning |
|---|---|
| 400 | Validation failed. See code |
| 401 | Missing or invalid token |
| 402 | Payment required (Stripe checkout url returned) |
| 404 | Resource not in this workspace |
| 409 | Conflict (still has dependencies, etc.) |
| 429 | Rate-limited; retry after Retry-After |
| 5xx | Server-side; safe to retry with backoff |
{ "error": { "code": "conflict", "message": "no dedicated IP on this workspace yet — place your first order via the dashboard" } }
Get current workspace
Returns the workspace the token belongs to, plus the API key’s own metadata. Useful as a token-validity ping.
{ "data": { "workspace": { "id": "ws_4f8c", "slug": "acme-cold", "name": "Acme Cold Outreach", "currency": "USD" }, "apiKey": { "id": "key_01HE…", "name": "production server" } } }
List domains
All domains on the workspace, ordered by name. Each row carries the DNS-verification flags, the IP currently used for outbound mail on that domain (outboundIp), any staged IP switch waiting on SPF propagation (pendingOutboundIp), and a reputation sub-object with the latest Gmail Postmaster + 7-day Rspamd signals — handy for batch scoring loops against POST /v1/domains/assign-ip.
{ "data": [ { "id": "dom_a1b2", "name": "acme-hq.co", "mode": "byo", "status": "verified", "isPrimary": true, "spfOk": true, "dkimOk": true, "dmarcOk": true, "mxOk": true, "dkimSelector": "adbtd1", "outboundIp": { "id": "ip_d4e5", "address": "195.154.152.46", "label": "pristine" }, "pendingOutboundIp": null, "reputation": { "gmail": "HIGH", // HIGH | MEDIUM | LOW | BAD | UNKNOWN | null "userReportedSpamRate": 0.0008, // 0–1. Gmail flags >0.003 as critical. "gmailLastFetchedAt": "2026-05-13T20:00:00Z", "sent7d": 4218, "avgSpamScore7d": 1.42 // rspamd score, lower is cleaner }, "createdAt": "2025-09-12T08:14:00Z", "lastVerifiedAt": "2026-05-12T03:00:00Z" } ] }
Add a domain
Attach a new domain to the workspace. The workspace must already have at least one dedicated IP (place the first order from the dashboard). The scheduler then provisions DNS records, DKIM keys, and MTA configuration; poll GET /v1/domains until status flips to active.
body
| Name | Type | Description | |
|---|---|---|---|
| name | string | required | FQDN. Lower-cased; acme.com, 3–253 chars. |
| mode | string | optional | byo (default) — you own the registration and add our DNS records. buy — reserved; currently falls back to byo. |
| ipLabel | string | optional | Bind the new domain to an IP that carries this label. Labels are comma- or space-separated multi-tags — an IP tagged "pristine, low-volume" matches both queries. When multiple IPs share the label we pick one at random (same load-balancing as the workspace’s random default-IP mode). Mutually exclusive with ipAddress; fails with 400 validation_error when no IP matches. |
| ipAddress | string | optional | Bind the new domain to a specific IP address (dotted-decimal IPv4). Bypasses the workspace’s default-IP policy. Mutually exclusive with ipLabel; fails with 400 validation_error when the address is not assigned to this workspace. |
Neither ipLabel nor ipAddress set? The workspace default-IP policy applies (set via the dashboard): specific mode pins the oldest or a chosen IP; random mode picks any assigned IP at provision time. The domain’s outbound IP can be reassigned later via POST /v1/domains/assign-ip.
curl -X POST https://adbtd.com/api/v1/domains \ -H "Authorization: Bearer …" \ -H "Content-Type: application/json" \ -d '{ "name": "acme-hq.co" }'
Bulk-assign outbound IPs
Bind one or more domains to specific outbound IPs in a single round-trip. Designed for agency-style customers who score their list periodically and re-route domains between their own “pristine / middle / trashy” IP tiers. Each row is processed independently — the response shape is { results: [{ domain, ok, ... }] }, so partial failures don’t abort the batch.
SPF gate. The target IP must already be in the domain’s published SPF record — otherwise the assignment would instantly break SPF on every receiver until DNS catches up. Update DNS in bulk first, then call this endpoint. Rows that fail the gate return code: "spf_missing_ip".
body
| Name | Type | Description | |
|---|---|---|---|
| assignments | array | required | 1–500 objects, each { domain: string, ipAddress: string }. ipAddress is dotted-decimal IPv4 and must be currently assigned to the workspace. Duplicate (domain, ip) pairs are deduped. |
curl -X POST https://adbtd.com/api/v1/domains/assign-ip \ -H "Authorization: Bearer …" \ -H "Content-Type: application/json" \ -d '{ "assignments": [ { "domain": "acme.com", "ipAddress": "1.2.3.4" }, { "domain": "get-acme.io", "ipAddress": "5.6.7.8" } ] }'
Per-row error codes: domain_not_found, ip_not_assigned, spf_missing_ip. Successful rows write the new outbound_ip_id immediately and any in-flight UI-staged switch on those domains is cleared. The tenant Postfix sender_transport map is re-rendered + reloaded once at the end of a batch with at least one success.
Delete a domain
Removes the domain and cascades to every mailbox under it. DKIM keys and mailbox-password rows are scrubbed from the tenant filesystem; the subscription is re-synced against the new mailbox count.
{ "data": { "deletedMailboxes": 12 } }
List dedicated IPs
All IPs currently assigned to the workspace, oldest first. Reputation flags surface the latest RBL / blocklist signals.
{ "data": [ { "id": "ip_d4e5", "address": "195.154.152.46", "label": "pristine, transactional", // free-text, comma-separated multi-tags "status": "assigned", "allocatedAt": "2025-09-12T08:14:00Z", "lastReputationAt": "2026-05-12T03:00:00Z", "reputationFlags": [] } ] }
Allocate dedicated IPs
Adds count dedicated IPs to the workspace. The scheduler picks fresh /32s, RBL-sweeps them, and binds them to the workspace’s domains. Bills $19/mo per IP, prorated. The workspace must already have at least one domain.
body
| Name | Type | Description | |
|---|---|---|---|
| count | integer | required | How many IPs to add. 1–3 per call. |
curl -X POST https://adbtd.com/api/v1/ips \ -H "Authorization: Bearer …" \ -H "Content-Type: application/json" \ -d '{ "count": 2 }'
Update an IP (set labels)
Set or clear the customer-visible label on an assigned IP. Labels are free-text, comma- or space-separated multi-tags — an IP tagged "pristine, low-volume" matches either query at provision time via POST /v1/domains with ipLabel. The server normalises whitespace + dedupes; empty result (or explicit null) clears the label.
body
| Name | Type | Description | |
|---|---|---|---|
| label | string | null | optional | Up to 64 chars. Comma- or space-separated multi-tag. Pass null or an empty string to clear. |
Omitting label from the body is a no-op that returns the IP’s current state — useful as a probe. Additional patchable fields may land on this same endpoint in future iters.
curl -X PATCH https://adbtd.com/api/v1/ips/ip_d4e5 \ -H "Authorization: Bearer …" \ -H "Content-Type: application/json" \ -d '{ "label": "pristine, transactional" }'
Errors: 404 not_found when the IP isn’t on this workspace (or has been released). 400 validation_error when the label exceeds 64 chars.
Release a dedicated IP
Releases the IP back to the pool. The IP enters a cooldown (rDNS neutralised) before it can be re-issued. You cannot release the workspace’s last IP while it still has active mailboxes — delete the mailboxes first, or 409 is returned.
{ "data": { "released": "195.154.152.46", "cancelledSubscription": false } }
List mailboxes
All mailboxes on the workspace with their SMTP / IMAP endpoints. Ordered by address. Passwords are never returned by listing or fetching — they are shown only once in the dashboard at provisioning time.
{ "data": [ { "id": "mb_8e2a", "email": "lena@acme-hq.co", "displayName": "Lena Park", "smtpHost": "195.154.152.46", "imapHost": "195.154.152.46", "submissionPort": 587, "imapPort": 993, "status": "active", "createdAt": "2025-09-12T08:14:00Z" } ] }
Create mailboxes
Bulk-create mailboxes. Pass full email addresses; we group by domain and place a single order. Every referenced domain must already exist on the workspace — add domains via POST /v1/domains first. Provisioning is async; poll GET /v1/mailboxes for the new rows.
body
| Name | Type | Description | |
|---|---|---|---|
| emails | array | required | 1–1000 full email addresses. Local-parts are validated (alphanumerics, . _ + -). |
curl -X POST https://adbtd.com/api/v1/mailboxes \ -H "Authorization: Bearer …" \ -H "Content-Type: application/json" \ -d '{ "emails": ["lena@acme-hq.co", "max@acme-hq.co"] }'
Get a mailbox
Returns the mailbox’s SMTP / IMAP host and ports. Use this when configuring an external mail client — pair it with the password shown once in the dashboard at creation.
{ "data": { "id": "mb_8e2a", "email": "lena@acme-hq.co", "smtpHost": "195.154.152.46", "imapHost": "195.154.152.46", "submissionPort": 587, "imapPort": 993, "status": "active", "createdAt": "2025-09-12T08:14:00Z" } }
Delete a mailbox
Deletes the mailbox and removes its Dovecot password row. The subscription is re-synced against the new mailbox count.
{ "data": { "deleted": "lena@acme-hq.co" } }
List messages in a mailbox
Reads the mailbox over IMAP and returns envelopes only. Sorted by UID descending (newest first). Use before for pagination — pass the previous page’s nextBefore.
query
| folder | string | optional | Default INBOX. Any IMAP folder name (Sent, Spam, …). |
| limit | integer | optional | 1–100. Default 25. |
| before | integer | optional | Only return UIDs less than this. Pass nextBefore from the previous page. |
{ "data": { "folder": "INBOX", "total": 142, "messages": [ { "uid": 14021, "messageId": "<CAJ…@bigco.io>", "subject": "Re: saw the launch", "from": ["Priya <priya@bigco.io>"], "to": ["lena@acme-hq.co"], "cc": [], "date": "2026-05-06T14:22:10Z", "flags": ["\\Seen"], "size": 4821, "seen": true } ], "nextBefore": 13996 } }
Get a single message
Fetches the full RFC-5322 message by UID, parses it, and returns the decoded text + HTML bodies and an attachment manifest (metadata only; raw bytes are not returned over the wire).
query
| folder | string | optional | Default INBOX. |
| markSeen | integer | optional | Pass 1 to set the \Seen flag as a side effect. |
{ "data": { "uid": 14021, "messageId": "<CAJ…@bigco.io>", "subject": "Re: saw the launch", "from": "Priya <priya@bigco.io>", "to": "lena@acme-hq.co", "cc": null, "date": "2026-05-06T14:22:10Z", "text": "Hey Lena, let's chat. Free Thurs?", "html": "<div>…</div>", "attachments": [{ "filename": "agenda.pdf", "contentType": "application/pdf", "size": 84211, "contentId": null }], "flags": ["\\Seen"], "seen": true } }
Send a message
Sends through the mailbox’s own submission server (port 587, STARTTLS). Same code path as the dashboard test-send button — the message goes out on your dedicated IP, signed by your DKIM, from your domain. No central relay.
body
| Name | Type | Description | |
|---|---|---|---|
| to | string | array | required | Single address or 1–50 addresses. |
| subject | string | required | 1–998 characters. |
| text | string | conditional | Plain-text body. One of text or html required. |
| html | string | conditional | HTML body. |
| cc | array | optional | Up to 50 addresses. |
| bcc | array | optional | Up to 50 addresses. |
| fromName | string | optional | Friendly name for the From header (e.g. Lena Park). Falls back to the mailbox’s configured display name. |
| replyTo | string | optional | Single address. |
curl -X POST \ https://adbtd.com/api/v1/mailboxes/mb_8e2a/send \ -H "Authorization: Bearer …" \ -H "Content-Type: application/json" \ -d '{ "to": "priya@bigco.io", "subject": "saw the launch", "text": "Hey Priya…" }'
Check a message with rspamd
Submits a message to the same rspamd scanner that scores real outbound traffic and returns the score, action, and per-symbol breakdown — without sending the mail. Useful for dry-running a draft before queueing a campaign or for catching a regression in your template before it lands in a recipient’s spam folder. Rate-limited to 30/min per token (burst 30); rspamd’s lua sandbox + RBL DNS lookups make each call non-trivial.
body
Either raw (a full RFC-5322 message) or the structured fields below. At minimum the structured form needs subject plus one of text / html.
| Name | Type | Description | |
|---|---|---|---|
| raw | string | conditional | Full RFC-5322 message including headers, sent through as-is. Use this when you’ve already composed the MIME (e.g. for testing an exact byte-for-byte payload). |
| from | string | optional | Envelope sender. Defaults to test@adbtd.local. |
| to | string | optional | Envelope recipient. Defaults to recipient@adbtd.local. |
| subject | string | conditional | 1–998 characters. Required for the structured form. |
| text | string | conditional | Plain-text body. One of text or html is required for the structured form. |
| html | string | conditional | HTML body. |
response shape
| Name | Type | Description |
|---|---|---|
| score | number | Total rspamd score. Lower is cleaner; 0 is neutral. |
| requiredScore | number | Score at which rspamd’s reject action trips. In observe-only mode this is set high; action is the reliable verdict. |
| action | string | One of no action, greylist, soft reject, add header, rewrite subject, reject. |
| isSpam | boolean | Convenience flag — true when action is anything beyond no action / greylist. |
| symbols | array | Per-symbol contributions to the score, sorted by impact. Each entry carries name, score, plain-language description / why / fix when adbtd has a mapping for it, plus rspamd’s raw options. |
| scanTimeSec | number | Wall-clock seconds rspamd spent on the scan. |
curl -X POST \ https://adbtd.com/api/v1/check \ -H "Authorization: Bearer …" \ -H "Content-Type: application/json" \ -d '{ "from": "lena@acme-hq.co", "to": "priya@bigco.io", "subject": "saw the launch", "text": "Hey Priya…" }'
Webhook event payload
Every inbound message fires email.received at every webhook subscribed to one of the message’s recipient domains, filtered by mailbox scope (user / system / all). Set up webhooks from the dashboard’s API · Webhooks tab. Three transports are available: raw HTTPS (signed), Slack incoming-webhook (formatted message), Discord channel webhook (formatted embed).
request headers (HTTPS transport)
| Name | Description | |
|---|---|---|
| Content-Type | application/json | |
| User-Agent | adbtd-webhook/1.0 | |
| X-Adbtd-Event | Stable event id (evt_…). Dedupe on this. | |
| X-Adbtd-Webhook | Webhook id that fired this event. | |
| X-Adbtd-Type | v1: email.received. Reserved for v2: email.sent, email.bounced. | |
| X-Adbtd-Signature | t=<unix>,v1=<hmac> — see signature section below. |
payload body
| Name | Type | Description | |
|---|---|---|---|
| id | string | Event id, evt_<ulid>. Stable across retries. | |
| type | string | Always email.received in v1. | |
| created | string | RFC 3339 timestamp. | |
| workspace_id | string | Workspace that owns the receiving mailbox. | |
| webhook_id | string | Which configured webhook this fire belongs to. | |
| data.message_id | string | RFC-5322 Message-Id of the inbound mail. | |
| data.from | object | { address, name } | |
| data.to | array | Full envelope-To list, one entry per recipient. | |
| data.cc | array | Cc list, possibly empty. | |
| data.subject | string | Decoded Subject header (MIME-decoded). | |
| data.date | string | RFC 3339 of the Date header. | |
| data.headers | object | Map of decoded headers we surface (dkim-signature, authentication-results, received-spf, …). | |
| data.text | string | Decoded text/plain body (may be empty). | |
| data.html | string | Decoded text/html body (may be empty). | |
| data.attachments | array | { filename, content_type, size, content_base64 }. Inlined base64. Stripped (with attachments_omitted: true) when total payload would exceed 5 MB. | |
| data.triggered_by | string | Single recipient address (one of data.to[].address) that caused this fire. | |
| data.raw_size_bytes | integer | Size of the raw RFC-5322 message before parsing. |
// POST <your-url> — application/json { "id": "evt_01HE…", "type": "email.received", "created": "2026-05-12T08:14:22.103Z", "workspace_id": "ws_…", "webhook_id": "wh_…", "data": { "message_id": "<CAJ…@mail.example.com>", "from": { "address": "alice@example.com", "name": "Alice" }, "to": [{ "address": "bob@acme.com" }], "subject": "Re: invoice", "text": "Hey Bob — quick thought…", "html": "<div>…</div>", "attachments": [{ "filename": "invoice.pdf", "size": 84211 }], "triggered_by": "bob@acme.com" } }
Signature (HTTPS transport)
The X-Adbtd-Signature header carries an HMAC-SHA256 over the request body, scoped by a timestamp to make replays inert. Same scheme as Stripe’s, same caveats: store the signing secret server-side, verify with constant-time comparison, reject events older than 5 minutes.
format
X-Adbtd-Signature: t=<unix-seconds>,v1=<hex>
signed string
<unix-seconds>.<raw-body>
algorithm
v1 = hex(HMAC_SHA256(secret, `${t}.${body}`)). The secret is shown once at webhook creation in the dashboard and rotated by deleting + recreating the webhook (and updating your endpoint).
Slack and Discord transports are not signed — their per-channel URL is the bearer.
import crypto from "node:crypto"; const sig = req.headers["x-adbtd-signature"]; const { t, v1 } = Object.fromEntries( sig.split(",").map(p => p.split("="))); const expected = crypto .createHmac("sha256", process.env.SECRET) .update(`${t}.${rawBody}`) .digest("hex"); if (Math.abs(Date.now() / 1000 - Number(t)) >= 300) throw new Error("stale"); if (!crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(v1))) throw new Error("bad sig");
Retries, idempotency, retention
retry schedule
Network errors and 5xx responses retry on a jittered backoff:
| Attempt | Delay from previous | |
|---|---|---|
| 1 | 0s (fired immediately on inbound) | |
| 2 | 60 s (±10% jitter) | |
| 3 | 5 min | |
| 4 | 30 min | |
| — | After 4 attempts, the delivery is marked failed. |
4xx responses are terminal — we don’t retry past a misconfigured endpoint. Each attempt’s response code, latency, and a snippet of the response body are visible in the dashboard.
delivery semantics
At-least-once. Endpoints may receive the same event twice. Dedupe on id (stable across retries) and treat downstream effects as idempotent.
dedup at fire time
One event per (webhook, inbound message). Mail addressed to two of your domains, or two recipients on the same domain, never fires the same webhook twice for the same message — we dedupe on (webhook_id, message_id) at insert time.
retention
Delivery rows are retained 90 days. Deleting a webhook also deletes its history (FK cascade).
resend
The dashboard re-fire button enqueues a fresh delivery with the same metadata (subject, sender, body summary) but no body — we don’t retain full mail bodies past first-delivery success. Recover the full body from your mailbox via IMAP or the API.