# WarmInboxes API

> Read a WarmInboxes account, change it, export its inboxes into the customer's own sequencer, and order inboxes — Google, Microsoft 365, Azure and prewarmed — from code. Three ways in, one key, the same rules: the REST API, an MCP server for AI assistants, and the `wib` command line.

> This is the transactional, authenticated half of WarmInboxes. The free,
> read-only deliverability checks are a different surface, documented at
> https://warminboxes.com/llms.txt and https://warminboxes.com/for-ai, and
> share no routes with this one. The onboarding sequence — account first,
> key from the account, then connect — is at https://warminboxes.com/developers.
> The human-readable version of this reference is https://docs.warminboxes.com,
> which is authoritative if the two ever differ. Published here 2026-10-15.

- Base URL: https://api.warminboxes.com
- Everything is under /v1. JSON in, JSON out.
- Auth: `Authorization: Bearer wib_<prefix>_<secret>`; a test key (`wib_test_…`) uses the sandbox, see below
- MCP: https://api.warminboxes.com/mcp (stateless Streamable HTTP)
- CLI: `curl -fsSL https://api.warminboxes.com/v1/cli -o wib && chmod +x wib`
- Human-readable version of this reference: https://docs.warminboxes.com

## What it can and cannot do

Can: read everything the portal shows (orders, domains and build state, invoices, deliverability, requests); change folders, order names, sequencer choice, monitors, forwarding and inbox names; export a built order's inboxes into Instantly, Smartlead, PlusVibe or Email Bison and follow the export; order more inboxes — Google, Microsoft 365, Azure tenants, or prewarmed domains by count — on a plan the account is already on, at its price, charged to the card on file; open a checkout for anything the shop sells (a new plan, prewarmed, domain registration) for a person to pay on Stripe's page.

Cannot (refused with 403): manage API keys, cancel an order or undo a cancellation, change the team, take a referral code or set its payout, change the account's email. A key also cannot act on another account. Those stay with a person in the portal at https://app.warminboxes.com. A program can ask for a cancellation with POST /v1/portal/requests and `"kind": "cancel"`.

## Quickstart

