Order inboxes from code.
Everything the WarmInboxes portal shows you is available as JSON, and orders — Google, Microsoft 365, Azure and prewarmed — can be placed without opening it. Call the REST API, give an AI assistant the MCP server, or run the one-file wib command line. All three use the same key and obey the same rules.
$ curl https://api.warminboxes.com/v1/orders/preview \
-H "Authorization: Bearer $WIB_API_KEY" \
-H "Content-Type: application/json" \
-d @order.json
{
"ok": true,
"domains": 1,
"mailboxes": 2,
"quantity": 2,
"price": {
"total": 7.0,
"first_invoice": 7.0,
"interval": "month"
},
"problems": []
}
$ ./wib preview order.json
{ "ok": true, "quantity": 2,
"price": { "total": 7.0, ... } }
You: Price 2 more inboxes on
acme-mail.com, Jane and John.
Claude: That adds 2 units at $3.50,
$7.00 a month. Nothing has
been ordered yet.
Reading this as an agent? /llms.txt has this whole reference as plain Markdown.
On this page
| Base URL | https://api.warminboxes.com |
| Version | Everything is under /v1. |
| Format | JSON in, JSON out. Errors carry a sentence in detail. |
| Auth | One API key, sent as Authorization: Bearer wib_… |
| Ways in | REST, the MCP server at /mcp, and the wib CLI. |
What you can do with it
- Read everything the portal shows: your orders, every domain and how far it has got, invoices and what is due next, deliverability figures, change requests and our replies.
- Change what the portal lets you change: folders, order names, which sequencer an order sends from, the monitors, where domains forward to, and the names on inboxes.
- Export a built order's inboxes into your own Instantly, Smartlead, PlusVibe or Email Bison, and follow the export until it finishes.
- Order more inboxes — Google, Microsoft 365, Azure tenants or prewarmed — on a plan you are already on, at the price you already pay, charged to the card on file — or open a checkout for anything the shop sells, for a person to pay on Stripe's page.
What it deliberately does not do is listed under What a key cannot do. Cancelling an order, managing your team and minting keys all stay in the portal, with a person.
Quickstart
Five minutes from nothing to a priced order. Each step is safe to run: nothing is created or charged until step 5.
1. Make a key
In the customer portal, open Settings → API keys and create one. Only the account's owner can do this. Choose its scopes (see Keys and scopes). For a first try, read is enough. The full key is shown once, so copy it then.
$ export WIB_API_KEY=wib_3f9a1c2e7b40_kq8…2. Make a first call
Your dashboard: every order and the domains it bought.
$ curl https://api.warminboxes.com/v1/portal/dashboard \
-H "Authorization: Bearer $WIB_API_KEY"A 401 means the key was not sent or not recognised. Check the Bearer prefix and that the whole key was copied.
3. See what you can order
$ curl https://api.warminboxes.com/v1/catalogue \
-H "Authorization: Bearer $WIB_API_KEY"The catalogue lists the plans you are already on, each with a sku and whether it can be ordered from code (orderable). If it is empty, or nothing is orderable, start with a checkout (or the portal); once that order is paid, its plan appears here.
4. Price an order
Write what you want built in order.json: the sku, your domains, and the people on each.
{
"sku": "price_1S15oC…",
"domains": [
{ "domain": "acme-mail.com",
"mailboxes": [
{ "first_name": "Jane", "last_name": "Doe", "local_part": "jane" },
{ "first_name": "John", "last_name": "Roe", "local_part": "john" }
] }
]
}$ curl https://api.warminboxes.com/v1/orders/preview \
-H "Authorization: Bearer $WIB_API_KEY" \
-H "Content-Type: application/json" -d @order.jsonA preview runs every check that placing runs, so "ok": true means the order will be accepted. A 400 lists every problem at once, by domain.
5. Place it
This charges the card on file. The key needs the orders:place scope, and every placement needs an Idempotency-Key (see Retry with the same Idempotency-Key).
$ curl https://api.warminboxes.com/v1/orders \
-H "Authorization: Bearer $WIB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: acme-2026-10-07" -d @order.jsonThe order then appears in GET /v1/portal/orders straight away. A person on our team checks it before anything is built.
Keys and scopes
Every request carries one API key as a bearer token:
Authorization: Bearer wib_3f9a1c2e7b40_kq8Zt…What a key is
A key looks like wib_<prefix>_<secret>. The prefix identifies the key and is safe to show; the portal's key list shows it so you can tell keys apart. The secret is shown once, when the key is made. We keep only a hash of it, so a lost key cannot be recovered: revoke it and make another.
A key belongs to exactly one account, and every answer is about that account and nothing else. Asking about an order that is not yours gets 404, the same answer as an order that does not exist, so a guessed id reveals nothing.
Scopes
Each key carries one or more scopes, fixed when it is made. Give each program only what it needs. A reporting dashboard needs read and nothing else.
| Scope | Lets the key |
|---|---|
| read | Read everything: every GET, and POST /v1/orders/preview, which answers a question and creates nothing. |
| write | Change things: folders, order names, sequencer choice, monitors, forwarding, inbox renames, change requests, sequencer logins, and exports. Every non-GET on /v1/portal/…. |
| orders:place | Spend money: place orders (POST /v1/orders), open checkouts (POST /v1/portal/checkout), and open a page to save a card (POST /v1/payment-method/setup-link). |
Scopes do not imply each other. A key with only write cannot read, and one with only orders:place cannot preview. A typical ordering key carries all three: read to check the catalogue and preview, write to manage what it ordered, and orders:place to place.
A key used for more than its scopes gets 403 with a sentence naming the scope it lacks, for example This API key does not carry the 'write' scope.
Looking after keys
- Treat a key like a password. Anyone holding it can do what its scopes allow on your account.
- Never put one in a web page, a mobile app, or a public repository. Keep it on a server or in your shell's environment.
- Revoke a key from the same Settings page the moment it might have leaked. Revocation takes effect on the very next request. Revoked keys stay listed, so you can see one existed and was turned off.
- The key list shows when each key was last used. A key nobody has used in months is one to revoke.
- Every request a key makes is recorded with the key's name. When a key creates something, such as an order or a request, the record names it, e.g.
you@acme.com (api key: reporting), so your team and ours can tell a program from a person.
Sandbox
Build and test an integration against a test key before you give it a live one. A test key looks like wib_test_<prefix>_<secret> and works on the same base URL, the same routes, the same scopes, the same rate limits and the same error sentences as a live key. It answers from a pretend copy of your account, so nothing it does is real: no card is charged, no Stripe object is made, no inbox is built, no DNS record or sequencer is touched, no email is sent, and our team never sees it. When your code works with the test key, swap in a live key and change nothing else.
Make one in the portal: Settings → API keys, switch on Test key, and pick scopes as you would for the live key you will use later. The key list labels it "Test key". A test key belongs to your account like any other, is shown once, and is revoked the same way.
Every answer to a test key carries the header X-Sandbox: true, so your code and your logs can always tell which world a response came from. GET /v1/sandbox describes your sandbox: its orders, folders and checkouts, the controls below, and the values that behave differently.
A first test run
# start clean, then order and follow it, exactly as you will live
curl -X POST https://api.warminboxes.com/v1/sandbox/reset -H "Authorization: Bearer $WIB_TEST_KEY"
curl -X POST https://api.warminboxes.com/v1/orders/preview -H "Authorization: Bearer $WIB_TEST_KEY" \
-H "Content-Type: application/json" -d @order.json
curl -X POST https://api.warminboxes.com/v1/orders -H "Authorization: Bearer $WIB_TEST_KEY" \
-H "Content-Type: application/json" -H "Idempotency-Key: test-run-1" -d @order.json
# skip the five-minute build
curl -X POST https://api.warminboxes.com/v1/sandbox/orders/sub_test_…/status -H "Authorization: Bearer $WIB_TEST_KEY" \
-H "Content-Type: application/json" -d '{"status": "live"}'What you start with
The first call with a test key creates your account's sandbox, which all of its test keys share. It holds five orders, so every read route has something to return:
| Order | What it is for |
|---|---|
| sub_test_acme_ | A finished Google order: 20 inboxes on 10 domains, in folder 1, forwarding, with a sequencer chosen. Use it for exports, forwarding, renames and sign-in codes. |
| sub_test_globex_ | A prewarmed order (6 inboxes on 2 domains). A domain swap on it is refused, as live. |
| sub_test_initech_ | A Microsoft order still being built. One domain is waiting_on_you with the nameservers to set. Exports and forwarding are refused because nothing is built yet. |
| sub_test_hooli_ | A finished Azure order: 2 tenants with 25 inboxes each. |
| sub_test_umbrella_ | A cancelled order, for lists that include ended ones. |
The sandbox catalogue has the Google plan ($3.50 an inbox), the prewarmed plan (ordered by quantity), the Microsoft 365 plan and the Azure plan ($50 a tenant), all orderable. A test card ending 4242 is saved. The sandbox shop sells everything the live shop does, at the same prices (and your reseller prices at yours): pay a test checkout for a new plan and it joins your sandbox catalogue, as it would live.
What is checked
A test request is refused for the same reasons as a live one, in the same words. The live parser checks the order spec. Checkout prices are checked against the live price list, and reseller prices against your live reseller terms. Return URLs, forwarding targets, inbox renames, folder names, sequencer choices and the logins an export needs are all checked the same way. Routes only a signed-in person may use (cancelling, API keys, the team, referrals) refuse a test key with the live 403. A route this reference does not list refuses a test key with 403, so a test key can never reach live data.
Time moves
So polling code meets the same changes it will meet live, an order placed with a test key, or paid through a test checkout, is new_order for its first minute. It is then build_in_process, uploading_in_process from minute three, and setup_completed with its inboxes listed from minute five. An export goes from started to processing and is completed after one minute. An unpaid checkout lapses after 31 minutes, and no reminder email is sent.
Paying a test checkout
POST /v1/portal/checkout and POST /v1/payment-method/setup-link answer with a url to a sandbox payment page instead of Stripe's. Pay or cancel there, and the page sends the browser to your success_url (with {CHECKOUT_SESSION_ID} filled in) or cancel_url, as Stripe would. Test session ids start cs_test_.
Sandbox-only routes
These skip the waiting, for test suites. Only test keys can call them, and they need read.
| GET /v1/sandbox | What the sandbox holds and how to drive it. |
| POST /v1/sandbox/reset | Put the sandbox back to the four starting orders. Run it at the start of a test suite. |
| POST /v1/sandbox/orders/{subscription_id}/status | {"status": "live"} puts an order in any order status at once, including payment_failed and cancelled. It then stays there. |
| POST /v1/sandbox/checkout/{session_id}/pay | Pay a test checkout, or save the card of a setup link, without a browser. |
| POST /v1/sandbox/checkout/{session_id}/expire | Let a test checkout lapse now. |
Making things fail
Send X-Sandbox-Simulate on any test request to get a failure you could meet live:
| rate_limited | 429 with Retry-After: 30. |
| server_error | 500. |
| card_declined | POST /v1/orders answers 402, and nothing is placed. |
| no_card | GET /v1/payment-method says no card is saved, and POST /v1/orders answers 409. |
| sequencer_down | An export answers 502 and is listed as failed. A sequencer check answers unknown. |
A few values behave differently on purpose:
- A domain ending
-taken.combelongs to another account, and is already registered when you ask us to buy it. - For an Azure or Microsoft 365 order: a domain ending
-unregistered.comis registered to nobody,-in-tenant.comis already in another Microsoft tenant,-not-pointed.comdoes not point at its nameservers yet,-no-nameservers.comwas never given nameservers, and-unknown.comis one no check can get an answer about (a note, not a refusal). Every other domain passes. - The promotion codes
SANDBOX10(10% off) andSANDBOX100(100% off) take money off the first invoice. Any other code is unknown. - A sequencer key or password of
rejectedfails a sequencer check.
MCP and the command line
Both take a test key in place of a live one. Every MCP tool answers from the sandbox, including place_order, and the assistant is told on connecting that nothing it does is real. For wib, set export WIB_API_KEY=wib_test_…. Then:
wib sandboxshows the sandbox;wib sandbox reset,wib sandbox status sub_test_… live,wib sandbox pay cs_test_…andwib sandbox expire cs_test_…drive it.wib --simulate card_declined order order.json --idempotency-key ksendsX-Sandbox-Simulate.orderneeds no--yeswith a test key, because nothing is charged.
Limits
Orders placed with a test key count against their own hourly limit, not your live one; the other rate limits are the live ones. A sandbox holds up to about 500 KB of pretend records; past that, changes answer 409 until you reset it. Every test call is in your account's audit trail, marked as the sandbox.
Conventions
| Requests | Send JSON bodies with Content-Type: application/json. Bodies over 4 MB are refused with 413 before they are read. |
| Responses | JSON. Lists come back whole, newest first where order matters. There is no pagination. |
| Times | ISO 8601, in UTC, e.g. "2026-09-24T19:10:51". A time with no offset is UTC. |
| Money | Decimal amounts in the currency's main unit, so 3.5 is $3.50. Currencies are upper-case codes, e.g. "USD". |
| Order ids | An order is identified by its subscription id, sub_…, as every listing gives it. Folders have numeric ids. |
| Paths | The portal's own screens call the same routes without the /v1 prefix. Use /v1: it is the version that will not move. |
| Errors | {"detail": "…"}, one sentence written to be shown to a person. An order that cannot be built also lists each reason in problems. See Errors. |
Reading your account
All of these need read. Each one is the data behind a screen in the portal, so if the portal shows it, one of these returns it.
- GET/v1/portal/dashboardEvery order with the domains it bought, how far each domain has got, and account totals.
- GET/v1/portal/ordersThe flat list of your orders, newest first, including cancelled ones.
- GET/v1/portal/placed-ordersEvery order as you placed it — the form you filled in — including checkouts that were never paid.
- GET/v1/portal/billingWhat you have been charged, what is due next, and every invoice with links to it.
- GET/v1/portal/analyticsDeliverability for your domains.
?period=7d,30dor90d. - GET/v1/portal/workspacesYour folders and how many domains are in each.
- GET/v1/portal/requestsEvery change you have asked for, newest first, with our reply.
- GET/v1/portal/referralsYour referral code, the terms, and what it has earned.
- GET/v1/portal/teamWho else has access to the account, and in which role.
- GET/v1/portal/accountsThe accounts this caller may act on. For a key, always one: the account it belongs to.
The dashboard
The richest single read. workspaces here means orders (the portal groups the estate by the order that paid for it), each with its domains. totals counts across them all.
{
"customer": { "name": "Acme", "email": "you@acme.com" },
"workspaces": [{
"id": "sub_1Q8xYz…",
"name": "October outbound",
"placedAt": "2026-09-24T19:02:39",
"product": "google",
"mrr": 35.0,
"status": "setup_completed",
"inboxes": 10,
"sequencer": "instantly",
"renewsAt": "2026-10-24T19:02:39",
"endsAt": null,
"workspaceId": 6,
"monitors": { "reconnection": false, "deliverability": true },
"domainCount": 5,
"domains": [{
"domain": "acme-mail.com",
"provider": "google",
"inboxes": 2,
"status": "live",
"forwardingTo": "https://acme.com",
"nameservers": null,
…
}]
}],
"totals": { "workspaces": 1, "domains": 5, "inboxes": 10, "mrr": 35.0, … },
"freshness": { "billing": "2026-10-07T09:13:45", "fleet": null }
}| Field | Meaning |
|---|---|
| product | What the order bought: google, microsoft365, azure, prewarmedGoogle, prewarmedMicrosoft or prewarmedAzure. |
| status | Where the order is. See Order and domain statuses below. |
| mrr | What the order costs per month, in dollars. |
| workspaceId | The folder the order is filed in, or null. |
| monitors | Which watches you have switched on for this order. |
| freshness | When the figures were last brought up to date from billing and from the fleet. null means not yet. |
Order and domain statuses
An order's status follows it from payment to a finished build, then says how billing stands. Build progress is our team's record of work and moves when they move it; billing is read from Stripe.
| Order status | Meaning |
|---|---|
| pending | A checkout was opened but its first payment has not gone through. |
| expired | That checkout was never paid, and has lapsed. |
| new_order | Paid, and waiting for the team to pick it up. |
| update_requested | You asked for a change while it was being built, and the team is on it. |
| build_in_process | The inboxes are being created. |
| uploading_in_process | Built, and being connected to your sequencer. |
| setup_completed | The build is finished. |
| live | Finished, confirmed, and running. |
| cancelling | Set to end at the close of its billing period. |
| cancelled | Ended. |
| payment_failed | The last charge did not go through. This outranks every other status, because it is the one you need to act on. |
| Domain status | Meaning |
|---|---|
| setting_up | Being built. |
| waiting_on_you | Waiting for you to point the domain's nameservers at the ones shown in nameservers. |
| live | Built, with its inboxes. |
| failed | The build of this domain failed. detail may say why. |
Orders
GET /v1/portal/orders is the flat list, one entry per order, including the cancelled ones the dashboard leaves out.
[{
"id": "sub_1Q8xYz…",
"name": "October outbound",
"placedAt": "2026-09-24T19:02:39",
"status": "setup_completed",
"amount": 35.0,
"inboxes": 10,
"domains": 5,
"provider": "google",
"sequencer": "instantly"
}]GET /v1/portal/placed-orders answers a different question: what you asked for, as you filled it in, rather than what is being billed. It includes checkouts that were opened and never paid.
Billing
{
"thisMonth": 0,
"next30Days": 226.5,
"activeSubscriptions": 6,
"pastDue": 0,
"credits": 0.0,
"upcoming": [{ "date": "2026-10-18T15:28:17", "orderId": "sub_…", "order": "October outbound", "amount": 35.0, "product": "google" }],
"byOrder": [{ "id": "sub_…", "name": "October outbound", "mrr": 35.0, "spent": 70.0, "startedAt": "…", "renewsAt": "…" }],
"invoices": [{ "id": "in_…", "number": "47YT1WKV-0006", "date": "…", "amount": 35.0,
"status": "paid", "order": "October outbound", "hostedUrl": "https://invoice.stripe.com/…", "pdfUrl": "https://pay.stripe.com/…" }],
"freshness": "2026-10-07T09:13:45"
}The amounts are Stripe's own. The only arithmetic is summing them and projecting the next renewal date forward, which Stripe does not store. hostedUrl and pdfUrl open the invoice itself.
Deliverability
GET /v1/portal/analytics?period=7d returns one row per domain you own, with its sending figures from our deliverability monitor.
{
"available": true,
"range": { "from": "2026-09-30T07:00:00+00:00", "to": "2026-10-07T07:00:00+00:00" },
"rows": [{ "domain": "acme-mail.com", "provider": "google", "inboxes": 2, "workspace": "Q4",
"stats": { "sent": 412, "replies": 9, "bounces": 3, "opens": 0, "health": "pending" } }],
"totals": { "sent": 412, "replies": 9, "bounces": 3 }
}sent, replies and bounces are measured. opens is not tracked and is always 0, and health is not graded yet and always reads "pending"; both are in the shape so they can be filled in later without it changing.
Read available before the numbers. When it is false there are no figures to show yet — the monitor has not measured these domains, or cannot be reached — and the stats are placeholders. Zero replies with available: false means "not measured", not "nobody replied". Only report figures when available is true.
Folders, requests, referrals, team
GET /v1/portal/workspaces:{"workspaces": [{"id", "name", "domains", "created_at", "sequencer", "monitors"}], "unassigned"}, whereunassignedcounts domains in no folder.GET /v1/portal/requests:[{"id", "orderId", "orderName", "kind", "summary", "raisedAt", "status", "note"}].statusisopenuntil someone on our team picks it up, thenassigned;noteis our reply.GET /v1/portal/referrals: your partner record (ornullif you have not taken a code), the commission terms, totals by stage (pending,payable,paid), each earning, and asuggested_code. It never names the customers who paid.GET /v1/portal/team:[{"id", "email", "role", "invitedAt", "acceptedAt"}]. Roles aremember(can do everything but manage the team and keys) andviewer(read only).
One order in detail
Everything about a single order, by its sub_… id. All need read. An id that is not one of your orders is 404.
- GET/v1/portal/orders/{subscription_id}/detailsThe order form it was placed with: the domains, the people on them, where they forward. Passwords and other secrets are never included.
- GET/v1/portal/orders/{subscription_id}/accountsThe sign-in accounts (inboxes) on the order:
{"accounts": [{"email", "firstName", "lastName"}]}. - GET/v1/portal/orders/{subscription_id}/accounts/codeThe current 2-step verification code for one of those accounts.
?email=jane@acme-mail.com; only an account on this order. - GET/v1/portal/orders/{subscription_id}/requestsThis order's request log: the same list as
/v1/portal/requests, for one order. - GET/v1/portal/orders/{subscription_id}/updatesInbox-rename and domain-swap requests on this order, each
in_progressordone. - GET/v1/portal/orders/{subscription_id}/cancellationWhether the order is set to end, when, and why. Cancelling itself is done in the portal (see What a key cannot do).
- GET/v1/portal/orders/{subscription_id}/exportsEvery export of this order to a sequencer, and how each went. See Exporting.
- GET/v1/portal/orders/{subscription_id}/nameserversThe two nameservers each of the order's domains should point at, and whether it does yet. Reads only. See Nameservers.
Nameservers
For an order on your own domains, each domain is connected by pointing its nameservers at a pair on our Cloudflare. GET /v1/portal/orders/{subscription_id}/nameservers tells you, per domain, the pair to set at your registrar and whether the domain already points at it — call it to update your own records, and again to see when the change has taken. It never changes anything.
{ "domains": [
{ "domain": "acme-mail.com", "status": "waiting_on_you",
"nameservers": ["ada.ns.cloudflare.com", "kai.ns.cloudflare.com"] },
{ "domain": "acme-team.com", "status": "connected", "nameservers": ["…", "…"] }
] }| connected | The domain points at our pair. Nothing to do. |
| waiting_on_ | Set the two nameservers at your registrar. It shows connected as soon as public DNS shows the change — usually minutes after you save it at the registrar, and we check again every two minutes. |
| no_ | No pair has been issued for this domain yet. POST /v1/portal/domains/nameservers with {"provider": "microsoft", "domains": ["acme-mail.com"]} issues one (provider is google for Google orders; up to 10 domains a call; needs orders:place). |
| unknown | We could not ask just now. Try again shortly. |
It covers the domains on record for the order and, for an order on your own domains, the ones its order form named — so it answers for an order placed a minute ago. A domain still waiting is checked again every two minutes; a connected one is settled. The same pair and status appear on each domain in GET /v1/portal/dashboard, as nameservers with status waiting_on_you.
The order form
{
"orderName": "acme-mail.com",
"placedAt": "2026-09-24 19:02:39+00:00",
"lineItems": [{ "priceId": "price_1S15oC…", "quantity": 2 }],
"stripe": { "plan": "Google Unwarmed", "product": "google", "interval": "month", "intervalCount": 1, "quantity": 2 },
"form": {
"sku": "price_1S15oC…",
"source": "api",
"domains": [{ "domain": "acme-mail.com", "forwarding_url": null,
"mailboxes": [{ "email": "jane@acme-mail.com", "first_name": "Jane", "last_name": "Doe", "is_admin": true }] }],
"mailboxes": 2,
"promotion_code": null,
"placed_by": "you@acme.com (api key: ordering)"
}
}An order placed before forms were recorded answers "form": null and still carries stripe: the plan, how often it bills, and how many units.
Cancellation state
{
"status": "active",
"periodEnd": "2026-10-24T19:02:39",
"endsAt": null,
"reason": null,
"quantity": 2,
"canCancel": true,
"canPartial": false,
"reasons": [{ "key": "unused", "label": "I wasn't using it" }, …]
}status is active, cancelling (set to end on endsAt), partial (some domains will drop at the period end), or ended.
Changing things
All need write. Changes take effect at once. The ones that touch an order (its name, forwarding, renames, requests) are also noted on that order's history, under the name of whoever made them.
Folders
Folders (called workspaces in the API) group your orders, for your own organising and for choosing a sequencer and monitors per group.
- POST/v1/portal/workspacesMake a folder.
{ "name": "Q4 outbound" }. Names are up to 80 characters and unique on the account, ignoring case; a duplicate is409. - PUT/v1/portal/workspaces/{workspace_id}Rename one.
{ "name": "…" } - DELETE/v1/portal/workspaces/{workspace_id}Remove one. Its domains are kept, with no folder, and the answer says how many were moved out.
- PUT/v1/portal/orders/{subscription_id}/workspaceFile a whole order into a folder:
{ "workspace_id": 12 }, ornullto take it out. Every domain on the order moves, and domains built for it later follow. - PUT/v1/portal/domains/{domain}/workspaceFile one domain:
{ "workspace_id": 12 }, ornull. Both the domain and the folder must be yours.
Order name
- PUT/v1/portal/orders/{subscription_id}/nameRename an order:
{ "name": "October outbound" }, up to 80 characters. Takes effect at once; it is what the portal, our team and your emails call the order.
Which sequencer, and the monitors
These record a choice; they do not move anything. Setting a sequencer tells our team which tool these inboxes should be connected to, and the move happens when someone does it. To push inboxes into your sequencer yourself, use Exporting.
- PUT/v1/portal/workspaces/{workspace_id}/sequencerThe sequencer for every domain in a folder:
{ "sequencer": "instantly" }. One ofinstantly,smartlead,plusvibe,emailbison, ornullto clear it. This is the one to use for new choices. - PUT/v1/portal/orders/{subscription_id}/sequencerThe same for a single order. Either can also name which saved login exports sign in with: add
"key_id"or"label". The answer is{"sequencer", "key_id", "label"}. - PUT/v1/portal/workspaces/{workspace_id}/monitorsSwitch the reconnection and deliverability watches for a folder:
{ "reconnection": true }. - PUT/v1/portal/orders/{subscription_id}/monitorsThe same for one order.
Send only the switch you are changing. A field you leave out keeps its value rather than being turned off. The answer is both switches as they now stand.
Forwarding
Point an order's domains at a website, so anyone who visits one lands on your real site, or stop them forwarding. Check first, then apply.
- POST/v1/portal/orders/{subscription_id}/forwarding/preflightWhat a change would do, domain by domain. Writes nothing.
- POST/v1/portal/orders/{subscription_id}/forwardingApply it.
Both take the same body:
{ "target": "acme.com/demo", "domains": ["acme-mail.com"] } // point these
{ "remove": true, "domains": ["acme-mail.com"] } // stop forwarding- Leave
domainsout to change every domain on the order. - A bare domain gets
https://added, and a path is kept, soacme.com/demobecomeshttps://acme.com/demo. - You cannot forward to one of the order's own domains, which would send visitors round in a loop. Naming a domain that is not on the order is
422, and an order with nothing built yet is409.
The preflight answers per domain, so you can show exactly what will happen before committing:
{
"target": "https://acme.com/demo",
"problems": [],
"domains": [
{ "domain": "acme-mail.com", "state": "ok", "said": "Starts forwarding." },
{ "domain": "acme-team.com", "state": "same", "said": "Already forwards there." }
],
"applies": ["acme-mail.com"]
}state is ok (it will change), same (nothing to do), warn (it will change, with something worth knowing in said), or fail (it cannot, and said says why, e.g. the domain's nameservers do not point at us yet). applies lists what applying would change.
Renaming inboxes
- POST/v1/portal/orders/{subscription_id}/accounts/renameChange the display name and/or the address of inboxes on the order.
{ "changes": [
{ "email": "jane@acme-mail.com", "firstName": "Janet" },
{ "email": "john@acme-mail.com", "username": "jroe" }
] }Name each inbox by its current email (see GET …/accounts), and send only what changes: firstName, lastName, or username, the new part before the @ (letters, numbers, dots, dashes, underscores). Every change is checked before any is made. Up to 20 at a time.
Most changes apply at once; any that cannot become a request our team completes. The answer counts both: { "applied": 1, "requested": 1, "failed": [] }. failed lists any that were refused, with the reason.
Asking us for a change
For anything that needs a person, raise a request. It reaches our team, appears in GET /v1/portal/requests, and the account is emailed an acknowledgement when it is raised and again when it is done.
- POST/v1/portal/requestsAsk for a change to an order.
{ "subscription_id": "sub_…", "kind": "rename", "summary": "…" } - POST/v1/portal/orders/{subscription_id}/updatesAsk a technician to rename inboxes or swap domains.
{ "kind": "inbox_rename", "details": "…" }
| kind | What it does |
|---|---|
| rename | Asks for a rename. Say in summary exactly what should be renamed. (To rename the order yourself, at once, use PUT …/name; for inboxes, …/accounts/rename.) |
| domain_swap | Asks to swap domains on the order. |
| cancel | Asks a person to cancel the order. A key cannot cancel directly; this is how a program asks. |
| forwarding | Applied straight away, not queued: send forwardTo with the URL and every built domain on the order is repointed. The answer's applied_to counts them. The …/forwarding route above gives you more control. |
summary (or details) is required, up to 2,000 characters, and is shown to our team in your words. On …/updates, kind is inbox_rename or domain_swap; a domain swap on a prewarmed order is refused with 409, because swapping would lose the warm-up those domains carry.
Exporting to a sequencer
Push a built order's inboxes into your own Instantly, Smartlead, PlusVibe or Email Bison, so you can start sending without anyone connecting them by hand. You save your sequencer login once; every export signs in with it.
The flow
- Save the login for your sequencer (once):
PUT /v1/portal/sequencer-keys. - Start the export for an order:
POST /v1/portal/orders/{id}/export. It returns at once. - Follow it:
GET /v1/portal/orders/{id}/exports, until the status iscompletedorfailed.
- GET/v1/portal/sequencer-keysThe logins and keys you have saved. Never the secrets: each row says which platform, its label, and whether a key and a login are stored. Needs
read. - PUT/v1/portal/sequencer-keysSave one, or replace the one already saved for that platform and label. A login is saved whole:
api_key,login.emailandlogin.password— one missing is refused with422. Needswrite. - PATCH/v1/portal/sequencer-keys/{key_id}Switch a saved login off or on without losing it:
{ "active": false }. Needswrite. - DELETE/v1/portal/sequencer-keys/{key_id}Remove one. Needs
write. - POST/v1/portal/sequencer-checkAsk whether a key or login works before saving it. Saves nothing. Needs
write. - POST/v1/portal/orders/{subscription_id}/exportStart an export. Needs
write. - GET/v1/portal/orders/{subscription_id}/exportsEvery export of that order, newest first, and how each went. Needs
read.
1. Save a login
{
"platform": "instantly",
"api_key": "…",
"login": { "email": "you@acme.com", "password": "…" }
}Send an API key, a login, or both: what each sequencer needs is in the table below. label (default "Default") lets you keep more than one login for the same platform — one per client, say; see Which login an export uses. Saving again under the same platform and label replaces the stored key and login; send both again if you want both kept. A workspace name an export remembered is kept across a re-save.
For Email Bison, which runs on your own address, add base_url, e.g. "https://yourteam.emailbison.com". It must be a public https address.
| Sequencer | Needs saved | Needs a workspace name |
|---|---|---|
| instantly | Login and API key | Yes |
| smartlead | Login | No |
| plusvibe | Login | Yes |
| emailbison | API key and base_url | Yes |
To check credentials first, send the same body to POST /v1/portal/sequencer-check. It answers {"key": …, "login": …}, each "ok" (accepted), "rejected" (the sequencer refused it), "unknown" (it could not be told, e.g. the sequencer was slow), or null (that kind cannot be checked for this platform). Logins are only checked for PlusVibe; for the others a test sign-in from a new place can upset the account, so it is not tried.
2. Start the export
$ curl https://api.warminboxes.com/v1/portal/orders/sub_1Q8xYz…/export \
-H "Authorization: Bearer $WIB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"sequencer": "instantly", "workspace": "Main"}'{ "ok": true, "inboxes": 10, "sequencer": "instantly", "exportId": "…", "id": 42,
"login": { "id": 7, "label": "Client A" } }workspace is the workspace inside your sequencer. Send it with the first export; it is remembered with your login, and later exports use it unless you send another. Your password never goes in this body: the export signs in with what you saved, and if anything is missing the 422 names the part.
Starting an export does not connect the inboxes there and then; it queues the work and returns. id identifies this export in the list, and login says which saved login it signed in with (null for one typed on the order form).
Which login an export uses
You can keep several logins for one sequencer — a reseller often has one per client — and each export must sign in to the right one. The first of these that answers decides:
- The login the export names:
"key_id": 7(theidfromGET /v1/portal/sequencer-keys) or"label": "Client A". - The order's login, remembered with
PUT /v1/portal/orders/{subscription_id}/sequencerand{"sequencer": "instantly", "label": "Client A"}. - The folder's login, for an order filed in a folder: one remembered the same way with
PUT /v1/portal/workspaces/{workspace_id}/sequencer, or the folder's own login saved in the portal (labelworkspace:<folder id>). - The login the order form chose at checkout.
- Your account's login: the one labelled
Default, or your only one. A folder's own login is never used for another folder's orders.
If you keep several and none of these chooses, the export is refused with 422 naming them — This account has 2 Instantly logins saved (Client A, Client B) and nothing says which this order uses. — rather than guessing. A login that is another account's (404), for another sequencer, or switched off (422) is refused too. So the simplest set-up for a reseller: save a login per client with its own label, and remember it on each client's folder or order once; every export after that goes to the right place by itself.
3. Follow it
{ "exports": [{
"id": 42,
"sequencer": "instantly",
"workspace": "Main",
"inboxes": 10,
"processed": 6,
"status": "processing",
"failed": null,
"startedBy": "you@acme.com (api key: ops)",
"startedAt": "2026-10-07T10:02:11",
"checkedAt": "2026-10-07T10:04:40"
}] }| Field | Meaning |
|---|---|
| status | starting or started at first, then the progress of the connecting work, such as processing, ending in completed, failed or cancelled. |
| inboxes | How many inboxes were handed over. |
| processed | How many have been connected so far. null until the first progress report. |
| failed | Why it did not start or did not finish, in words you can show, or null. |
| startedBy | You (and which key), or "WarmInboxes" for an export our team ran. |
| checkedAt | When the status was last brought up to date. |
Polling is fine. An unfinished export's status is refreshed when it is more than 30 seconds old, so asking every 30 to 60 seconds gets a fresh answer each time. A finished one never changes again. Exports our team ran on the same order are listed too, so the list is the whole history.
When an export refuses
422means a login, key or workspace is missing; the sentence names which. Save it, then try again.409means the order has no built inboxes yet, or its inboxes were not built where exports run; the sentence says which. For the second, ask us and we connect them.502means the sequencer refused the sign-in or the workspace, or could not be reached; the sentence carries its reason. The attempt is recorded in the export list with that reason.- An inbox can be exported to the same sequencer once a day, so retry a failed export after fixing its cause, not in a loop.
Ordering
Order more inboxes on a plan you are already on — Google, Microsoft 365, Azure tenants or prewarmed — at the price you already pay, charged to the card on file. Preview first, then place: a preview runs exactly the checks placing runs, so one that passes will be accepted.
- GET/v1/catalogueWhat you can order and at what price: the plans you are already on. Needs
read. - POST/v1/orders/previewWhat an order would build and cost. Creates nothing and charges nothing. Needs
read. - POST/v1/ordersPlace it and charge the card on file. Needs
orders:placeand anIdempotency-Keyheader. - GET/v1/payment-methodWhether there is a card to charge, as brand and last four digits. Needs
read. - POST/v1/payment-method/setup-linkA Stripe page where a person saves a card. Needs
orders:place.
The catalogue
The catalogue is not a price list. It is your plans: each subscription you have, at the price you pay for it. That is what makes it safe for a program to order from — it can only buy more of what you already buy, at your price.
{ "catalogue": [{
"sku": "price_1S15oC…",
"product": "Google Workspace Reseller Admin - Monthly (No MOQ)",
"offering": "Google Unwarmed",
"unit_amount": 3.5,
"currency": "USD",
"interval": "month",
"mailboxes_per_unit": 1,
"orders_by": "domains",
"orderable": true,
"why_not": null,
"on_subscriptions": ["sub_1Q8xYz…"]
}] }| Field | Meaning |
|---|---|
| sku | What you name in an order. |
| unit_amount | The price of one billed unit, per interval. |
| mailboxes_per_unit | How many inboxes one unit buys. An order's quantity is the number of inboxes divided by this, rounded up. null means we have not recorded it for this plan yet: a preview still answers, but placing is refused until we set it, so ask us. |
| orders_by | Which shape an order for this plan takes: domains (Google and Microsoft 365: you name the domains and the people on them), tenants (Azure: a tenant per domain; see Azure and Microsoft 365) or quantity (prewarmed: you say how many; see Prewarmed orders). For Azure, mailboxes_per_unit is null: each order chooses 25, 50 or 99 inboxes a tenant. |
| orderable | Whether this plan can be ordered from code. When false, why_not says why. |
| on_subscriptions | Your existing orders on this plan. |
Prewarmed plans are ordered by quantity. Prewarmed inboxes are already built and warmed on our own domains, so an order for them says how many domains you want, not which: see Prewarmed orders.
A plan you are not on yet is not in your catalogue. Buy it the first time through a checkout; once it is paid, it appears here and further orders can use /v1/orders.
Send what to build, not what to charge
An order names a sku and describes the inboxes. It never names a price: the price is always the one on your plan. A body containing price, price_id, amount, unit_amount or total is refused rather than ignored, so nobody believes a number they sent was used.
{
"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" }
]
}
]
}| Field | Required | Meaning |
|---|---|---|
| sku | Yes | A sku from your catalogue whose orderable is true. |
| domains | Yes | At least one. Up to 500 domains and 2,000 inboxes in one order. |
| domains[].domain | Yes | A domain you own, such as acme-mail.com. Each may appear once, and not if it is already on another account. |
| domains[].mailboxes | Yes | The people to make inboxes for. Each needs first_name, last_name and local_part, the part before the @. The first inbox on a domain is its admin. |
| domains[].forward_to | No | A website the domain should forward visitors to. |
| promotion_code | No | A code to apply. See Promotion codes. |
Every order is checked by the same rules as an order file uploaded in the portal: a missing name, an address that cannot be used, something that is not a domain, a domain listed twice. Every problem is reported at once, by domain, so you can fix them in one pass.
Previewing
{
"ok": true,
"sku": "price_1S15oC…",
"product": "Google Workspace Reseller Admin - Monthly (No MOQ)",
"offering": "Google Unwarmed",
"domains": 1,
"mailboxes": 2,
"quantity": 2,
"price": {
"unit_amount": 3.5, "currency": "USD", "interval": "month",
"mailboxes_per_unit": 1, "total": 7.0, "first_invoice": 6.3
},
"discount": {
"code": "SPRING10", "percent_off": 10.0, "amount_off": null,
"applies_to": "the first invoice only — renewals are charged at full price"
},
"problems": [],
"note": null
}total is the recurring price per interval; first_invoice is what the first charge comes to after any discount. When something is wrong the answer is 400, with detail summing it up and problems listing each issue by domain:
{
"detail": "Nothing in that order can be built. domains[0] (acme-mail.com): acme-mail.com has no usable users.",
"problems": ["domains[0] (acme-mail.com): acme-mail.com has no usable users."]
}Prewarmed orders
For a plan with "orders_by": "quantity", send a count instead of domains. We assign that many prewarmed domains from our stock and set them up; the order lands with our team as new_order and moves on like any other, and its domains appear in GET /v1/portal/dashboard once they are assigned. Preview and place it exactly as above.
{
"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. How many 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: where to connect the inboxes. We use the login you saved with PUT /v1/portal/sequencer-keys; the saved login must hold the API key, the login email and the password, or the order is refused with 422 saying which is missing (leave sequencer out to skip it). Never send a password in an order. |
| forward_to | Optional. A website the domains redirect visitors to. |
| workspace_id | Optional. One of your folders to file the order in (another account's is 404). |
| name | Optional. What to call the order, up to 80 characters. Defaults to the plan and the count. |
| notes | Optional. Anything our team should know, up to 2,000 characters. |
| promotion_code | Optional, as for any order. |
A prewarmed order that names domains is refused with 400, since the domains are ours to choose. In the answers, domains is the number of domains ordered and mailboxes the inboxes they carry.
Azure and Microsoft 365
An Azure order ("orders_by": "tenants") is one tenant per domain, billed per tenant, each with the same number of inboxes and the same 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_ | 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. |
A Microsoft 365 order ("orders_by": "domains") is sent exactly like a Google one — domains with the mailboxes on each — and billed per inbox.
Both take the same domain choice, and the optional sequencer, forward_to, workspace_id, name and notes a prewarmed order takes:
| buy_domains | true: we register the domains you name (.com, .co or .site) and the registration is a one-off on the first invoice — price.registration in the preview, and in first_invoice, never in total. false (default): they are yours. |
| domain_access | For your own domains, how we get at their DNS. {"method": "nameservers"} (default; no secret changes hands): each domain must already point at the pair we issued for it — get them with POST /v1/portal/domains/nameservers, set them at your registrar, and order once they show. {"method": "grant", "registrar": "godaddy"}: invite setup@warminboxes.com at your registrar with DNS permissions. {"method": "credentials", "registrar": "godaddy", "username": "…", "password": "…"}: share the registrar login (add account_username for a registrar that also signs in with a username, such as Dynadot or Spaceship). registrar is godaddy, porkbun, spaceship, dynadot or other (then name it in other_registrar). A registrar password is kept on the order for our team and never returned by the API. |
Checked before anything is charged
- A domain to buy must not already be registered.
- A domain of your own must be registered, must not already be in another Microsoft tenant (a tenant cannot be made for a domain another one holds), and with
nameserversmust already point at our pair. - The registrar fields each method needs, the usernames, and the domain list, as the portal's order form checks them.
A domain that fails is left out and its reason is listed in problems; if none passes, the order is refused with 400. A check that could not get an answer (a registry or DNS not replying, or a TLD with no public lookup such as .co) never refuses: the preview's note says what could not be checked, and our team checks before building. On the nameservers method, placing answers with nameservers: the pair each domain points at.
POST /v1/portal/domains/nameservers — {"provider": "microsoft", "domains": ["acme-mail.com"]} (up to 10 a call; provider is microsoft for Azure and Microsoft 365, google for Google) answers {"domains": {"acme-mail.com": {"nameservers": ["…", "…"]}}}, or an error for a domain Cloudflare will not host. It reserves the pair on our Cloudflare, so it needs orders:place.
Placing
Placing creates the subscription and charges the card on file immediately, with nobody checking first. The key needs orders:place.
{
"ok": true,
"order_id": "API-69c62c42f6ed010a…",
"subscription_id": "sub_1Q9AbC…",
"sku": "price_1S15oC…",
"domains": 1, "mailboxes": 2, "quantity": 2,
"charged": { "amount": 7.0, "currency": "USD", "interval": "month", "first_invoice": 6.3 },
"discount": { …as in the preview… },
"replayed": false,
"problems": [],
"status": "placed — the build is queued and someone checks it before anything is created."
}The new order appears in GET /v1/portal/orders straight away with the status new_order, named after its first domain. Its subscription_id is the id every per-order route takes.
A placement can be refused even after a passing preview:
| Status | When |
|---|---|
| 402 | Stripe did not take the payment: the card was declined, or the bank wanted the cardholder to confirm. Nothing was charged and no order exists. |
| 409 | There is no card on file; or the price changed in the moment between your preview and now; or the plan has no recorded inboxes-per-unit. Nothing was charged. |
| 429 | Your account has placed 12 orders in the last hour. See Rate limits. |
| 502 | Stripe could not be reached. Nothing was charged; retry with the same Idempotency-Key. |
After placing, a person on our team checks the order before anything is built, and its status moves through the build stages as they go. There is nothing for your program to do in between.
Retry with the same Idempotency-Key
Every placement must carry an Idempotency-Key header: 8 to 64 characters of letters, digits, dot, dash, colon or underscore. It makes a retry safe.
- If a request times out or the connection drops, you cannot know whether the order was placed. Send the same request with the same key. If the first one went through, you get that order back, with
"replayed": trueand no second charge. - Use a new key for a genuinely new order. A good key comes from your own system, e.g.
campaign-481-batch-2, so a crash and restart sends the same one. - Keys are per account, so your keys never collide with anyone else's.
Promotion codes
Add "promotion_code" to the order: the same codes that work on the portal's checkout page. The preview shows exactly what it takes off and for how long, in discount.applies_to:
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 3 months — full price after thatevery invoice, for as long as the subscription runs
A code is refused with 400 rather than silently dropped if it is unknown or inactive, has expired, is for a customer's first order and this is not yours, has a minimum order amount the order does not reach, or no longer takes anything off. A quote that quietly dropped your code would be a quote for a different order.
The card
Orders are charged to your account's default card, the one the portal shows. GET /v1/payment-method says whether there is one:
{ "saved": true, "brand": "visa", "last4": "4242", "exp_month": 8, "exp_year": 2027 }If there is none, a program cannot add one. A card is entered by a person, on Stripe's own page. Ask for a link and hand it to them:
POST /v1/payment-method/setup-link
{ "success_url": "https://app.acme.com/billing?saved=1", "cancel_url": "https://app.acme.com/billing" }
{ "url": "https://checkout.stripe.com/c/pay/cs_live_…" }Opening it charges nothing. Both return addresses must be https (or http://localhost while developing).
Buying a new plan
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. It is the same path the portal's shop takes.
| To… | Use |
|---|---|
| Re-order a plan you are on | POST /v1/orders. Charges the card on file straight away; no person needed. |
| Buy anything else | A checkout. A person pays on Stripe's page; afterwards the plan is in your catalogue, so later orders can use /v1/orders. |
- POST/v1/portal/checkoutSave an order and open a Stripe page that pays for it. Needs
orders:place. - GET/v1/portal/checkout/{session_id}After the person has paid: the order it created, and what was charged. Needs
read.
Opening a checkout
{
"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"
}{ "url": "https://checkout.stripe.com/c/pay/cs_live_…", "session_id": "cs_live_…" }| Field | Meaning |
|---|---|
| mode | Always "subscription". A one-off payment is refused: nothing would confirm the order once paid. |
| order.order_name | Required. What the order is called everywhere afterwards. |
| order.line_items | Required. One or more { "priceId", "quantity" }, each price from the shop's list (or a reseller's own price), each quantity a whole number of at least 1. A domain-registration line is a one-off charge and goes on the same checkout as a plan, not on its own. |
| order.metadata | What to build. See Build details. |
| success_url | Where Stripe sends the person after paying. Put {CHECKOUT_SESSION_ID} in it and Stripe fills in the session id, which is what GET …/checkout/{session_id} takes. |
| cancel_url | Where Stripe sends them if they back out. Both must be https (or http://localhost while developing). |
| metadata | Optional. workspace_id files the order into one of your folders (another account's folder is 404); referral_code credits a referral partner. Nothing else is read here. |
Hand the url to the person who will pay. Stripe's page offers the cards the account has paid with before, and accepts promotion codes typed there. A code is not sent in the body.
What can be bought
A line must name a price the shop sells. Anything else, including prices the shop used to sell, is refused with 422, "That price is not available on this account.", before anything is created.
| Price id | Plan | Price, and what quantity counts |
|---|---|---|
| price_1S15oCGCV13faDNPA8npFlww | Google Workspace, monthly | $3.50 / month · inboxes, any number |
| price_1SgnchGCV13faDNPDAirnw62 | Google Workspace, monthly, 30–99 | $3.25 / month · inboxes, 30 to 99 |
| price_1SmfbnGCV13faDNPZ2QZjQXg | Google Workspace, monthly, 100+ | $3.00 / month · inboxes, 100 or more |
| price_1SmfSHGCV13faDNPRbC8FzHJ | Google Workspace, yearly | $36 / year · inboxes, any number |
| price_1SmfXrGCV13faDNPV9tSPiPD | Google Workspace, yearly, 30–99 | $30 / year · inboxes, 30 to 99 |
| price_1SmfYmGCV13faDNP2MBMfd1g | Google Workspace, yearly, 100+ | $24 / year · inboxes, 100 or more |
| price_1Q3nAdGCV13faDNPkM246uvk | Microsoft 365, monthly | $3 / month · inboxes |
| price_1SgnzNGCV13faDNPxAFAa1Is | Microsoft 365, yearly | $24 / year · inboxes |
| price_1T3MWfGCV13faDNP4yMIvNuO | Azure tenant (25–100 inboxes) | $50 / month · tenants (one domain each), 1 to 99 |
| price_1SgoIlGCV13faDNPtCZHkjN0 | Prewarmed Google, monthly | $24 / month · packs of 3 inboxes on one prewarmed domain |
| price_1UEroDGCV13faDNP5fyIVFT9 | Prewarmed Google, every 2 months | $42 / 2 months · packs of 3 |
| price_1UErpEGCV13faDNPibm3US0S | Prewarmed Google, quarterly | $54 / 3 months · packs of 3 |
| price_1SgoBVGCV13faDNPUtNoNHL2 | Prewarmed Microsoft, monthly | $24 / month · packs of 3 |
| price_1UErqqGCV13faDNProTHieZA | Prewarmed Microsoft, every 2 months | $42 / 2 months · packs of 3 |
| price_1UErsVGCV13faDNPHkLjN3EQ | Prewarmed Microsoft, quarterly | $54 / 3 months · packs of 3 |
| price_1T3MUHGCV13faDNPzWN3CSIL | Prewarmed Azure tenant, monthly | $69 / month · tenants, each a prewarmed domain |
| price_1UEruhGCV13faDNPikX7kfpd | Prewarmed Azure tenant, every 2 months | $120 / 2 months · tenants |
| price_1T9UmqGCV13faDNPau6lpxIX | Prewarmed Azure tenant, quarterly | $150 / 3 months · tenants |
| price_1Roqh9GCV13faDNPS7OFt7eu | .com domain registration | $12, once · domains |
| price_1RoqhVGCV13faDNP1lc9pfqY | .co domain registration | $10, once · domains |
| price_1Roqi1GCV13faDNPKR3dwYaX | .site domain registration | $3, once · domains |
The ranges on the tiered Google prices are what each price is for: order 30 to 99 inboxes on the 30–99 price, and so on. Stripe charges exactly the listed price times the quantity, and the order records that total.
Resellers may also name their own negotiated prices. Each is accepted only on the reseller's own account, inside its dates, at or above its minimum quantity, on the cheapest tier the quantity qualifies for, and when the order reaches the reseller's minimum spend. Otherwise it is refused with a sentence saying which rule, or, for a price that is not yours, the same "not available" sentence as above.
Build details
order.metadata tells our team what to build, the way the portal's order form does. It is stored with the order, shown to the team, and returned by GET /v1/portal/orders/{id}/details. These are the fields the build reads:
| Field | Meaning |
|---|---|
| product_type | "google", "microsoft" or "azure". |
| total_inboxes | How many inboxes in all. |
| number_of_domains | How many domains they go on. |
| domain_option | "existing" if you are bringing your own domains, "new" if we should buy them. |
| existing_domains | Your domains, one per line or comma-separated. With "existing". |
| domain_ideas | Domains you would like us to buy, one per line or comma-separated. With "new", add a domain-registration line for them. These are suggestions: our team confirms what is available. |
| email_accounts | The inboxes: [{ "name": "jane", "firstName": "Jane", "lastName": "Doe" }], where name is the part before the @. |
| sending_platforms | The sequencer to connect them to: ["instantly"], or { "instantly": true }. One of instantly, smartlead, plusvibe, emailbison. |
| forwarding_address | A website the domains should forward visitors to. |
| azure_inboxes_per_domain | For Azure: how many inboxes on each tenant. |
| azure_usernames | For Azure: the usernames, as a list. |
Anything else you include is kept with the order and shown to our team. Use it for instructions in your own words, e.g. a special_instructions field.
Do not put passwords in an order. To have the inboxes connected to your sequencer, save its login once with PUT /v1/portal/sequencer-keys. The build uses the saved login for the sequencer named in sending_platforms.
After the person pays
Stripe sends them to your success_url. The order is confirmed within moments and appears in GET /v1/portal/orders as new_order, and a person on our team checks it before anything is built. To find the order from the session id:
GET /v1/portal/checkout/{session_id} // the cs_live_… id from your success_url
{ "subscription": "sub_1Q9AbC…", "paid": true, "amount": 70.0, "currency": "usd" }amount is what was charged, after any promotion code. Another account's session, or a made-up one, is 404.
If nobody pays
- The payment page stays open for 31 minutes. After that, open a new checkout.
- When it expires unpaid, the account's email address gets a reminder that the order was not finished, at most one a day. Bear this in mind when testing.
- The unpaid order is listed in
GET /v1/portal/placed-ordersaspending, and asexpiredafter a day. Nothing is charged.
Refusals
| Status | When |
|---|---|
| 403 | The key lacks orders:place. |
| 404 | The workspace_id is not one of your folders. |
| 422 | A price the shop does not sell; a reseller price that does not apply; mode is not "subscription"; no order_name; no line items or a quantity under 1; a return address that is not https. |
| 429 | The account has placed 12 orders or checkouts in the last hour. |
| 502 | Stripe could not be reached. Nothing was charged; the sentence says what Stripe said. |
Errors
Every error body has a sentence in detail, written to be shown to a person as it is: it says what happened and, where there is one, what to do. Ordering refusals also list each issue in problems.
{ "detail": "This API key does not carry the 'orders:place' scope." }| Status | Meaning |
|---|---|
| 400 | The request was refused as sent. detail says what to change. |
| 401 | No key was sent, or it is not recognised, or it has been revoked. The sentence is the same for all three, on purpose. |
| 402 | Stripe did not take a payment. Nothing was charged. |
| 403 | The key lacks the scope this needs, or this is something only a signed-in person may do (see What a key cannot do). |
| 404 | No such record on your account. A record that belongs to someone else gets the same answer. |
| 409 | It cannot go ahead in the state things are in: no card on file, the price changed, nothing built yet, or a name already taken. Nothing was changed. |
| 413 | The body is over 4 MB. |
| 422 | A field is missing or in the wrong shape, e.g. a return URL, a sequencer login, or a workspace name. |
| 429 | Too many requests. Wait the number of seconds in Retry-After, then try again. |
| 500 | Something broke on our side. It has been logged; the sentence says so. |
| 502 | Something we depend on, such as Stripe or a sequencer, refused or could not be reached. The sentence carries its reason. |
| 503 | Sign-in checks are briefly unavailable. Retry. |
A status in the 400s will fail the same way if retried unchanged; fix what detail says first. 429, 502 and 503 are worth retrying after a pause, and for placing orders always with the same Idempotency-Key.
Rate limits
Limits stop a runaway loop, not normal use. Each key has its own allowance, so one integration's mistake does not slow another down.
| Limit | Applies to |
|---|---|
| 240 / minute | Reading: every GET, and every message an AI assistant sends to the MCP server. Per key. |
| 60 / minute | Changing: anything that is not a GET. Per key. |
| 12 / hour | Placing orders and opening checkouts, together. Per account, shared by the API, MCP and CLI, and counted whether or not the card was accepted. |
| 900 / minute | Everything from one network address, whatever key it carries. |
JSON answers carry X-RateLimit-Limit and X-RateLimit-Remaining, so you can slow down before being refused. A refusal is 429 with Retry-After in seconds. Wait that long rather than retrying at once, which only spends the next allowance.
Building something that needs more? Tell us what it does and we will raise your limits.
Recipes
Report spend and deliverability every morning
A read-only key is enough, and is the right key for anything that only looks.
$ curl -s https://api.warminboxes.com/v1/portal/billing \
-H "Authorization: Bearer $WIB_API_KEY" | jq '{next30Days, pastDue}'
$ curl -s "https://api.warminboxes.com/v1/portal/analytics?period=7d" \
-H "Authorization: Bearer $WIB_API_KEY" \
| jq 'if .available then .totals else "not measured yet" end'Order more inboxes when a campaign grows
- Read
/v1/catalogueand pick the orderableskuyou use. - Build the order from your own data and
POST /v1/orders/preview. Show a person thepriceif anyone should approve the spend. - Place it with an
Idempotency-Keymade from your own job id, so a retry after a crash cannot double-order. - Store the returned
subscription_id; poll/v1/portal/ordersto follow itsstatus.
Export a finished order and wait for it
$ ./wib export sub_1Q8xYz… --sequencer instantly --workspace Main
$ while sleep 45; do
s=$(./wib exports sub_1Q8xYz… | jq -r '.exports[0].status')
echo "$s"; [ "$s" = completed ] || [ "$s" = failed ] && break
doneLet an assistant quote, but not order
Give it a key with read only. It can then see your account, check the catalogue and price any order with preview_order, and it is not even shown the tool that places orders. When the customer decides, a person (or a key with orders:place) places it.
Notice a failed payment
An order whose last charge failed reads "status": "payment_failed" in /v1/portal/orders, and /v1/portal/billing counts it in pastDue. A daily check on either is enough to catch it before inboxes are affected.
What a key cannot do
Some things stay with a person signed into the portal, whatever scopes a key carries. They are the actions whose damage a leaked or misbehaving key could not undo. The first five are refused with 403 and a sentence saying so.
| Action | Why, and what to do instead |
|---|---|
| Manage API keys | A key that could mint keys could widen its own powers, and revoking it would mean nothing. Keys are made and revoked by the account's owner in the portal. |
| Cancel an order | Cancelling, or undoing a cancellation, is done in the portal by a person. A program can ask for one: POST /v1/portal/requests with "kind": "cancel". It can read the state with GET …/cancellation. |
| Change the team | Inviting, removing or changing the role of a colleague is the owner's alone. Keys can read the team. |
| Referral payouts | Taking a referral code and setting where its commission is paid are the owner's alone, so nobody else can redirect the money. Keys can read the referral page. |
| Change the account's email | That is the sign-in address, changed in the portal's settings. |
| Reach another account | A key belongs to one account. The X-Account header that lets a person switch accounts is ignored for keys. |
MCP server
An assistant that supports the Model Context Protocol can read your account, export orders and, if its key allows, place them. The server is stateless Streamable HTTP at one URL, with your key as a bearer token. It calls the same code as the REST API, so the answers and the rules are identical.
Connect it
Claude Code:
claude mcp add --transport http warminboxes https://api.warminboxes.com/mcp \
--header "Authorization: Bearer wib_your_key_here"Clients configured with JSON, such as Claude Desktop and Cursor:
{
"mcpServers": {
"warminboxes": {
"url": "https://api.warminboxes.com/mcp",
"headers": { "Authorization": "Bearer wib_your_key_here" }
}
}
}Tools
An assistant is shown only the tools its key's scopes allow; a tool it cannot use is not listed at all, so it does not keep trying and failing.
| Tool | Scope | What it does |
|---|---|---|
| get_ | read | Your orders and their domains, with build state. |
| list_ | read | The flat list of orders. |
| get_ | read | Invoices, spend, and what is due next. |
| get_ | read | How your domains are sending. Argument: period (7d, 30d, 90d). |
| list_ | read | What you can order, and at what price. |
| check_ | read | Whether there is a card to charge. |
| preview_ | read | Check and price an order. Creates nothing. Takes the same order as POST /v1/orders. |
| list_ | read | Which sequencer logins are saved. Never the secrets. |
| list_ | read | How each export of an order has gone. Argument: subscription_id. |
| export_ | write | Push an order's inboxes into your sequencer. Arguments: subscription_id, sequencer, optional workspace, and key_id or label to name the saved login. |
| get_ | read | The nameservers each of an order's domains should point at, and whether it does yet. Argument: subscription_id. |
| create_ | orders:place | A Stripe page for a person to save a card. Arguments: success_url, cancel_url. |
| place_ | orders:place | Place an order and charge the card. The order plus idempotency_key. |
How it behaves
- Protocol versions
2025-11-25,2025-06-18and2025-03-26. Stateless: no session, no event stream. Every request carries the key and is answered on the spot. - One JSON-RPC message per request; batches are refused.
- When a tool is refused — a declined card, a missing login, a sku you are not on — the answer is a normal result marked
isError, carrying the same sentence the REST API gives. The assistant reads it and tells you, instead of treating it as a connection failure and retrying. - Every message counts against the reading limit, and orders against the account's 12 an hour.
- There is no checkout tool: a checkout ends on a payment page a person must open, which an assistant cannot do for them. It can still create one through the REST API if you build that in.
An assistant can order only plans you are already on, at your price, but it can still get the quantity wrong. To let it quote without ordering, give it a key without orders:place.
There is no tool for saving a sequencer login, on purpose: it would put your sequencer password into the assistant's conversation. Save logins in the portal or through the API; the assistant then exports with them. If one is missing, the export's answer says so and the assistant can tell you where to add it.
Command line
wib is one Python 3 file with nothing to install, for scripts and for agents that have a shell rather than an MCP connector. It comes from the API itself, with the address already in it, so the copy you download always matches the API serving it.
$ 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| Command | What it does |
|---|---|
| catalogue | What you can order, and at what price. |
| preview <spec> | Check and price an order. Creates nothing. |
| order <spec> | Place it and charge the card. Needs --idempotency-key and --yes. |
| payment-method | Whether there is a card to charge. |
| setup-link | A Stripe page to save a card. --success-url, --cancel-url. |
| dashboard, orders, billing | The reading screens, as JSON. |
| deliverability | How your domains are sending. --period 7d, 30d or 90d. |
| requests, workspaces | Your change requests and folders. |
| export <order> | Push an order's inboxes into your sequencer. --sequencer instantly, optional --workspace Main, and --label "Client A" or --key-id 7 to name the saved login. |
| nameservers <order> | The nameservers each of the order's domains should point at, and whether it does yet. |
| exports <order> | How each export of an order has gone. |
| sequencer-keys | Which sequencer logins are saved. |
| sequencer-key-save <spec> | Save a login or key. Give a file, or - for stdin, so the password stays out of your shell history. |
| get, post <path> | Any other route, for what has no command, e.g. ./wib get /v1/portal/team, or ./wib post /v1/portal/checkout checkout.json to open a checkout. |
- Specs (the order, a sequencer login, a
postbody) can be a file,-for stdin, or the JSON itself in quotes. - Output: every command prints JSON and exits
0. A refusal prints the reason on stderr and exits1, sowib … || handleworks. A mistake in the command itself exits2. - Ordering needs
--yeson top of the key's scope, so an agent that meant to preview and typedorderstops at a missing flag, not at a charge. - Settings:
--keyand--apioverrideWIB_API_KEYandWIB_API_URL. It refuses to send your key to an address that is nothttps(other thanlocalhost), so a typo cannot put the key on the network in the clear. - When limited, it prints how many seconds to wait.
Questions
Is there a test mode?
Yes. Make a test key and you are in the sandbox, a pretend copy of your account where nothing is charged or built. Live keys act on your real account. Even there, POST /v1/orders/preview runs every check placing runs, without creating or charging anything.
My request to place an order timed out. Was it placed?
Send it again with the same Idempotency-Key. If it was placed, you get that order back with "replayed": true and no second charge; if not, it is placed now.
Why is my plan not orderable?
Either it is not a kind of plan this API can take an order for (a domain registration on its own), or we have not recorded how many inboxes one unit buys. why_not says which. For the second, ask us and we set it. Google, Microsoft 365, Azure and prewarmed plans are all orderable.
How do I buy a plan I am not on?
Open a checkout for it and have a person pay on the Stripe page. Once paid, it appears in your catalogue and can be re-ordered with /v1/orders.
Will my team see orders placed through the API?
Yes. They are ordinary orders, in the same lists, and each one records which key placed it.
How long does a build take?
A person checks each order before it is built, and the time depends on the order. Follow status in /v1/portal/orders: it moves as the build does.
Are there webhooks?
Not yet. Poll the read routes. The rate limits allow a check every few seconds, far more often than anything changes.
Who do I talk to?
Raise a request with POST /v1/portal/requests, or message us from the portal.