Bobtail

Reference

API Reference

Last updated August 17, 2026

REST and MCP call the exact same code and share the exact same contracts — an agent sees what your backend sees, not a chat wrapper. Bodies are JSON, camelCase, unknown fields are rejected, not stripped: a typo is a 400 today, not a surprise later.

Node SDK

@mxplane/bobtailmailis a thin, typed client over the exact contract on this page — same request/response shapes, same error envelope, nothing it adds that the API itself doesn't already do.

npm install @mxplane/bobtailmail
import { BobtailMail } from "@mxplane/bobtailmail";

const bobtail = new BobtailMail({ apiKey: process.env.BOBTAILMAIL_API_KEY });

const message = await bobtail.messages.send({
  from: "orders@yourdomain.com",
  to: "customer@example.com",
  subject: "Your receipt",
  text: "Thanks!",
});

Source and full reference: github.com/mxplane/bobtailmail-node. No SDK yet for the MCP surface or other languages — the REST contract below covers those directly.

Authentication

Every request — REST or MCP — carries Authorization: Bearer bm_live_…. Mint a key from the console's Keys page after signing up. A key belongs to one workspace; there is no separate account-vs-server distinction.

Sending & reading messages

POST /v1/messages

POST /v1/messages201 · accepted
curl https://bobtailmail.com/v1/messages \
  -H "authorization: Bearer bm_live_…" \
  -H "idempotency-key: <your key>" \
  -d '{ "from": "orders@yourdomain.com",
        "to": "customer@example.com",
        "subject": "Your receipt",
        "text": "Thanks!",
        "html": "<p>Thanks!</p>" }'
fromstringA single address at a domain you've registered and verified.
tostring | string[]At least one recipient, max 50 across to/cc/bcc combined.
cc, bcc, replyTostring | string[]Optional. Same string-or-array shape as to.
subjectstringRequired, max 998 chars (RFC 5322's line limit).
text, htmlstringAt least one of the two is required.

Send Idempotency-Key: <your key> to make retries safe — same key, same message, no double send. The key binds for 24 hours, then releases: reused months later, it starts a genuine new send rather than silently replaying an old one.

Returns 201 with the message resource (200 on an idempotent replay, marked Idempotent-Replay: true). The message is durably recorded before the carrier is ever called, so a carrier failure is a recorded failure — never a lost message.

GET /v1/messages/:id · GET /v1/messages?limit=&before=

The message with its current status. Every state change is also its own immutable row on the message's event trail (message.accepted, message.sent, …) — append-only, enforced at the database grant.

Statuses, in the order a message can move through them:

acceptedRecorded, carrier not yet confirmed.
sentCarrier accepted it for delivery.
deliveredCarrier confirmed the recipient's mail server accepted it.
bouncedPermanent failure — the address is suppressed from future sends.
complainedRecipient marked it spam — the address is suppressed.
rejected422Refused before or by the carrier — caller-fixable.
failed502Carrier unreachable or erroring — retry with the same idempotency key.

bounced/complained win over delivered regardless of arrival order — a delivery notification never downgrades a problem status. A transient bounce (mailbox full, greylisting) is recorded on the event trail but leaves status and suppression alone; only permanent bounces and complaints suppress.

Errors & refusals

Errors are {"error": {"type", "message", "details?"}} with one of these types:

A send is refused, and the refusal recorded, without any carrier round trip when one of four things is true — each names itself in details.rejection.kind:

Two more refusals happen even earlier, before a message row exists at all — no id, no event trail: no active plan (402 no_active_plan) or the plan's monthly cap reached (402 plan_limit_reached, with the reset time), and the per-workspace rate limit (429). All three still spend their own counter even when the request fails downstream — otherwise failing would be the way around the limit.

Domain onboarding

A message can only send froman address at a domain you've registered and verified.

POST /v1/domains201
curl https://bobtailmail.com/v1/domains \
  -H "authorization: Bearer bm_live_…" \
  -d '{ "name": "notify.yourdomain.com" }'

Returns the exact DNS records to set — the count and record types depend on which carrier is active behind the scenes, but every record is the same shape: {kind, type, name, value, priority}. dkim is the hard requirement — a domain sends the moment it verifies. spf is the carrier's own bounce/alignment mechanism — optional, never blocks sending. A recommended DMARC TXT record is served alongside, unverified — that's your own domain's policy to set.

POST /v1/domains/:id/verifydkimVerified: true
curl -X POST https://bobtailmail.com/v1/domains/dom_… /verify \
  -H "authorization: Bearer bm_live_…"

Polls the carrier for a fresh answer — DKIM verification realistically takes several minutes even once DNS has propagated correctly. Registering the same domain twice, or sending from an address at an unregistered domain, both return the same resource shape with registered: false and no records to compare against.

MCP (agent surface)

POST /mcp — streamable HTTP, the same Authorization: Bearer keys as REST, checked before any MCP machinery runs. Stateless: no session to manage, connect fresh whenever.

mcp.jsontools: 3
{ "mcpServers": {
    "bobtailmail": {
      "url": "https://bobtailmail.com/mcp",
      "headers": { "authorization": "Bearer bm_live_…" }
} } }

Three tools, calling the exact same code the REST routes call: send_message (adds an optional idempotencyKey field — a tool call has no header channel), get_message, list_messages. A domain-level failure comes back as isError: true with the same {type,message,details}envelope REST uses; malformed input is rejected by the tool's own advertised schema before a handler runs at all.

Webhooks

Register a URL and BobtailMail POSTs every message.* event on your messages to it, HMAC-signed so you can verify it actually came from us.

POST /v1/webhook-endpoints201
curl https://bobtailmail.com/v1/webhook-endpoints \
  -H "authorization: Bearer bm_live_…" \
  -d '{ "url": "https://yourapp.example.com/webhooks/bobtailmail" }'

https only — loopback/private/link-local/CGNAT addresses are rejected at registration. Returns the endpoint resource and its signing secret (whsec_…), shown exactly once, same posture as an API key. Every message.*event fires a delivery to every enabled endpoint — one send typically means several deliveries per endpoint, one per event on that message's trail. Failed deliveries retry with backoff; every attempt lands on an append-only ledger you can read back from the console.

Machine-readable: openapi.json · llms.txt. Full design rationale in the repo's own docs/DECISIONS.md.