1. Make a key in the portal: Settings → API keys (account owner only). Pick scopes. The full key is shown once.
2. First call: `curl https://api.warminboxes.com/v1/portal/dashboard -H "Authorization: Bearer $WIB_API_KEY"`
3. See what can be ordered: GET /v1/catalogue. If nothing is orderable, buy the plan the first time with a checkout (POST /v1/portal/checkout, a person pays on Stripe's page).
4. Price an order: POST /v1/orders/preview with the order JSON. Creates nothing.
5. Place it: POST /v1/orders with the same JSON and an `Idempotency-Key` header. Charges the card on file.

## Keys and scopes

A key looks like `wib_<prefix>_<secret>`. The prefix identifies it and is shown in the portal's key list; the secret is shown once and only a hash is kept, so a lost key cannot be recovered — revoke it and make another. A key belongs to one account; every answer is about that account only, and another account's record answers 404 exactly like a missing one. The `X-Account` header is ignored for keys.

Scopes are fixed when the key is made and do not imply each other:

- `read` — every GET, and POST /v1/orders/preview (it answers a question and creates nothing).
- `write` — every non-GET on /v1/portal/…: folders, order names, sequencer choice, monitors, forwarding, inbox renames, change requests, sequencer logins, exports.
- `orders:place` — POST /v1/orders (charges the card), POST /v1/portal/checkout (opens a payment page), and POST /v1/payment-method/setup-link.

A typical ordering key carries all three. A reporting key needs only `read`. Using a key beyond its scopes gives 403 naming the missing scope, e.g. `This API key does not carry the 'write' scope.`

Revoking a key in the portal takes effect on the next request. Every request a key makes is recorded with the key's name, and anything it creates names it, e.g. `you@acme.com (api key: reporting)`.

## Sandbox (test keys)

A test key, `wib_test_<prefix>_<secret>`, works on the same base URL, routes, scopes, rate limits and error sentences as a live key. It answers from a pretend copy of the account: nothing is charged, built, sent or changed, and the team never sees it. Swap in a live key when the integration works. Make one in the portal: Settings → API keys, switch on "Test key". Every answer to a test key carries `X-Sandbox: true`. GET /v1/sandbox describes the sandbox: its orders, folders, checkouts, controls, simulations and special values.

A first run: POST /v1/sandbox/reset; POST /v1/orders/preview; POST /v1/orders with an Idempotency-Key; POST /v1/sandbox/orders/{subscription_id}/status `{"status": "live"}` to skip the build.

- Starts with five orders: sub_test_acme_outbound (finished Google, 20 inboxes on 10 domains, folder 1), sub_test_globex_prewarmed (prewarmed; domain swap refused), sub_test_initech_build (Microsoft 365, building; one domain waiting_on_you), sub_test_hooli_azure (finished Azure, 2 tenants of 25), sub_test_umbrella_ended (cancelled). Catalogue: Google ($3.50 an inbox), prewarmed (by quantity), Microsoft 365 and Azure ($50 a tenant), all orderable. A test card ending 4242 is saved. All of the account's test keys share one sandbox. The sandbox shop sells everything the live shop does at the live prices (and a reseller's own prices at theirs); a paid test checkout for a new plan adds it to the sandbox catalogue.
- Refused for the same reasons as live, in the same words: the order spec (live parser), checkout prices (live list, and the reseller's live terms), return URLs, forwarding, renames, names, sequencer choices, export logins. Person-only routes refuse with the live 403. A route not in this reference refuses a test key with 403.
- Time moves. A placed (or paid) order is new_order for 1 minute, then build_in_process, uploading_in_process from minute 3, and setup_completed from minute 5. Exports complete after 1 minute. Unpaid checkouts lapse after 31 minutes, with no reminder email.
- Checkout and setup-link answer with a `url` to a sandbox payment page. Pay or cancel there to be sent to success_url (with {CHECKOUT_SESSION_ID} filled in) or cancel_url. Session ids start `cs_test_`.
- Sandbox-only routes (test keys only, need `read`): GET /v1/sandbox (overview); POST /v1/sandbox/reset (back to the starting orders); POST /v1/sandbox/orders/{subscription_id}/status with `{"status": "live"}` (any order status, at once); POST /v1/sandbox/checkout/{session_id}/pay; POST /v1/sandbox/checkout/{session_id}/expire.
- `X-Sandbox-Simulate` header: rate_limited (429, Retry-After 30), server_error (500), card_declined (POST /v1/orders 402), no_card (payment-method saved false; POST /v1/orders 409), sequencer_down (export 502 and listed as failed; sequencer check unknown).
- Values that behave differently: domains ending `-taken.com` belong to another account (and are already registered when buying); for Azure and Microsoft 365, `-unregistered.com` is registered to nobody, `-in-tenant.com` is in another Microsoft tenant, `-not-pointed.com` does not point at its nameservers, `-no-nameservers.com` was never given any, `-unknown.com` cannot be checked (a note, not a refusal); promotion codes SANDBOX10 (10% off) and SANDBOX100 (100% off) apply to the first invoice, and any other code is unknown; a sequencer key or password of `rejected` fails a sequencer check.
- MCP and `wib` take a test key the same way; every MCP tool answers from the sandbox, and the assistant is told so on connecting. `wib sandbox` shows it; `wib sandbox reset | status <sub_…> <status> | pay <cs_test_…> | expire <cs_test_…>` drive it; `wib --simulate <failure> …` sends X-Sandbox-Simulate; `order` needs no --yes with a test key.
- Limits: test orders have their own hourly limit; other rate limits are the live ones. A sandbox holds about 500 KB of pretend records; past that, changes answer 409 until reset. Test calls are in the audit trail, marked as the sandbox.

## Conventions

- Send JSON with `Content-Type: application/json`. Bodies over 4 MB are refused with 413.
- Lists come back whole, newest first where order matters. No pagination.
- Times are ISO 8601 in UTC, e.g. `2026-09-24T19:10:51`; no offset means UTC.
- Money is a decimal in the currency's main unit: `3.5` is $3.50. Currencies are upper-case, e.g. `USD`.
- An order is identified by its subscription id, `sub_…`. Folders have numeric ids.
- The portal calls the same routes without `/v1`; use `/v1`.
- Errors are `{"detail": "<one sentence for a person>"}`; ordering refusals add `problems: [...]`.

## Reading the account (all need `read`)

- GET /v1/portal/dashboard — every order with its domains, build state, and account totals. In this response `workspaces` means orders. Each order: `id` (sub_…), `name`, `placedAt`, `product`, `mrr`, `status`, `inboxes`, `sequencer`, `renewsAt`, `endsAt`, `workspaceId` (its folder), `monitors`, `domainCount`, `domains` (each: `domain`, `provider`, `inboxes`, `status`, `forwardingTo`, `nameservers`, `detail`, …). Plus `totals` and `freshness` (when billing and fleet figures were last updated; null = not yet).
- GET /v1/portal/orders — flat list of orders, newest first, including cancelled ones: `id`, `name`, `placedAt`, `status`, `amount`, `inboxes`, `domains`, `provider`, `sequencer`.
- GET /v1/portal/placed-orders — every order as placed (the form filled in), including checkouts never paid.
- GET /v1/portal/billing — `thisMonth`, `next30Days`, `activeSubscriptions`, `pastDue`, `credits`, `upcoming[]` (date, orderId, order, amount, product), `byOrder[]` (id, name, mrr, spent, startedAt, renewsAt), `invoices[]` (id, number, date, amount, status, order, hostedUrl, pdfUrl), `freshness`. Amounts are Stripe's own.
- GET /v1/portal/analytics?period=7d — deliverability per domain (`period`: 7d, 30d, 90d): `available`, `range`, `rows[]` (domain, provider, inboxes, workspace, stats{sent, replies, bounces, opens, health}), `totals`. Read `available` first: when false, figures are not measured yet and the zeros mean "not measured", not "no replies". `opens` is not tracked (always 0) and `health` is not graded yet (always "pending").
- GET /v1/portal/workspaces — folders: `{"workspaces": [{id, name, domains, created_at, sequencer, monitors}], "unassigned": <domains in no folder>}`.
- GET /v1/portal/requests — change requests, newest first: id, orderId, orderName, kind, summary, raisedAt, status (`open`, then `assigned` once picked up), note (our reply).
- GET /v1/portal/referrals — referral partner record (or null), terms, totals (pending, payable, paid), earnings, suggested_code. Never names the customers who paid.
- GET /v1/portal/team — colleagues: id, email, role (`member`: everything but managing team and keys; `viewer`: read only), invitedAt, acceptedAt.
- GET /v1/portal/accounts — accounts this caller may act on; for a key, always its own one.

### Order statuses

- `pending` — checkout opened, first payment not through yet.
- `expired` — that checkout was never paid and lapsed.
- `new_order` — paid, waiting for the team to pick it up.
- `update_requested` — a change was asked for during the build.
- `build_in_process` — inboxes being created.
- `uploading_in_process` — built, being connected to the sequencer.
- `setup_completed` — build finished.
- `live` — finished, confirmed and running.
- `cancelling` — set to end at the close of the billing period.
- `cancelled` — ended.
- `payment_failed` — last charge failed; outranks every other status.

### Domain statuses

- `setting_up` — being built.
- `waiting_on_you` — point the domain's nameservers at the ones in `nameservers`.
- `live` — built, with its inboxes.
- `failed` — build failed; `detail` may say why.

## One order in detail (all need `read`; another account's order is 404)

- GET /v1/portal/orders/{subscription_id}/details — the order form: `orderName`, `placedAt`, `lineItems`, `stripe` (plan, product, interval, intervalCount, quantity) and `form` (sku, source, domains with mailboxes and forwarding_url, mailboxes, promotion_code, placed_by). Secrets are never included. Older orders answer `form: null` with `stripe` still present.
- GET /v1/portal/orders/{subscription_id}/accounts — the inboxes (sign-in accounts): `{"accounts": [{email, firstName, lastName}]}`.
- GET /v1/portal/orders/{subscription_id}/accounts/code?email=jane@acme-mail.com — the current 2-step verification code for one of the order's accounts.
- GET /v1/portal/orders/{subscription_id}/requests — this order's request log.
- GET /v1/portal/orders/{subscription_id}/updates — inbox-rename and domain-swap requests, each `in_progress` or `done`.
- GET /v1/portal/orders/{subscription_id}/cancellation — `status` (`active`, `cancelling`, `partial`, `ended`), periodEnd, endsAt, reason, quantity, canCancel, canPartial, reasons. Cancelling itself is done in the portal.
- GET /v1/portal/orders/{subscription_id}/exports — every export of the order to a sequencer (see Exporting).
- GET /v1/portal/orders/{subscription_id}/nameservers — for each domain on the order (on record, plus own domains its order form named): `{"domains": [{"domain", "status", "nameservers": [two]}]}`. `status`: `connected` (points at our pair), `waiting_on_you` (set the two nameservers at the registrar; shows connected as soon as public DNS shows the change, re-checked every two minutes), `no_nameservers` (none issued yet — POST /v1/portal/domains/nameservers with `{"provider": "microsoft" | "google", "domains": [...]}`, ≤10 a call, needs `orders:place`), `unknown` (could not ask; retry). Reads only. The same pair shows on dashboard domains as `nameservers` with status `waiting_on_you`.

## Changing things (all need `write`)

Changes take effect at once; those that touch an order are noted on its history under the caller's name.

### Folders

- POST /v1/portal/workspaces — make a folder: `{"name": "Q4 outbound"}`. Up to 80 characters, unique on the account ignoring case (duplicate is 409).
- PUT /v1/portal/workspaces/{workspace_id} — rename: `{"name": "..."}`.
- DELETE /v1/portal/workspaces/{workspace_id} — remove; its domains are kept with no folder, and the answer says how many moved.
- PUT /v1/portal/orders/{subscription_id}/workspace — file a whole order: `{"workspace_id": 12}` or null. Every domain moves, and domains built later follow.
- PUT /v1/portal/domains/{domain}/workspace — file one domain: `{"workspace_id": 12}` or null. Domain and folder must both be yours.

### Order name

- PUT /v1/portal/orders/{subscription_id}/name — `{"name": "October outbound"}`, up to 80 characters, at once.

### Sequencer choice and monitors (record a choice; nothing is moved)

- PUT /v1/portal/workspaces/{workspace_id}/sequencer — `{"sequencer": "instantly"}`: instantly, smartlead, plusvibe, emailbison, or null to clear. Use this for new choices.
- PUT /v1/portal/orders/{subscription_id}/sequencer — the same for one order.
- Either takes `key_id` or `label` too, to remember which saved login exports of that folder's or order's inboxes sign in with (see Which login an export uses). Answers `{"sequencer", "key_id", "label"}`. Choosing another sequencer drops a remembered login for the old one.
- PUT /v1/portal/workspaces/{workspace_id}/monitors — `{"reconnection": true}` and/or `{"deliverability": true}`.
- PUT /v1/portal/orders/{subscription_id}/monitors — the same for one order.

Send only the switch being changed; an absent field keeps its value. The answer is both switches. To actually push inboxes into a sequencer, use Exporting.

### Forwarding

- POST /v1/portal/orders/{subscription_id}/forwarding/preflight — what a change would do, per domain. Writes nothing.
- POST /v1/portal/orders/{subscription_id}/forwarding — apply it.

Body: `{"target": "acme.com/demo", "domains": ["acme-mail.com"]}` to point, or `{"remove": true, "domains": [...]}` to stop. Omit `domains` for every domain on the order. A bare domain gets https:// and a path is kept. Forwarding to one of the order's own domains, or naming a domain not on the order, is 422; an order with nothing built is 409. The preflight answers `{"target", "problems", "domains": [{"domain", "state", "said"}], "applies"}` with `state` one of `ok`, `same`, `warn`, `fail`. Apply answers `{"ok": true, "domains": <n>, "removed": <bool>, "target"}`.

### Renaming inboxes

- POST /v1/portal/orders/{subscription_id}/accounts/rename — `{"changes": [{"email": "jane@acme-mail.com", "firstName": "Janet"}, {"email": "john@acme-mail.com", "username": "jroe"}]}`. Name each inbox by its current email; send only what changes (`firstName`, `lastName`, `username` = new part before the @: letters, numbers, dots, dashes, underscores). All checked before any is made; up to 20 at a time. Answers `{"applied", "requested", "failed"}`: most apply at once, others become a request for the team.

### Asking us for a change

- POST /v1/portal/requests — `{"subscription_id": "sub_…", "kind": "...", "summary": "..."}`. Kinds: `rename` (say what to rename in summary), `domain_swap`, `cancel` (asks a person to cancel — how a program asks), `forwarding` (applied straight away, not queued: send `forwardTo` with the URL and every built domain is repointed; the answer's `applied_to` counts them). The account is emailed an acknowledgement and again when it is done.
- POST /v1/portal/orders/{subscription_id}/updates — `{"kind": "inbox_rename" | "domain_swap", "details": "..."}`. A domain swap on a prewarmed order is 409.

`summary` / `details` are required, up to 2,000 characters.

## Exporting to a sequencer

Push a built order's inboxes into the customer's own Instantly, Smartlead, PlusVibe or Email Bison. Flow: save the sequencer login once, start an export, follow it until `completed` or `failed`.

- GET /v1/portal/sequencer-keys — saved logins and keys, never the secrets: id, platform, label, active, has_key, has_login, base_url. Needs `read`.
- PUT /v1/portal/sequencer-keys — save or replace one. Needs `write`. Body: `{"platform": "instantly", "api_key": "...", "login": {"email": "...", "password": "..."}, "base_url": "https://yourteam.emailbison.com"}`. API key, login, or both; `label` (default "Default") allows several per platform; `base_url` is Email Bison only and must be a public https address. Saving again under the same platform and label replaces the key and login (send both if you want both kept); a remembered workspace is kept.
- PATCH /v1/portal/sequencer-keys/{key_id} — `{"active": false}` to switch one off without losing it. Needs `write`.
- DELETE /v1/portal/sequencer-keys/{key_id} — remove one. Needs `write`.
- POST /v1/portal/sequencer-check — does a key or login work, before saving? Same body as saving; saves nothing. Answers `{"key": ..., "login": ...}`, each `ok`, `rejected`, `unknown` (could not tell), or null (not checkable). Logins are only checked for PlusVibe. Needs `write`.
- POST /v1/portal/orders/{subscription_id}/export — start: `{"sequencer": "instantly", "workspace": "Main"}`, plus optional `key_id` or `label` to name the saved login. Answers `{"ok": true, "inboxes", "sequencer", "exportId", "id", "login": {"id", "label"} | null}` at once; the connecting happens afterwards. Needs `write`.
- GET /v1/portal/orders/{subscription_id}/exports — `{"exports": [{id, sequencer, workspace, inboxes, processed, status, failed, startedBy, startedAt, checkedAt}]}`, newest first. Needs `read`.

What each needs saved: instantly — login and API key, plus a workspace; smartlead — login; plusvibe — login, plus a workspace; emailbison — API key and base_url, plus a workspace. The workspace is the one inside the sequencer: send it with the first export and it is remembered for later ones. An export never takes a password in its body.

### Which login an export uses

Several logins can be saved for one sequencer (`label`, e.g. one per client). The first that answers decides: (1) `key_id` / `label` in the export body; (2) the order's login, remembered with PUT /v1/portal/orders/{subscription_id}/sequencer `{"sequencer": "instantly", "label": "Client A"}`; (3) the folder's, for an order filed in one — remembered with PUT /v1/portal/workspaces/{workspace_id}/sequencer, or the folder's own login saved in the portal (label `workspace:<folder id>`); (4) the login the order form chose at checkout; (5) the account's: label `Default`, or the only one. A folder's own login is never used for another folder's orders. Several and none choosing: 422 naming them ("This account has 2 Instantly logins saved (Client A, Client B) and nothing says which this order uses.") — never a guess. Another account's login 404; another sequencer's, or switched off, 422. Reseller set-up: a login per client with its own label, remembered on each client's folder or order once.

Export `status`: `starting` / `started`, then progress such as `processing`, ending in `completed`, `failed` or `cancelled`. `processed` counts inboxes connected so far (null until the first report). `startedBy` is the caller (and key), or "WarmInboxes" for an export the team ran; those are listed too. An unfinished export's status is refreshed when it is over 30 seconds old, so polling every 30–60 seconds gets fresh answers.

Refusals: 422 — a login, key or workspace is missing (the sentence names it); 409 — nothing built yet, or the inboxes were not built where exports run (ask us); 502 — the sequencer refused or could not be reached (the reason is in the sentence and the attempt is recorded in the export list). An inbox can be exported to the same sequencer once a day, so fix the cause before retrying.

## Ordering

Order more inboxes on a plan the account is already on — Google, Microsoft 365, Azure tenants or prewarmed — at its price, charged to the card on file. Preview first: it runs exactly the checks placing runs.

- GET /v1/catalogue — the account's own plans (not a price list). Needs `read`.
- POST /v1/orders/preview — what an order would build and cost. Creates and charges nothing. Needs `read`.
- POST /v1/orders — place and charge. Needs `orders:place` and an `Idempotency-Key` header.
- GET /v1/payment-method — `{"saved", "brand", "last4", "exp_month", "exp_year"}`. Needs `read`.
- POST /v1/payment-method/setup-link — `{"success_url", "cancel_url"}` (https, or http://localhost) → a Stripe page `url` where a person saves a card. Charges nothing. Needs `orders:place`.

Catalogue entry: `sku` (what an order names), `product`, `offering`, `unit_amount` (price per billed unit per `interval`), `currency`, `interval`, `mailboxes_per_unit` (inboxes per unit; quantity = inboxes ÷ this, rounded up; null means not recorded yet — preview answers, placing is refused until we set it), `orders_by` (`domains` for Google and Microsoft 365, `tenants` for Azure, `quantity` for prewarmed; Azure has `mailboxes_per_unit` null, as each order picks 25, 50 or 99 a tenant), `orderable`, `why_not`, `on_subscriptions`. A plan the account is not on is not listed; buy it the first time with a checkout, after which it is in the catalogue.

### The order

```
{
  "sku": "price_1S15oC…",
  "promotion_code": "SPRING10",
  "domains": [
    {
      "domain": "acme-mail.com",
      "forward_to": "https://acme.com",
      "mailboxes": [
        { "first_name": "Jane", "last_name": "Doe", "local_part": "jane" },
        { "first_name": "John", "last_name": "Roe", "local_part": "john" }
      ]
    }
  ]
}
```

- `sku` (required): an orderable sku from the catalogue.
- `domains` (required): at least one; up to 500 domains and 2,000 inboxes per order.
- `domains[].domain` (required): each once, and not already on another account.
- `domains[].mailboxes` (required): each needs `first_name`, `last_name`, `local_part` (the part before the @). The first inbox on a domain is its admin.
- `domains[].forward_to` (optional): a website for visitors.
- `promotion_code` (optional).

Never send a price: `price`, `price_id`, `amount`, `unit_amount` or `total` in the body is refused (400). Checks are the same as a portal order file (missing names, unusable addresses, non-domains, duplicates), all reported at once by domain.

Preview answers `{"ok", "sku", "product", "offering", "domains", "mailboxes", "quantity", "price": {"unit_amount", "currency", "interval", "mailboxes_per_unit", "total", "first_invoice"}, "discount": {"code", "percent_off", "amount_off", "applies_to"} | null, "problems": [], "note"}`. `total` is the recurring price per interval; `first_invoice` is the first charge after discount. A problem is 400 with `detail` and `problems`.

### Prewarmed orders

For a sku with `"orders_by": "quantity"` (prewarmed), send a count, not `domains`; we assign that many prewarmed domains from our stock and set them up, and the order lands with the team as `new_order` like any other:

```
{ "sku": "price_1SgoIl…", "quantity": 5, "sequencer": "instantly", "forward_to": "acme.com", "workspace_id": 12, "name": "Acme — prewarmed", "notes": "Any first names are fine" }
```

- `quantity` (required): prewarmed domains, 1 to 200. Each carries the plan's `mailboxes_per_unit` inboxes (3 for Google and Microsoft packs, 100 for an Azure tenant) and is one billed unit.
- `sequencer` (optional): instantly, smartlead, plusvibe or emailbison. We use the login saved with PUT /v1/portal/sequencer-keys; if none is saved, the preview's `note` says so and we ask for it. Never put a password in an order.
- `forward_to` (optional): a website the domains redirect to. `workspace_id` (optional): one of your folders (another account's is 404). `name` (optional, ≤80 chars; defaults to the plan and count). `notes` (optional, ≤2,000 chars). `promotion_code` (optional).
- Naming `domains` is 400. In answers, `domains` is the number of domains ordered and `mailboxes` the inboxes on them. Domains appear in GET /v1/portal/dashboard once assigned.

### Azure and Microsoft 365

Azure (`"orders_by": "tenants"`): one tenant per domain, billed per tenant, each with the same inboxes and usernames:

```
{ "sku": "price_1T3MWf…", "domains": ["acme-mail.com", "acme-team.com"], "inboxes_per_domain": 25, "usernames": ["jane", "john", "…25 in all"], "domain_access": {"method": "nameservers"}, "sequencer": "instantly", "forward_to": "acme.com" }
```

- `domains` (required): 1 to 99 domain names, one tenant each, none twice.
- `inboxes_per_domain` (required): 25 (lowest risk, 4-5 cold emails per inbox a day), 50 (3-4) or 99 (2).
- `usernames` (required): exactly `inboxes_per_domain` names, the part before the @ (letters, numbers, dots, dashes, underscores), none twice; each is created on every tenant.

Microsoft 365 (`"orders_by": "domains"`): sent exactly like a Google order (`domains` with `mailboxes` on each), billed per inbox.

Both take the optional `sequencer`, `forward_to`, `workspace_id`, `name`, `notes` that prewarmed takes, and the domain choice:

- `buy_domains`: true — we register the domains named (.com, .co or .site); registration is a one-off on the first invoice (`price.registration` in the preview, included in `first_invoice`, never in `total`). false (default) — they are yours.
- `domain_access` (own domains): `{"method": "nameservers"}` (default; each domain must already point at the pair from POST /v1/portal/domains/nameservers), `{"method": "grant", "registrar": "godaddy"}` (invite setup@warminboxes.com at the registrar with DNS permissions), or `{"method": "credentials", "registrar": "godaddy", "username": "…", "password": "…"}`. `registrar`: godaddy, porkbun, spaceship, dynadot or other (then `other_registrar`). A registrar password is kept on the order for our team and never returned by the API.

Checked before charging: a domain to buy must not be registered; an own domain must be registered, must not already be in another Microsoft tenant, and with nameservers must already point at our pair; the registrar fields, usernames and domain list as the portal form checks them. A failing domain is dropped with its reason in `problems`; none left is 400. A check that could not answer (registry or DNS silent, a TLD without public lookup such as .co) never refuses: the preview's `note` says so and the team checks. On the nameservers method, placing answers `nameservers`: the pair each domain points at.

POST /v1/portal/domains/nameservers — `{"provider": "microsoft", "domains": ["acme-mail.com"]}` (≤10 a call; microsoft for Azure and Microsoft 365, google for Google) → `{"domains": {"acme-mail.com": {"nameservers": [...]}}}`, or `error` for a domain Cloudflare will not host. Reserves the pair on our Cloudflare, so needs `orders:place`.

Placing creates the subscription and charges the card immediately. Answers `{"ok", "order_id", "subscription_id", "sku", "domains", "mailboxes", "quantity", "charged": {"amount", "currency", "interval", "first_invoice"}, "discount", "replayed", "problems", "status"}`. The order appears in GET /v1/portal/orders at once as `new_order`; a person checks it before anything is built. Refusals after a passing preview: 402 (card declined or needed the cardholder to confirm; nothing charged, no order), 409 (no card; price changed since the preview; no recorded inboxes-per-unit), 429 (12 orders this hour), 502 (Stripe unreachable; retry with the same Idempotency-Key).

### Idempotency

`Idempotency-Key` header, required on POST /v1/orders: 8–64 characters of letters, digits, dot, dash, colon, underscore. Retrying with the same key returns the order the first attempt placed, with `"replayed": true` and no second charge. Use a new key for a new order; derive it from your own job id so a crash-and-restart reuses it. Keys are scoped per account.

### Promotion codes

`promotion_code` takes the same codes as the portal checkout. The preview's `discount.applies_to` says how long it lasts: "the first invoice only — renewals are charged at full price" (a 100%-off code of this kind makes the first invoice free, not the subscription), "the first N months — full price after that", or "every invoice, for as long as the subscription runs". Refused with 400 (never silently dropped) if unknown or inactive, expired, first-order-only and this is not the first order, below its minimum order amount, or no longer discounting.

## Buying a new plan (checkout)

A checkout opens a Stripe payment page for anything the shop sells — a plan the account is not on yet, prewarmed inboxes, domain registration. A person opens the page and pays; the API cannot complete the payment. To re-order a plan already on the account and charge the card straight away, use POST /v1/orders instead. Once a checkout is paid, its plan is in the catalogue.

- POST /v1/portal/checkout — save an order and open the payment page. Needs `orders:place`.
- GET /v1/portal/checkout/{session_id} — after payment: `{"subscription": "sub_…", "paid": true, "amount": 70.0, "currency": "usd"}` (`amount` after any promotion code). Another account's or a made-up session is 404. Needs `read`.

Request:

```
{
  "mode": "subscription",
  "order": {
    "order_name": "Q4 outbound",
    "line_items": [{ "priceId": "price_1S15oCGCV13faDNPA8npFlww", "quantity": 20 }],
    "metadata": {
      "product_type": "google",
      "total_inboxes": 20,
      "number_of_domains": 10,
      "domain_option": "existing",
      "existing_domains": "acme-mail.com\nacme-team.com",
      "email_accounts": [{ "name": "jane", "firstName": "Jane", "lastName": "Doe" }],
      "sending_platforms": ["instantly"],
      "forwarding_address": "https://acme.com"
    }
  },
  "success_url": "https://app.acme.com/thanks?session_id={CHECKOUT_SESSION_ID}",
  "cancel_url": "https://app.acme.com/order"
}
```

Answer: `{"url": "https://checkout.stripe.com/c/pay/cs_live_…", "session_id": "cs_live_…"}`. Give `url` to the person who pays. Stripe's page offers cards the account paid with before and takes promotion codes typed there (not in the body).

- `mode` — always "subscription"; a one-off payment is refused.
- `order.order_name` — required.
- `order.line_items` — required; each `priceId` from the list below (or a reseller's own price), each `quantity` a whole number ≥ 1. A domain-registration line is one-off and goes on the same checkout as a plan, not alone.
- `order.metadata` — what to build (below).
- `success_url` / `cancel_url` — https (or http://localhost). `{CHECKOUT_SESSION_ID}` in success_url is filled in by Stripe.
- `metadata` (optional) — `workspace_id` (one of your folders; another account's is 404) and `referral_code`. Nothing else is read.

### What can be bought

A line must name a price the shop sells; anything else — including prices the shop used to sell — is 422 "That price is not available on this account.", before anything is created.

- price_1S15oCGCV13faDNPA8npFlww — Google Workspace, monthly — $3.50/month per inbox, any number
- price_1SgnchGCV13faDNPDAirnw62 — Google Workspace, monthly, 30–99 — $3.25/month per inbox
- price_1SmfbnGCV13faDNPZ2QZjQXg — Google Workspace, monthly, 100+ — $3.00/month per inbox
- price_1SmfSHGCV13faDNPRbC8FzHJ — Google Workspace, yearly — $36/year per inbox, any number
- price_1SmfXrGCV13faDNPV9tSPiPD — Google Workspace, yearly, 30–99 — $30/year per inbox
- price_1SmfYmGCV13faDNP2MBMfd1g — Google Workspace, yearly, 100+ — $24/year per inbox
- price_1Q3nAdGCV13faDNPkM246uvk — Microsoft 365, monthly — $3/month per inbox
- price_1SgnzNGCV13faDNPxAFAa1Is — Microsoft 365, yearly — $24/year per inbox
- price_1T3MWfGCV13faDNP4yMIvNuO — Azure tenant (25–100 inboxes) — $50/month per tenant (one domain each), 1–99
- price_1SgoIlGCV13faDNPtCZHkjN0 — Prewarmed Google, monthly — $24/month per pack of 3 inboxes on one prewarmed domain
- price_1UEroDGCV13faDNP5fyIVFT9 — Prewarmed Google, every 2 months — $42 per pack
- price_1UErpEGCV13faDNPibm3US0S — Prewarmed Google, quarterly — $54 per pack
- price_1SgoBVGCV13faDNPUtNoNHL2 — Prewarmed Microsoft, monthly — $24/month per pack of 3
- price_1UErqqGCV13faDNProTHieZA — Prewarmed Microsoft, every 2 months — $42 per pack
- price_1UErsVGCV13faDNPHkLjN3EQ — Prewarmed Microsoft, quarterly — $54 per pack
- price_1T3MUHGCV13faDNPzWN3CSIL — Prewarmed Azure tenant, monthly — $69/month per tenant
- price_1UEruhGCV13faDNPikX7kfpd — Prewarmed Azure tenant, every 2 months — $120 per tenant
- price_1T9UmqGCV13faDNPau6lpxIX — Prewarmed Azure tenant, quarterly — $150 per tenant
- price_1Roqh9GCV13faDNPS7OFt7eu — .com domain registration — $12 once, per domain
- price_1RoqhVGCV13faDNP1lc9pfqY — .co domain registration — $10 once, per domain
- price_1Roqi1GCV13faDNPKR3dwYaX — .site domain registration — $3 once, per domain

The ranges on the tiered Google prices are what each price is for. Stripe charges the listed price times the quantity and the order records that total.

Resellers may also name their own negotiated prices — accepted only on the reseller's own account, inside their dates, at or above their minimum quantity, on the cheapest tier the quantity qualifies for, and when the order reaches the reseller's minimum spend. Otherwise refused with a sentence naming the rule (or, for a price that is not yours, the same "not available" sentence).

### Build details (`order.metadata`)

Stored with the order, shown to our team, and returned by GET /v1/portal/orders/{subscription_id}/details. Fields the build reads:

- `product_type` — "google", "microsoft" or "azure".
- `total_inboxes` — inboxes in all.
- `number_of_domains` — how many domains.
- `domain_option` — "existing" (your own domains) or "new" (we buy them).
- `existing_domains` — your domains, one per line or comma-separated.
- `domain_ideas` — domains you would like bought (suggestions; the team confirms availability). Add a domain-registration line for them.
- `email_accounts` — `[{"name": "jane", "firstName": "Jane", "lastName": "Doe"}]`; `name` is the part before the @.
- `sending_platforms` — `["instantly"]` or `{"instantly": true}`: instantly, smartlead, plusvibe or emailbison.
- `forwarding_address` — a website for visitors.
- `azure_inboxes_per_domain`, `azure_usernames` — for Azure.

Anything else is kept and shown to the team (e.g. `special_instructions`). Never put passwords in an order: save the sequencer login with PUT /v1/portal/sequencer-keys; the build uses it for the sequencer named in `sending_platforms`.

### After payment, and if nobody pays

After paying, Stripe sends the person to `success_url`; the order appears in GET /v1/portal/orders as `new_order` and a person checks it before anything is built. The payment page stays open for 31 minutes. If it expires unpaid, the account's email address gets a reminder (at most one a day) — bear this in mind when testing — and the order is listed in GET /v1/portal/placed-orders as `pending`, then `expired` after a day. Nothing is charged.

Refusals: 403 (no `orders:place`), 404 (`workspace_id` not yours), 422 (a price not on the shop; a reseller price that does not apply; `mode` not "subscription"; no `order_name`; no line items or quantity < 1; a non-https return address), 429 (12 orders or checkouts this hour), 502 (Stripe unreachable; nothing charged).

## Errors

Every error has a sentence in `detail`, written to be shown to a person.

- 400 — refused as sent; `detail` says what to change.
- 401 — key missing, not recognised, or revoked (one sentence for all three).
- 402 — Stripe did not take a payment. Nothing was charged.
- 403 — the key lacks the scope, or only a signed-in person may do this.
- 404 — no such record on the account (another account's record answers the same).
- 409 — cannot go ahead in the current state (no card, price changed, nothing built, name taken). Nothing changed.
- 413 — body over 4 MB.
- 422 — a field is missing or the wrong shape (return URL, sequencer login, workspace).
- 429 — too many requests; wait `Retry-After` seconds.
- 500 — something broke on our side; it has been logged.
- 502 — Stripe or a sequencer refused or could not be reached; the reason is in the sentence.
- 503 — sign-in checks briefly unavailable; retry.

4xx fails the same way when retried unchanged. Retry 429, 502 and 503 after a pause — for placing orders, with the same Idempotency-Key.

## Rate limits

- 240 / minute per key — reading: every GET, and every MCP message.
- 60 / minute per key — changing: anything that is not a GET.
- 12 / hour per account — placing orders and opening checkouts together, shared by the API, MCP and CLI, counted whether or not the card was accepted.
- 900 / minute per network address — everything.

JSON answers carry `X-RateLimit-Limit` and `X-RateLimit-Remaining`. A refusal is 429 with `Retry-After` in seconds; wait that long. Ask us to raise limits if an integration needs more.

## MCP server

Stateless Streamable HTTP at https://api.warminboxes.com/mcp, key as bearer token. Same code as the REST API, same answers and rules. Protocol versions 2025-11-25, 2025-06-18, 2025-03-26. One JSON-RPC message per request; batches refused.

Claude Code:

```
claude mcp add --transport http warminboxes https://api.warminboxes.com/mcp \
  --header "Authorization: Bearer wib_your_key_here"
```

Clients that take a JSON config (Claude Desktop, Cursor):

```
{
  "mcpServers": {
    "warminboxes": {
      "url": "https://api.warminboxes.com/mcp",
      "headers": { "Authorization": "Bearer wib_your_key_here" }
    }
  }
}
```

Tools (an assistant sees only those its key's scopes allow):

- get_dashboard (read) — orders and their domains, with build state.
- list_orders (read) — the flat list of orders.
- get_billing (read) — invoices, spend, and what is due next.
- get_deliverability (read) — how the domains are sending. Argument: `period` (7d, 30d, 90d).
- list_catalogue (read) — what can be ordered, and at what price.
- check_payment_method (read) — whether there is a card to charge.
- preview_order (read) — check and price an order; creates nothing. Takes the same order as POST /v1/orders.
- list_sequencer_keys (read) — which sequencer logins are saved; never the secrets.
- list_exports (read) — how each export of an order has gone. Argument: `subscription_id`.
- export_to_sequencer (write) — push an order's inboxes into the customer's sequencer. Arguments: `subscription_id`, `sequencer`, optional `workspace`, optional `key_id` or `label` (which saved login).
- get_nameservers (read) — the nameservers each of an order's domains should point at, and whether it does yet. Argument: `subscription_id`.
- create_card_setup_link (orders:place) — a Stripe page for a person to save a card. Arguments: `success_url`, `cancel_url`.
- place_order (orders:place) — place an order and charge the card. The order plus `idempotency_key`.

There is no checkout tool: a checkout ends on a payment page a person must open.

A refused tool call returns a normal result with `isError: true` and the same sentence the REST API gives — read it and tell the customer; do not retry as if the connection failed. Every message counts against the reading limit; orders count against the account's 12 per hour. To let an assistant quote without ordering, give it a key without `orders:place`.

There is no tool for saving a sequencer login, on purpose: it would put the password into the assistant's conversation. Logins are saved in the portal or with PUT /v1/portal/sequencer-keys; if one is missing, the export's answer says so — tell the customer where to add it, and never ask them for a password.

## CLI

`wib` is one Python 3 file with no install, served by the API with its address built in.

```
curl -fsSL https://api.warminboxes.com/v1/cli -o wib && chmod +x wib
export WIB_API_KEY=wib_your_key_here
./wib catalogue
./wib preview order.json
./wib order order.json --idempotency-key monday-1 --yes
```

Commands: `catalogue`; `preview <spec>`; `order <spec> --idempotency-key K --yes`; `payment-method`; `setup-link --success-url U --cancel-url U`; `dashboard`; `orders`; `billing`; `deliverability --period 7d`; `requests`; `workspaces`; `export <order> --sequencer instantly [--workspace Main] [--label "Client A" | --key-id 7]`; `exports <order>`; `nameservers <order>`; `sequencer-keys`; `sequencer-key-save <spec>` (a file or `-` for stdin, so the password stays out of shell history); `get <path>` and `post <path> [body]` for any other route — e.g. `post /v1/portal/checkout checkout.json` to open a checkout.

A spec can be a file, `-` for stdin, or the JSON itself. Every command prints JSON and exits 0; a refusal prints the reason on stderr and exits 1, so `wib … || handle` works; a malformed command exits 2. `order` needs `--yes` on top of the key's scope. `--key` and `--api` override `WIB_API_KEY` and `WIB_API_URL`. It refuses to send the key to a non-https address (other than localhost). When rate limited it prints how many seconds to wait.

## Questions

- Test mode? Yes: a test key (wib_test_…) uses the sandbox described above. Live keys act on the real account.
- An order request timed out — was it placed? Resend with the same Idempotency-Key: you get the original order back (`replayed: true`) or it is placed now.
- Why is a plan not orderable? It is a domain registration on its own, or its inboxes-per-unit is not recorded yet (ask us). `why_not` says which. Google, Microsoft 365, Azure and prewarmed are all orderable.
- How do I buy a plan the account is not on? Open a checkout and have a person pay on the Stripe page; then it is in the catalogue.
- Does the team see API orders? Yes, as ordinary orders, each naming the key that placed it.
- How long does a build take? A person checks each order first and timing depends on the order; follow `status` in GET /v1/portal/orders.
- Webhooks? Not yet; poll.
