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.

base urlhttps://adbtd.com/api/v1

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.

StatusMeaning
400Validation failed. See code
401Missing or invalid token
402Payment required (Stripe checkout url returned)
404Resource not in this workspace
409Conflict (still has dependencies, etc.)
429Rate-limited; retry after Retry-After
5xxServer-side; safe to retry with backoff
error body
{
  "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.

GET/v1/me
200 OK
json
{
  "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.

GET/v1/domains
200 OK
json
{
  "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.

POST/v1/domains

body

NameTypeDescription
namestringrequiredFQDN. Lower-cased; acme.com, 3–253 chars.
modestringoptionalbyo (default) — you own the registration and add our DNS records. buy — reserved; currently falls back to byo.
ipLabelstringoptionalBind 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.
ipAddressstringoptionalBind 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".

POST/v1/domains/assign-ip

body

NameTypeDescription
assignmentsarrayrequired1–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.

DEL/v1/domains/{id}
200 OK
json
{
  "data": { "deletedMailboxes": 12 }
}

List dedicated IPs

All IPs currently assigned to the workspace, oldest first. Reputation flags surface the latest RBL / blocklist signals.

GET/v1/ips
200 OK
json
{
  "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.

POST/v1/ips

body

NameTypeDescription
countintegerrequiredHow 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.

PATCH/v1/ips/{id}

body

NameTypeDescription
labelstring | nulloptionalUp 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.

DEL/v1/ips/{id}
200 OK
json
{
  "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.

GET/v1/mailboxes
200 OK
json
{
  "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.

POST/v1/mailboxes

body

NameTypeDescription
emailsarrayrequired1–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.

GET/v1/mailboxes/{id}
200 OK
json
{
  "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.

DEL/v1/mailboxes/{id}
200 OK
json
{
  "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.

GET/v1/mailboxes/{id}/messages

query

folderstringoptionalDefault INBOX. Any IMAP folder name (Sent, Spam, …).
limitintegeroptional1–100. Default 25.
beforeintegeroptionalOnly return UIDs less than this. Pass nextBefore from the previous page.
200 OK
json
{
  "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).

GET/v1/mailboxes/{id}/messages/{uid}

query

folderstringoptionalDefault INBOX.
markSeenintegeroptionalPass 1 to set the \Seen flag as a side effect.
200 OK
json
{
  "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.

POST/v1/mailboxes/{id}/send

body

NameTypeDescription
tostring | arrayrequiredSingle address or 1–50 addresses.
subjectstringrequired1–998 characters.
textstringconditionalPlain-text body. One of text or html required.
htmlstringconditionalHTML body.
ccarrayoptionalUp to 50 addresses.
bccarrayoptionalUp to 50 addresses.
fromNamestringoptionalFriendly name for the From header (e.g. Lena Park). Falls back to the mailbox’s configured display name.
replyTostringoptionalSingle 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.

POST/v1/check

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.

NameTypeDescription
rawstringconditionalFull 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).
fromstringoptionalEnvelope sender. Defaults to test@adbtd.local.
tostringoptionalEnvelope recipient. Defaults to recipient@adbtd.local.
subjectstringconditional1–998 characters. Required for the structured form.
textstringconditionalPlain-text body. One of text or html is required for the structured form.
htmlstringconditionalHTML body.

response shape

NameTypeDescription
scorenumberTotal rspamd score. Lower is cleaner; 0 is neutral.
requiredScorenumberScore at which rspamd’s reject action trips. In observe-only mode this is set high; action is the reliable verdict.
actionstringOne of no action, greylist, soft reject, add header, rewrite subject, reject.
isSpambooleanConvenience flag — true when action is anything beyond no action / greylist.
symbolsarrayPer-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.
scanTimeSecnumberWall-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)

NameDescription
Content-Typeapplication/json
User-Agentadbtd-webhook/1.0
X-Adbtd-EventStable event id (evt_…). Dedupe on this.
X-Adbtd-WebhookWebhook id that fired this event.
X-Adbtd-Typev1: email.received. Reserved for v2: email.sent, email.bounced.
X-Adbtd-Signaturet=<unix>,v1=<hmac> — see signature section below.

payload body

NameTypeDescription
idstringEvent id, evt_<ulid>. Stable across retries.
typestringAlways email.received in v1.
createdstringRFC 3339 timestamp.
workspace_idstringWorkspace that owns the receiving mailbox.
webhook_idstringWhich configured webhook this fire belongs to.
data.message_idstringRFC-5322 Message-Id of the inbound mail.
data.fromobject{ address, name }
data.toarrayFull envelope-To list, one entry per recipient.
data.ccarrayCc list, possibly empty.
data.subjectstringDecoded Subject header (MIME-decoded).
data.datestringRFC 3339 of the Date header.
data.headersobjectMap of decoded headers we surface (dkim-signature, authentication-results, received-spf, …).
data.textstringDecoded text/plain body (may be empty).
data.htmlstringDecoded text/html body (may be empty).
data.attachmentsarray{ filename, content_type, size, content_base64 }. Inlined base64. Stripped (with attachments_omitted: true) when total payload would exceed 5 MB.
data.triggered_bystringSingle recipient address (one of data.to[].address) that caused this fire.
data.raw_size_bytesintegerSize 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

header
X-Adbtd-Signature: t=<unix-seconds>,v1=<hex>

signed string

plain
<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:

AttemptDelay from previous
10s (fired immediately on inbound)
260 s (±10% jitter)
35 min
430 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.