Skip to content

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.

Provisioning APIv1alpha1Internal testing only
Base URL
https://api.builders.ac/api/provisioning/v1alpha1
API Key
age1…The same credential used by Mailbox v1beta1 and Deliverability.
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

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.

  1. Open the API credentials page and sign in.
  2. Create a builders.ac API credential and give it a name. The same credential also works with Mailbox v1beta1 and Deliverability v1alpha1.
  3. 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.
  4. 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.


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.

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

All examples use the internal-testing Provisioning endpoint currently exposed at:

https://api.builders.ac/api/provisioning/v1alpha1

Everything below builds on this. Keep the key in the environment, never in the source.

Terminal window
export BASE=https://api.builders.ac/api/provisioning/v1alpha1
export KEY=age1qz8x... # API Key
export 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')
Terminal window
curl -s "${H[@]}" "$BASE/me" | jq
{
"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.


These rules apply throughout the API. Reading this section saves you most of the surprises.

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:

Terminal window
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
}

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.

{
"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/problems

It 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.

DELETE and POST /inboxes/deprovision are two-step:

  1. Call it. You get 403 and a Sealed-Nonce header — an age file, base64url-encoded.
  2. Open the nonce with your API Secret. The plaintext is the proof.
  3. Repeat the same call with Sealed-Proof: <proof>.

The nonce is single-use and bound to that exact request. Reads never issue one.

Terminal window
# 1. ask
curl -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. open
python3 -c "
import base64,sys
s='${NONCE#age-v1.}'.replace('-','+').replace('_','/'); s+='='*(-len(s)%4)
sys.stdout.buffer.write(base64.b64decode(s))" > /tmp/n.age
PROOF=$(age -d -i "$SEC" /tmp/n.age)
# 3. repeat with the proof
curl -s "${H[@]}" -H "Sealed-Proof: $PROOF" -X DELETE "$BASE/domains/$DOM"
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.


A Workspace groups domains, personas and inboxes — typically one per end customer.

Terminal window
WSP=$(curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \
-X POST "$BASE/workspaces" \
-d '{"name":"Acme Corp","external_customer_id":"acme-001"}' | jq -r .id)

external_customer_id is yours to set and is how you find the workspace later:

Terminal window
curl -s "${H[@]}" "$BASE/workspaces?external_customer_id=acme-001" | jq

Creating with an external_id you already used returns 200 with the existing workspace instead of 201 — the call is safe to repeat.


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.

Terminal window
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')

Within a minute or two the domain publishes its nameservers:

Terminal window
curl -s "${H[@]}" "$BASE/domains/$DOM" | jq '{state, 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.

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.

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.

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.

Terminal window
curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \
-X POST "$BASE/domains/$DOM/reconnect" | jq

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.


A Persona is a person who will send — a name the platform uses to derive mailbox addresses.

Terminal window
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)

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.


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.

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.

Terminal window
curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \
-X POST "$BASE/domains/$DOM/inboxes" \
-d "{\"from_personas\":[{\"persona_id\":\"$PER\",\"count\":50}]}" | jq
Terminal window
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" | jq

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.

Terminal window
curl -s "${H[@]}" "$BASE/inboxes?domain_id=$DOM&limit=100" \
| jq -r '.data[] | "\(.email) \(.state) persona=\(.persona_id // "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).

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.

Terminal window
curl -s "${H[@]}" "$BASE/domains/$DOM" \
| jq -r .smtp_password.ciphertext | age -d -i "$SEC"

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:

  1. Register the sequencer account once, with POST /provider-accounts.
  2. Bind a domain to it with PUT /domains/{dom}/sequencer.
  3. Connect the inboxes with POST /inboxes/upload.
Terminal window
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)

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.

Terminal window
# no Idempotency-Key on a PUT — binding twice is binding once
curl -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"

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.


A connected domain can serve web traffic, which helps deliverability.

Terminal window
# 301 to a target — no Idempotency-Key needed
curl -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 itself
curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \
-X POST "$BASE/domains/$DOM/clone" | jq

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.


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.


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.

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.

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.

CodeStatusRetryableMeaning
authentication_required401NoMissing or unknown API Key
invalid_api_key401NoThe key was recognised but is revoked or expired
account_inactive403NoThe key is valid but the account is not active — talk to us
insufficient_proof403YesOpen the Sealed-Nonce and retry
not_found404NoUnknown or not yours — never 403 for ownership
validation_error422NoSee the field-keyed errors map
idempotency_key_required400NoA POST needs the header
idempotency_mismatch422NoThat key was already used for a different request body
idempotency_in_progress409YesSame key still running; wait and re-poll
external_id_conflict409NoThat external_id is already in use
persona_in_use409NoAn inbox references the Persona
workspace_archived409NoUnarchive it before creating anything in it
domain_not_ready409YesPoll the connect Operation, then retry with a new Idempotency-Key
inbox_address_unavailable409NoThat address is taken
rate_limit_exceeded429YesSee retry_after
operation_cancelled409NoInside the error of an Operation you cancelled
operation_failed500YesInside an Operation's error
resource_operation_in_progress409YesAnother Operation holds the resource; body names operation_id
invalid_sealed_value422NoA sealed field did not open — seal to a key from GET /encryption-keys
encryption_keys_unavailable503YesKey discovery is briefly down; retry
service_unavailable503YesTemporary; retry
internal_error500YesQuote correlation_id to us
workspace_limit_reached409NoBody names limit and count
workspace_not_empty409NoBody's counts names what is still in the way
workspace_is_default409NoThe default workspace is permanent
workspace_last_active409NoAn account keeps at least one active workspace
nameservers_not_delegated409NoThe nameservers we issued were never set at the registrar
external_id_immutable409NoSet once, at creation; it cannot be moved
domain_already_exists409NoYou hold it; body names the domain_id
domain_unavailable409NoHeld elsewhere on the platform
resource_quota_exceeded409NoNot enough mailbox slots
domain_inbox_capacity_exceeded409NoBody names limit, used, available and requested
domain_inbox_count_invalid409NoUse allowed_additions to reach 50, 75 or 150 Inboxes
domain_not_active409NoThe verb needs state: active
domain_traffic_unavailable409YesNo Cloudflare DNS mapping yet
redirect_not_configured409NoClone needs a redirect target first
domain_not_reconnectable409NoOnly a connect that did not finish can be repaired; body names state
account_not_provisioned409YesYour Account has no DNS capacity allocated yet; retry later
not_implemented501Nobuy and renew in v1alpha1
mailbox_upload_unavailable422NoMailbox Upload unavailable
provider_account_in_use409NoProvider account is in use
provider_credentials_invalid422NoProvider credentials rejected
provider_unreachable503YesProvider unreachable
no_inboxes_to_upload409NoNo inboxes to upload
domains_not_bound422NoDomains have no sequencer
sequencer_targets_mixed422NoSequencer targets differ
inbox_upload_failed500YesInbox upload failed
inbox_upload_partial500YesInbox upload incomplete
inbox_create_partial500NoInbox creation incomplete

Be aware of these before you build against it:

  • buy and renew return 501. 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; deprovision on it fails. Use a different local_part.
  • Cancelling an Operation is best-effort. It is honoured at a safe boundary, so keep polling state and done rather than assuming it stopped.