Provisioning API · Internal testing only
Provision inboxes
The Provisioning API lets you build sending infrastructure programmatically: create a Workspace, connect a Domain, describe the people who will send with Personas, and create Inboxes — real Microsoft 365 mailboxes, then hand those inboxes to the sequencer you already send from.
This guide goes from zero to a domain with working inboxes. Every example comes in shell, JavaScript and Python — pick a tab and the whole page follows.
- Base URL
https://api.builders.ac/api/provisioning/v1alpha1- API Key
age1…The same credential used by Mailbox v1beta1 and Deliverability.
Before you start
Section titled “Before you start”| A Builders account | Sign in and open the API credentials page |
The age CLI |
brew install age — opens sealed secrets and builds proofs |
| A domain you control | You must be able to change its nameservers at the registrar |
| Mailbox slots | Connecting a domain reserves 50; your plan must have them free |
| A runtime | curl + jq, Node 18+, or Python 3.9+ with requests |
1 · Get your credential
Section titled “1 · Get your credential”Your API Key and API Secret are generated in your browser. Nothing is installed and no command is run — the portal creates and registers the credential, then shows both values once.
- Open the API credentials page and sign in.
- Create a builders.ac API credential and give it a name. The same credential also works with Mailbox v1beta1 and Deliverability v1alpha1.
- Your browser generates the API Key and API Secret, opens a sealed registration challenge with the API Secret, and returns only the proof. The server stores a hash of the API Key, never either raw credential value.
- Click Download keys (.txt) and keep the file.
| Value | Looks like | What it is for |
|---|---|---|
| API Key | age1qz8x… |
The bearer token on every request. |
| API Secret | AGE-SECRET-KEY-1… |
Opens sealed values and proof challenges. It is never sent. |
The downloaded file is already an age identity
Section titled “The downloaded file is already an age identity”The downloaded .txt file is deliberately shaped like an age identity file, so
you can pass it straight to age -d -i wherever this guide calls for your secret:
# builders.ac API credential# name: my-integration# API Key: age1qz8x…# Keep this file private. The API Secret below is shown only once.AGE-SECRET-KEY-1…The # lines are comments that age ignores, so one file carries both values and works as
an identity.
2 · Authentication and headers
Section titled “2 · Authentication and headers”One header authenticates you
Section titled “One header authenticates you”The API Key is the bearer token. Nothing else authenticates a request, and there are no scopes.
Authorization: Bearer age1qz8x…The API Secret never travels. It is used locally, twice: to open values the API seals to your API Key, and to answer the challenge on destructive calls.
Headers you send
Section titled “Headers you send”| Header | When | Notes |
|---|---|---|
Authorization: Bearer <key> |
Always | Your age1… API Key |
Content-Type: application/json |
Any request with a body | |
Idempotency-Key: <uuid> |
Every POST |
Fresh UUID per logical request. PUT is exempt |
Sealed-Proof: <proof> |
DELETE and POST /inboxes/deprovision |
Only on the second attempt — see below |
Headers you get back
Section titled “Headers you get back”| Header | On | Meaning |
|---|---|---|
Location |
201, 202 |
Canonical URL of the created thing or Operation |
Idempotency-Replayed: true |
Replays | A stored replay, not new work |
Sealed-Nonce |
403 |
The challenge to open; single-use, request-bound |
WWW-Authenticate: Sealed error="insufficient_proof" |
403 |
Marks a proof challenge, not a permission failure |
Cache-Control: private, no-store |
Secrets, challenges | Never cache these |
x-correlation-id |
All | Quote it in support requests |
Base URL
Section titled “Base URL”All examples use the internal-testing Provisioning endpoint currently exposed at:
https://api.builders.ac/api/provisioning/v1alpha1
Set up a client
Section titled “Set up a client”Everything below builds on this. Keep the key in the environment, never in the source.
export BASE=https://api.builders.ac/api/provisioning/v1alpha1export KEY=age1qz8x... # API Keyexport SEC=./sending-ac-api-my-integration.txt # downloaded file for "my-integration"
# Reused by every example on this page
H=(-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json')Node 18+, no dependencies — fetch and crypto.randomUUID are built in.
const BASE = "https://api.builders.ac/api/provisioning/v1alpha1";const KEY = process.env.PROVISIONING_API_KEY;
/*_ Every example on this page calls through here. _/async function call(method, path, { body, idempotent = false, headers = {} } = {}) {const res = await fetch(BASE + path, {method,headers: {Authorization: `Bearer ${KEY}`,...(body ? { "Content-Type": "application/json" } : {}),...(idempotent ? { "Idempotency-Key": crypto.randomUUID() } : {}),...headers,},body: body ? JSON.stringify(body) : undefined,});
const data = res.status === 204 ? null : await res.json();if (!res.ok) {// Every error is an RFC 9457 problem. Branch on data.code, never the title.throw Object.assign(new Error(`${data.code}: ${data.title}`), { problem: data, res });}return { data, res };}pip install requests.
import osimport uuid
import requests
BASE = "https://api.builders.ac/api/provisioning/v1alpha1"KEY = os.environ["PROVISIONING_API_KEY"]
session = requests.Session()session.headers.update({"Authorization": f"Bearer {KEY}"})
def call(method, path, body=None, idempotent=False):"""Every example on this page calls through here."""headers = {"Idempotency-Key": str(uuid.uuid4())} if idempotent else {}r = session.request(method, BASE + path, json=body, headers=headers)
data = None if r.status_code == 204 else r.json() if not r.ok: # Every error is an RFC 9457 problem. Branch on data["code"]. raise RuntimeError(f"{data['code']}: {data['title']} ({data['correlation_id']})") return data, rCheck it works
Section titled “Check it works”curl -s "${H[@]}" "$BASE/me" | jqconst { data: me } = await call("GET", "/me");console.log(me.id, me.email);me, _ = call("GET", "/me")print(me["id"], me["email"]){ "id": "acc_01m1p076vgeb1b5r9tf5rxy9jp", "object": "account", "name": "Acme Integrations", "email": "ops@acme.com"}A 401 means the API Key is wrong or the credential was revoked. A 404 on every route
usually means the base URL is wrong.
3 · Conventions that apply everywhere
Section titled “3 · Conventions that apply everywhere”These rules apply throughout the API. Reading this section saves you most of the surprises.
Identifiers are TypeIDs
Section titled “Identifiers are TypeIDs”Every id is prefix_ plus 26 characters: acc_, wsp_, dom_, per_, inb_,
op_, or pra_ for a Provider Account. TypeIDs are case-insensitive on input and
always returned lowercase.
Long operations return 202 and an Operation
Section titled “Long operations return 202 and an Operation”Anything that touches real infrastructure is asynchronous. You get 202 with an Operation
and a Location header, and you poll it.
{ "id": "op_01m1z516pzeaxtf3f91fdk86z8", "type": "domain.connect", "state": "pending", "done": false, "target": { "type": "domain", "ids": ["dom_01m1tt7bmeeb1a334gr923hmsj"] }, "completion_by_date": "2026-09-11T00:00:06+00:00"}Poll GET /operations/{op} until
done is true, then read state (succeeded / failed / cancelled).
This is the part most integrations get wrong: treating the 202 as completion. Inbox
records exist immediately in pending or provisioning, but the mailboxes are not ready
to use until the Operation succeeds and they become active. Some operations chain — a
domain.connect spawns a domain.provision carrying parent_operation_id.
A helper you will reuse:
wait_for() { # wait_for op_01m1z5... while :; do op=$(curl -s "${H[@]}" "$BASE/operations/$1") [ "$(jq -r .done <<<"$op")" = "true" ] && { jq -r '.state' <<<"$op"; return; } sleep 15 done}async function waitFor(operationId, { intervalMs = 15_000 } = {}) { for (;;) { const { data: op } = await call("GET", `/operations/${operationId}`); if (op.done) return op; // read op.state: succeeded | failed | cancelled await new Promise((r) => setTimeout(r, intervalMs)); }}import time
def wait_for(operation_id, interval=15): while True: op, _ = call("GET", f"/operations/{operation_id}") if op["done"]: # read op["state"]: succeeded | failed | cancelled return op time.sleep(interval)update_by_date tells you when to expect the next progress change — poll around it rather
than tightly. completion_by_date is the SLA you can hold us to.
POST needs an Idempotency-Key; PUT does not
Section titled “POST needs an Idempotency-Key; PUT does not”Every POST requires an Idempotency-Key header — a fresh UUID per logical request.
Replaying the same key with the same request returns the same completed response, whether
that response contains a resource or an Operation, rather than doing the work twice. The
replay carries Idempotency-Replayed: true. Omit the header and you get
400 idempotency_key_required.
The call helpers above take idempotent: true / idempotent=True for exactly this.
PUT /domains/{dom}/redirect
is deliberately exempt: a redirect target is a value, so setting it twice is setting it
once.
Errors are RFC 9457 problems
Section titled “Errors are RFC 9457 problems”{"type": "https://api.builders.ac/api/provisioning/v1alpha1/problems/resource_quota_exceeded","title": "Mailbox slot quota exceeded","status": 409,"code": "resource_quota_exceeded","retryable": false,"correlation_id": "a2adf2d3-b26a-4981-96dc-349ac58c91c1","subscribed": 150,"used": 150,"available": 0,"requested": 50}Branch on code, never on title. Quote correlation_id in support requests. Anything
that is not yours is 404, never 403. A 403 with Sealed-Nonce is a proof challenge;
a 403 without it is a permission failure.
Problem types are self-documenting, and neither discovery call needs authentication. Every
error already links its own: the type in a Problem body is that code’s entry, so the URL
in front of you on a 409 resolves to what it means and what to do. The API lists the
whole set here:
GET https://api.builders.ac/api/provisioning/v1alpha1/problemsIt lists every problem type this
version can answer with; GET /problems/{code} returns one of them.
code is deliberately not a closed enum. New codes can appear in any release, so treat
one you do not recognise as its HTTP status rather than failing on it, and read the
catalogue rather than hard-coding what it returns.
Destructive calls need a proof
Section titled “Destructive calls need a proof”DELETE and
POST /inboxes/deprovision
are two-step:
- Call it. You get
403and aSealed-Nonceheader — an age file, base64url-encoded. - Open the nonce with your API Secret. The plaintext is the proof.
- Repeat the same call with
Sealed-Proof: <proof>.
The nonce is single-use and bound to that exact request. Reads never issue one.
# 1. askcurl -s -D /tmp/h -o /dev/null "${H[@]}" -X DELETE "$BASE/domains/$DOM"NONCE=$(grep -i '^sealed-nonce:' /tmp/h | sed 's/^[^:]*: *//' | tr -d '\r')
# 2. openpython3 -c "import base64,syss='${NONCE#age-v1.}'.replace('-','+').replace('_','/'); s+='='*(-len(s)%4)sys.stdout.buffer.write(base64.b64decode(s))" > /tmp/n.agePROOF=$(age -d -i "$SEC" /tmp/n.age)
# 3. repeat with the proofcurl -s "${H[@]}" -H "Sealed-Proof: $PROOF" -X DELETE "$BASE/domains/$DOM"Shells out to the age CLI. The age-encryption
package does it in-process if you would rather not.
import { execFileSync } from "node:child_process";
const SEC = process.env.PROVISIONING_API_SECRET_FILE; // the downloaded .txt
/** Runs a destructive call: challenge, open the nonce, repeat with the proof. */async function callSealed(method, path, options = {}) { // A POST challenge and retry must use the same Idempotency-Key because the // Sealed-Proof is bound to the exact request, including that header. const requestHeaders = { ...(options.idempotent ? { "Idempotency-Key": crypto.randomUUID() } : {}), ...(options.headers ?? {}), }; const requestOptions = { ...options, idempotent: false, headers: requestHeaders };
try { return await call(method, path, requestOptions); } catch (error) { const nonce = error.res?.headers.get("sealed-nonce"); if (!nonce) throw error;
const b64 = nonce .replace(/^age-v1\./, "") .replace(/-/g, "+") .replace(/_/g, "/"); const proof = execFileSync("age", ["-d", "-i", SEC], { input: Buffer.from(b64, "base64"), }) .toString() .trim();
return call(method, path, { ...requestOptions, headers: { ...requestHeaders, "Sealed-Proof": proof }, }); }}
await callSealed("DELETE", `/domains/${DOM}`);Shells out to the age CLI. pyrage does it
in-process if you would rather not.
import base64import subprocess
SEC = os.environ["PROVISIONING_API_SECRET_FILE"] # the downloaded .txt
def open_nonce(nonce): """Open a Sealed-Nonce header into the proof it challenges for.""" encoded = nonce.removeprefix("age-v1.") raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) completed = subprocess.run( ["age", "-d", "-i", SEC], input=raw, capture_output=True, check=True ) return completed.stdout.decode().strip()
def call_sealed(method, path, body=None, idempotent=False): """Runs a destructive call: challenge, open the nonce, repeat with the proof.""" # A POST challenge and retry must use the same Idempotency-Key because the # Sealed-Proof is bound to the exact request, including that header. headers = {"Idempotency-Key": str(uuid.uuid4())} if idempotent else {} first = session.request(method, BASE + path, json=body, headers=headers) if "Sealed-Nonce" not in first.headers: first.raise_for_status() # nothing to prove: it succeeded, or failed for another reason data = None if first.status_code == 204 else first.json() return data, first
proof = open_nonce(first.headers["Sealed-Nonce"]) second = session.request( method, BASE + path, json=body, headers={**headers, "Sealed-Proof": proof}, ) second.raise_for_status() data = None if second.status_code == 204 else second.json() return data, second
call_sealed("DELETE", f"/domains/{DOM}")Rate limits
Section titled “Rate limits”| Bucket | Limit |
|---|---|
| Unauthenticated | 300 / 60s |
| Reads | 120 / 60s |
| Mutations | 30 / 60s |
Exceeding one returns 429 rate_limit_exceeded with retry_after. Batch where you can:
connect takes up to 50 domains, and one inbox-creation request accepts up to 150
addresses. Inbox capacity also follows the supported totals described in
Create Inboxes.
4 · Create a Workspace
Section titled “4 · Create a Workspace”A Workspace groups domains, personas and inboxes — typically one per end customer.
WSP=$(curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ -X POST "$BASE/workspaces" \ -d '{"name":"Acme Corp","external_customer_id":"acme-001"}' | jq -r .id)const { data: workspace } = await call("POST", "/workspaces", { idempotent: true, body: { name: "Acme Corp", external_customer_id: "acme-001" },});const WSP = workspace.id;workspace, _ = call( "POST", "/workspaces", body={"name": "Acme Corp", "external_customer_id": "acme-001"}, idempotent=True,)WSP = workspace["id"]external_customer_id is yours to set and is how you find the workspace later:
curl -s "${H[@]}" "$BASE/workspaces?external_customer_id=acme-001" | jqconst { data: page } = await call( "GET", "/workspaces?external_customer_id=acme-001",);for (const workspace of page.data) { console.log(workspace.id, workspace.name);}page, _ = call("GET", "/workspaces?external_customer_id=acme-001")for workspace in page["data"]: print(workspace["id"], workspace["name"])Creating with an external_id you already used returns 200 with the existing
workspace instead of 201 — the call is safe to repeat.
5 · Connect a Domain
Section titled “5 · Connect a Domain”Connecting brings a domain you already own onto the platform: it creates a Cloudflare zone, waits for you to delegate nameservers, then provisions DNS and a Microsoft 365 tenant.
OP=$(curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ -X POST "$BASE/workspaces/$WSP/domains/connect" \ -d '{"domains":[{"name":"acme-outreach.com","external_id":"acme-dom-1"}]}' | jq -r .id)
DOM=$(curl -s "${H[@]}" "$BASE/operations/$OP" | jq -r '.metadata.domains[0].id')const { data: operation } = await call( "POST", `/workspaces/${WSP}/domains/connect`, { idempotent: true, body: { domains: [{ name: "acme-outreach.com", external_id: "acme-dom-1" }], }, },);
const DOM = operation.metadata.domains[0].id;operation, _ = call( "POST", f"/workspaces/{WSP}/domains/connect", body={"domains": [{"name": "acme-outreach.com", "external_id": "acme-dom-1"}]}, idempotent=True,)
DOM = operation["metadata"]["domains"][0]["id"]Get the nameservers and delegate them
Section titled “Get the nameservers and delegate them”Within a minute or two the domain publishes its nameservers:
curl -s "${H[@]}" "$BASE/domains/$DOM" | jq '{state, nameservers}'const { data: domain } = await call("GET", `/domains/${DOM}`);console.log(domain.state, domain.nameservers);domain, _ = call("GET", f"/domains/{DOM}")print(domain["state"], domain["nameservers"]){ "state": "awaiting_nameservers", "nameservers": ["camilo.ns.cloudflare.com", "karina.ns.cloudflare.com"]}Set those two nameservers at your registrar. awaiting_nameservers is a resting state,
not an error — the workflow retries every 15 minutes for up to 48 hours. Once delegation
propagates the domain moves provisioning → active.
This step is the long pole and it is outside the API. Design your onboarding so a customer can leave and come back rather than watching a spinner.
Domain states
Section titled “Domain states”| State | Meaning |
|---|---|
pending |
Accepted, nothing created yet |
provisioning |
DNS, tenant and certificates being set up |
awaiting_nameservers |
Waiting for you to delegate at the registrar |
active |
Ready to use |
failed |
Something went wrong; read health |
deprovisioning / deprovisioned |
Being torn down / gone |
health answers a different question from state: it is nine independent checks
(nameservers, cloudflare_zone, mx, spf, dkim, dmarc, o365_license,
o365_certificates, redirect), each ok, failing or unmeasured. A domain can be
active with checks still unmeasured.
Common refusals
Section titled “Common refusals”| Code | Meaning |
|---|---|
domain_already_exists |
You already hold this name; the body names the existing domain_id |
domain_unavailable |
Another account holds it |
account_not_provisioned |
Your Account has no DNS capacity allocated yet; retry later. Nothing was created or reserved |
resource_quota_exceeded |
Not enough mailbox slots — the body shows subscribed, used, available, requested |
workspace_archived |
Unarchive the workspace first |
Every refusal happens before anything is created. A batch is all-or-nothing.
Retry a connect that stopped short
Section titled “Retry a connect that stopped short”If the original connect failed after creating the Domain row, do not submit the name to
connect again: that correctly returns domain_already_exists. Reconnect the existing
Domain instead.
curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ -X POST "$BASE/domains/$DOM/reconnect" | jqconst { data: operation } = await call("POST", `/domains/${DOM}/reconnect`, { idempotent: true,});operation, _ = call( "POST", f"/domains/{DOM}/reconnect", idempotent=True,)This returns 202 with another domain.connect Operation. It keeps the same Domain ID,
external_id, and nameservers, and reserves no additional mailbox slots. Poll the new
Operation normally. An active or deprovisioning Domain returns
409 domain_not_reconnectable; an unfinished Operation already holding the Domain returns
409 resource_operation_in_progress.
6 · Create Personas
Section titled “6 · Create Personas”A Persona is a person who will send — a name the platform uses to derive mailbox addresses.
PER=$(curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ -X POST "$BASE/workspaces/$WSP/personas" \ -d '{"first_name":"Ada","last_name":"Lovelace","external_id":"acme-ada"}' | jq -r .id)const { data: persona } = await call("POST", `/workspaces/${WSP}/personas`, { idempotent: true, body: { first_name: "Ada", last_name: "Lovelace", external_id: "acme-ada" },});const PER = persona.id;persona, _ = call( "POST", f"/workspaces/{WSP}/personas", body={"first_name": "Ada", "last_name": "Lovelace", "external_id": "acme-ada"}, idempotent=True,)PER = persona["id"]Only first_name, last_name and external_id are writable.
PATCH updates them; anything else
returns 422.
Personas are optional — you can create inboxes by explicit address instead.
7 · Create Inboxes
Section titled “7 · Create Inboxes”An Inbox is a real Microsoft 365 mailbox. The Domain must be active
(domain_not_active) and its Microsoft 365 tenant must be ready (domain_not_ready).
Provide exactly one of from_personas or inboxes. Both, or neither, returns 422.
The two examples below are alternatives for an empty Domain; run one, not both.
The resulting number of capacity-occupying Inboxes on a Domain must be exactly 50, 75, or 150. A fresh Domain therefore needs a batch of 50, 75, or 150. A smaller batch is valid only when it is a top-up or replacement that lands on one of those totals—for example, 1 added to 49, or 25 added to 50. Pending, provisioning, active, failed, deprovisioning, and legacy Inbox rows occupy capacity; only deprovisioned and soft-deleted rows do not. The Domain and its Microsoft 365 tenant can each hold at most 150, and any reservation growth must also fit the Account’s paid mailbox slots. Every check is all-or-nothing: the API never creates a partial batch.
From a Persona
Section titled “From a Persona”The platform derives distinct addresses from the name—starting with professional forms
such as ada.lovelace@, adalovelace@, and ada@, then trying progressively broader
variants and numeric fallbacks until it has the requested number of unused addresses.
curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ -X POST "$BASE/domains/$DOM/inboxes" \ -d "{\"from_personas\":[{\"persona_id\":\"$PER\",\"count\":50}]}" | jqconst { data: operation } = await call("POST", `/domains/${DOM}/inboxes`, { idempotent: true, body: { from_personas: [{ persona_id: PER, count: 50 }] },});operation, _ = call( "POST", f"/domains/{DOM}/inboxes", body={"from_personas": [{"persona_id": PER, "count": 50}]}, idempotent=True,)By explicit address
Section titled “By explicit address”INBOXES=$(jq -n '{inboxes: ( [ {local_part: "sales", display_name: "Sales Desk", external_id: "acme-sales"}, {local_part: "ada.l"} ] + [range(3; 51) as $n | {local_part: ("sender" + ($n | tostring))}])}')
curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ -X POST "$BASE/domains/$DOM/inboxes" \ -d "$INBOXES" | jqconst { data: operation } = await call("POST", `/domains/${DOM}/inboxes`, { idempotent: true, body: { inboxes: [ { local_part: "sales", display_name: "Sales Desk", external_id: "acme-sales", }, { local_part: "ada.l" }, ...Array.from({ length: 48 }, (_, i) => ({ local_part: `sender${i + 3}`, })), ], },});operation, _ = call( "POST", f"/domains/{DOM}/inboxes", body={ "inboxes": [ {"local_part": "sales", "display_name": "Sales Desk", "external_id": "acme-sales"}, {"local_part": "ada.l"}, *({"local_part": f"sender{number}"} for number in range(3, 51)), ] }, idempotent=True,)local_part allows letters, digits, dots, hyphens and underscores; no leading, trailing or
doubled dots; 64 characters maximum. display_name is optional: the second entry above
comes back as Ada L, a title-cased form of ada.l.
If a batch fits the Domain and tenant capacity but would leave the Domain at an unsupported
total, the entire request returns 409 domain_inbox_count_invalid. Its current,
requested, and resulting fields show the rejected calculation; allowed_totals is
[50, 75, 150], and allowed_additions shows which tier-reaching batch sizes fit the
current Domain and tenant headroom. Paid slots are checked separately, so an allowed
addition can still return resource_quota_exceeded. A batch beyond physical headroom
returns domain_inbox_capacity_exceeded first.
Watch them come up
Section titled “Watch them come up”curl -s "${H[@]}" "$BASE/inboxes?domain_id=$DOM&limit=100" \ | jq -r '.data[] | "\(.email) \(.state) persona=\(.persona_id // "none")"'const { data: page } = await call("GET", `/inboxes?domain_id=${DOM}&limit=100`);for (const inbox of page.data) { console.log(inbox.email, inbox.state, inbox.persona_id ?? "none");}page, _ = call("GET", f"/inboxes?domain_id={DOM}&limit=100")for inbox in page["data"]: print(inbox["email"], inbox["state"], inbox["persona_id"] or "none")Inbox states are pending → provisioning → active, or failed; deprovisioning adds
deprovisioning → deprovisioned (see Tearing down). The creating
Operation carries a live counter in metadata:
"metadata": { "requested": 50, "active": 0, "failed": 0 }persona_id is null for inboxes created by explicit local_part. sequencer is null
until the inbox is connected to a sending tool — see
Connect inboxes to your sequencer — and
when present it carries provider (smartlead | instantly | plusvibe | bison |
other) and state (not_uploaded | uploading | uploaded | failed |
disconnected).
Retrieving the SMTP password
Section titled “Retrieving the SMTP password”GET /domains/{dom} returns
smtp_password as a SealedValue — an ASCII-armored age file sealed to your API Key,
which only your API Secret can open. Lists never seal secrets, and the value appears on
this endpoint only.
curl -s "${H[@]}" "$BASE/domains/$DOM" \ | jq -r .smtp_password.ciphertext | age -d -i "$SEC"import { execFileSync } from "node:child_process";
const { data: domain } = await call("GET", `/domains/${DOM}`);const password = execFileSync("age", ["-d", "-i", SEC], { input: domain.smtp_password.ciphertext,}) .toString() .trim();import subprocess
domain, _ = call("GET", f"/domains/{DOM}")password = subprocess.run( ["age", "-d", "-i", SEC], input=domain["smtp_password"]["ciphertext"].encode(), capture_output=True, check=True,).stdout.decode().strip()A 503 on a read that carries a secret means the sealer was unavailable — the value is
never returned in clear. Request values you send are sealed the other way, to a
platform recipient published by
GET /encryption-keys
(unauthenticated, cacheable for five minutes).
8 · Optional: connect inboxes to your sequencer
Section titled “8 · Optional: connect inboxes to your sequencer”Active inboxes can be handed to the sending tool you already run — Instantly, Smartlead, PlusVibe or Bison — without anyone pasting SMTP credentials into it. Three calls, in order:
- Register the sequencer account once, with
POST /provider-accounts. - Bind a domain to it with
PUT /domains/{dom}/sequencer. - Connect the inboxes with
POST /inboxes/upload.
Register the sequencer account
Section titled “Register the sequencer account”PRA=$(curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ -X POST "$BASE/provider-accounts" \ -d '{"provider":"smartlead","username":"outbound@acme.com", "api_key":"sk-acme-...","sequencer_workspace":"Acme Outbound", "external_id":"crm-8421"}' | jq -r .id)const { data: providerAccount } = await call("POST", "/provider-accounts", { idempotent: true, body: { provider: "smartlead", username: "outbound@acme.com", api_key: process.env.SMARTLEAD_API_KEY, sequencer_workspace: "Acme Outbound", external_id: "crm-8421", },});const PRA = providerAccount.id;provider_account, _ = call( "POST", "/provider-accounts", body={ "provider": "smartlead", "username": "outbound@acme.com", "api_key": os.environ["SMARTLEAD_API_KEY"], "sequencer_workspace": "Acme Outbound", "external_id": "crm-8421", }, idempotent=True,)PRA = provider_account["id"]This is synchronous — no Operation. The credentials are verified with the sequencer
before anything is stored, so a 422 provider_credentials_invalid means nothing was saved;
its validation names the checks that failed. A 503 provider_unreachable means the same,
and is worth retrying later with a fresh Idempotency-Key.
{ "id": "pra_01jsdqrf91f949ybjwh90xde9g", "object": "provider_account", "provider": "smartlead", "username": "outbound@acme.com", "url": null, "sequencer_workspaces": ["Acme Outbound", "Acme EU"], "validation_status": "valid", "validated_at": "2026-04-16T11:02:37Z", "external_id": "crm-8421"}| Field | Notes |
|---|---|
provider |
instantly | smartlead | plusvibe | bison. Set once — it cannot be changed by PATCH |
url |
Required for bison, which lives at a per-customer base URL; rejected for the others |
api_key |
Plaintext, or a SealedValue sealed to a platform key from GET /encryption-keys — prefer sealing |
password, totp_secret |
Only for providers whose browser session needs them. Never returned |
sequencer_workspace |
The workspace the key belongs to, defaulting to Default. Others reachable by the same key are discovered |
sequencer_workspaces |
Everything the key can reach, discovery included. PUT /domains/{dom}/sequencer takes one of these names |
external_id |
Yours, set once, unique per account. Reusing one answers 409 external_id_conflict |
Secrets are never read back. Rotate a key with
PATCH /provider-accounts/{pra} —
the replacement is verified before it is stored, so a refusal leaves the working credentials
in place. Once the account reaches more than one workspace, PATCH needs
sequencer_workspace to say which one it changes.
Bind a domain, then upload
Section titled “Bind a domain, then upload”# no Idempotency-Key on a PUT — binding twice is binding oncecurl -s "${H[@]}" -X PUT "$BASE/domains/$DOM/sequencer" \ -d "{\"provider_account_id\":\"$PRA\",\"sequencer_workspace\":\"Acme Outbound\"}" \ | jq .sequencer
OP=$(curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ -X POST "$BASE/inboxes/upload" \ -d "{\"workspace_id\":\"$WSP\",\"domain_ids\":[\"$DOM\"]}" | jq -r .id)
wait_for "$OP"// no Idempotency-Key on a PUT — binding twice is binding onceconst { data: domain } = await call("PUT", `/domains/${DOM}/sequencer`, { body: { provider_account_id: PRA, sequencer_workspace: "Acme Outbound" },});console.log(domain.sequencer.inboxes); // { uploaded, pending, disconnected }
const { data: operation } = await call("POST", "/inboxes/upload", { idempotent: true, body: { workspace_id: WSP, domain_ids: [DOM] },});const uploaded = await waitFor(operation.id);# no Idempotency-Key on a PUT — binding twice is binding oncedomain, _ = call( "PUT", f"/domains/{DOM}/sequencer", body={"provider_account_id": PRA, "sequencer_workspace": "Acme Outbound"},)print(domain["sequencer"]["inboxes"]) # {uploaded, pending, disconnected}
operation, _ = call( "POST", "/inboxes/upload", body={"workspace_id": WSP, "domain_ids": [DOM]}, idempotent=True,)uploaded = wait_for(operation["id"])Binding queues every inbox already on the domain for connection, so the upload call is only needed for inboxes created afterwards — or to retry. A domain has at most one sequencer: binding again replaces the previous target and re-queues the inboxes against the new one. The domain does not have to be active yet.
POST /inboxes/upload is asynchronous: 202 and an Operation of type inbox.upload. It
takes up to 50 domains, they must all be in workspace_id, and they must all be bound to
the same sequencer account and workspace (sequencer_targets_mixed otherwise). The
inbox set is fixed when the request is accepted. A succeeded Operation can still report
uploaded below requested when the sequencer skipped some — each inbox’s own
sequencer.state says which.
| Refusal | Meaning |
|---|---|
mailbox_upload_unavailable |
The account is not entitled to Mailbox Upload |
domains_not_bound |
Some domains have no binding; domain_ids lists them |
sequencer_targets_mixed |
The domains point at different accounts or workspaces |
no_inboxes_to_upload |
Every inbox on those domains is already connected |
provider_account_in_use |
On DELETE: unbind the domains first |
Read progress back from the domain’s sequencer block (provider_account_id, provider,
workspace, and an inboxes count of uploaded / pending / disconnected), or per
inbox from sequencer.state: not_uploaded → uploading → uploaded, or failed, and
disconnected once a binding is removed.
9 · Optional: redirect and clone
Section titled “9 · Optional: redirect and clone”A connected domain can serve web traffic, which helps deliverability.
# 301 to a target — no Idempotency-Key neededcurl -s "${H[@]}" -X PUT "$BASE/domains/$DOM/redirect" \ -d '{"target_url":"https://acme.com"}' | jq
# or host a copy of that target on the domain itselfcurl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ -X POST "$BASE/domains/$DOM/clone" | jq// 301 to a target — no Idempotency-Key neededawait call("PUT", `/domains/${DOM}/redirect`, { body: { target_url: "https://acme.com" },});
// or host a copy of that target on the domain itselfawait call("POST", `/domains/${DOM}/clone`, { idempotent: true });# 301 to a target — no Idempotency-Key neededcall("PUT", f"/domains/{DOM}/redirect", body={"target_url": "https://acme.com"})
# or host a copy of that target on the domain itselfcall("POST", f"/domains/{DOM}/clone", idempotent=True)target_url must be https. Clone requires a redirect target already set
(redirect_not_configured otherwise). redirect is null until you set one; mode is
redirect (a 301) or clone (a hosted copy), and progress reads from redirect.state:
deploying → active.
10 · Tearing down
Section titled “10 · Tearing down”Every row here needs a Sealed-Proof — use the callSealed / call_sealed helper from
Destructive calls need a proof.
| Action | Call |
|---|---|
| Remove inboxes | POST /inboxes/deprovision with {"inbox_ids":[…]}, up to 100 |
| Unbind a sequencer | DELETE /domains/{dom}/sequencer |
| Remove a sequencer account | DELETE /provider-accounts/{pra} |
| Remove a domain | DELETE /domains/{dom} |
| Remove a persona | DELETE /personas/{per} |
| Remove a workspace | DELETE /workspaces/{wsp} |
Deleting a domain removes its inboxes from Microsoft 365 and releases its mailbox slots.
A workspace must be empty first (workspace_not_empty). Unbinding a sequencer drops every inbox
link it created on that domain, remote ids included, and a sequencer account cannot be
removed while a domain is still bound to it (provider_account_in_use).
Deprovisioning an inbox moves it deprovisioning → deprovisioned. A deprovisioning row
still occupies capacity; only once it reaches deprovisioned does the slot come back — see
Create Inboxes.
A deprovisioned domain still answers GET with state: "deprovisioned" and no password,
so you can reconcile. Every other verb treats it as gone.
Reference
Section titled “Reference”Pagination
Section titled “Pagination”Lists return { data, has_more, next_cursor }. Pass next_cursor back as starting_after.
The cursor is bound to the order and filters it was issued for — change either and you get
422 validation_error. limit defaults to 20, maximum 100.
Operation fields
Section titled “Operation fields”| Field | Meaning |
|---|---|
done |
The only field to branch on for “is it finished” |
type |
domain.connect, domain.provision, domain.deprovision, domain.redirect, domain.clone, inbox.create, inbox.upload, inbox.deprovision |
target |
What it acts on — {type, ids}, or an inbox_collection for batches |
metadata |
Typed progress: create carries {requested, active, failed}, upload {requested, uploaded, failed}, and deprovision {requested, deprovisioned, failed} |
parent_operation_id |
Set when one Operation spawned another |
update_by_date |
When to expect the next progress change — poll around this |
completion_by_date |
The SLA for finishing |
cancel_requested_at |
Set once you call cancel; honoured at a safe boundary, so keep polling |
A succeeded Operation adds response; a failed or cancelled one adds error, itself a
Problem.
Error codes
Section titled “Error codes”Generated from GET /problems —
every code the API answers with as this page was built. It is not a closed set: read
the catalogue at runtime rather than copying this table into a switch. Each entry also
carries a detail saying what to do about that code, which GET /problems/{code} returns
alongside the fields below.
| Code | Status | Retryable | Meaning |
|---|---|---|---|
authentication_required | 401 | No | Missing or unknown API Key |
invalid_api_key | 401 | No | The key was recognised but is revoked or expired |
account_inactive | 403 | No | The key is valid but the account is not active — talk to us |
insufficient_proof | 403 | Yes | Open the Sealed-Nonce and retry |
not_found | 404 | No | Unknown or not yours — never 403 for ownership |
validation_error | 422 | No | See the field-keyed errors map |
idempotency_key_required | 400 | No | A POST needs the header |
idempotency_mismatch | 422 | No | That key was already used for a different request body |
idempotency_in_progress | 409 | Yes | Same key still running; wait and re-poll |
external_id_conflict | 409 | No | That external_id is already in use |
persona_in_use | 409 | No | An inbox references the Persona |
workspace_archived | 409 | No | Unarchive it before creating anything in it |
domain_not_ready | 409 | Yes | Poll the connect Operation, then retry with a new Idempotency-Key |
inbox_address_unavailable | 409 | No | That address is taken |
rate_limit_exceeded | 429 | Yes | See retry_after |
operation_cancelled | 409 | No | Inside the error of an Operation you cancelled |
operation_failed | 500 | Yes | Inside an Operation's error |
resource_operation_in_progress | 409 | Yes | Another Operation holds the resource; body names operation_id |
invalid_sealed_value | 422 | No | A sealed field did not open — seal to a key from GET /encryption-keys |
encryption_keys_unavailable | 503 | Yes | Key discovery is briefly down; retry |
service_unavailable | 503 | Yes | Temporary; retry |
internal_error | 500 | Yes | Quote correlation_id to us |
workspace_limit_reached | 409 | No | Body names limit and count |
workspace_not_empty | 409 | No | Body's counts names what is still in the way |
workspace_is_default | 409 | No | The default workspace is permanent |
workspace_last_active | 409 | No | An account keeps at least one active workspace |
nameservers_not_delegated | 409 | No | The nameservers we issued were never set at the registrar |
external_id_immutable | 409 | No | Set once, at creation; it cannot be moved |
domain_already_exists | 409 | No | You hold it; body names the domain_id |
domain_unavailable | 409 | No | Held elsewhere on the platform |
resource_quota_exceeded | 409 | No | Not enough mailbox slots |
domain_inbox_capacity_exceeded | 409 | No | Body names limit, used, available and requested |
domain_inbox_count_invalid | 409 | No | Use allowed_additions to reach 50, 75 or 150 Inboxes |
domain_not_active | 409 | No | The verb needs state: active |
domain_traffic_unavailable | 409 | Yes | No Cloudflare DNS mapping yet |
redirect_not_configured | 409 | No | Clone needs a redirect target first |
domain_not_reconnectable | 409 | No | Only a connect that did not finish can be repaired; body names state |
account_not_provisioned | 409 | Yes | Your Account has no DNS capacity allocated yet; retry later |
not_implemented | 501 | No | buy and renew in v1alpha1 |
mailbox_upload_unavailable | 422 | No | Mailbox Upload unavailable |
provider_account_in_use | 409 | No | Provider account is in use |
provider_credentials_invalid | 422 | No | Provider credentials rejected |
provider_unreachable | 503 | Yes | Provider unreachable |
no_inboxes_to_upload | 409 | No | No inboxes to upload |
domains_not_bound | 422 | No | Domains have no sequencer |
sequencer_targets_mixed | 422 | No | Sequencer targets differ |
inbox_upload_failed | 500 | Yes | Inbox upload failed |
inbox_upload_partial | 500 | Yes | Inbox upload incomplete |
inbox_create_partial | 500 | No | Inbox creation incomplete |
Known limitations in v1alpha1
Section titled “Known limitations in v1alpha1”Be aware of these before you build against it:
buyandrenewreturn501. Domain purchase is not in this version; connect a domain you already own.- A failed Inbox cannot be retried or removed. Its address stays taken;
deprovisionon it fails. Use a differentlocal_part. - Cancelling an Operation is best-effort. It is honoured at a safe boundary, so keep
polling
stateanddonerather than assuming it stopped.