# OrbitFour REST API — integration guide ICANN-accredited registrar (IANA 3873). This document is written for LLM agents and integration authors. The normative reference is the OpenAPI spec at `https://www.orbitfour.com/api/v1/openapi`; everything here is the operational layer on top of it. Base URL: `https://www.orbitfour.com/api/v1` ## Authentication Every request to an authenticated endpoint sends: ``` Authorization: Bearer o4_live_ ``` Keys are created at `https://www.orbitfour.com/account/api-keys` and the secret is shown **once**. A key belongs to one account, carries an explicit scope list, an optional IP allowlist, an optional expiry, and its own per-minute rate limit. Scopes (a key is granted a subset; every endpoint documents what it needs): - `domains:read` - `domains:write` - `domains:transfer` - `dns:read` - `dns:write` - `contacts:read` - `contacts:write` - `account:read` - `billing:write` - `webhooks:write` `billing:write` is required *in addition to* `domains:write` for anything that spends money (register, renew, transfer, deposit), and on its own it adds, removes and changes saved cards. A domain-automation key without `billing:write` can read and manage DNS but cannot charge the account. `domains:transfer` is the other scope nothing implies. It unlocks a domain, reveals or rotates its transfer authorization code, and approves or rejects a transfer out: together, everything needed to move a domain away. A key with `domains:write` alone can change settings and nameservers and lock a domain, but can't unlock it or read its code. Keys created before the scope existed don't have it; an owner grants it. `webhooks:write` adds, changes and removes webhook endpoints; reading them and the event log takes `account:read`. A key can carry a **daily spend limit** (UTC day), set by the owner on the API keys page. Every paid operation reserves its total against it before paying; over it is `422 spend_limit_exceeded` with what's left, when it resets (`resets_at`, midnight UTC) and whether the total is over the whole limit (`exceeds_daily_limit`, which waiting can't fix). A key with no limit is uncapped over REST and capped at $100 a day for purchases made through the MCP server. An owner can also make a key **require approval for every purchase**. Its registrations and renewals then charge nothing: they answer `403 approval_required` with `details.approval_id`, `approval_url` and `status_url`, and run only when someone on the account who can edit billing approves them (on the web or in the app) within 30 minutes, even after the quote's 10 minutes, at no more than the quoted total. Poll `GET /approvals/{id}` (MCP: `get_purchase_approval`), no faster than its `retry_after`, until it is `approved` (then poll its `operation_id`), `declined`, `expired` or `failed`; sending the same purchase again returns the same approval, or its operation once it ran. One quote parks one approval: different terms with the same `quote_token` are `409 approval_exists`, so get a fresh quote to change them. A key can have 5 waiting at once, and one over its spend limit isn't parked at all. Such a key can't start transfers. Verify a key and see its account + scopes: ```bash curl -sS https://www.orbitfour.com/api/v1/me \ -H "Authorization: Bearer $O4_KEY" ``` ### Signing in as a person (OrbitFour's own apps) OrbitFour's apps don't use API keys: a person signs in, and the app acts as them. This is OAuth 2.1 authorization code + PKCE (S256) for first-party clients only; there is no client registration. Metadata: `https://www.orbitfour.com/.well-known/oauth-authorization-server`. 1. Open `https://www.orbitfour.com/oauth/authorize?client_id=…&redirect_uri=…&response_type=code&code_challenge=…&code_challenge_method=S256&state=…` in an ephemeral browser session. The person signs in on the web (2FA included). 2. `POST https://www.orbitfour.com/oauth/token` with `grant_type=authorization_code`, `code`, `redirect_uri`, `client_id`, `code_verifier`, and optional `device_name`, `platform`, `device_uuid` (labels for the person's devices page). 3. Send `Authorization: Bearer o4at_…` and `X-OrbitFour-Account: ` on each call. `GET /api/v1/me/accounts` lists the ids and the person's role in each; `/me` and `/me/accounts` need no account header, everything else does (`account_required`). The role decides what each call may do, as on the web. Access tokens live 15 minutes. Refresh with `grant_type=refresh_token`; each refresh returns a new refresh token and spends the old one, and presenting a spent one signs the device out. Sign out with `POST /oauth/revoke` and the refresh token. The client contract: - Run one refresh at a time. If a refresh (or the code exchange) gets no answer, retry it with the same token within 60 seconds: that returns a fresh pair, and the pair you never received stops working. - On a 401 `unauthorized`, refresh once and retry once. - Sign the person out only when a refresh returns `invalid_grant`, or on `session_expired`. - Never sign out on a network error or a 5xx. - `two_factor_required`, `ip_not_allowed` and `account_suspended` are about one account: show them for that account and keep the sign-in. Some endpoints are only for the OrbitFour app signed in as a person, and refuse API keys and third-party apps with `403 first_party_app_required`: - team management: `/team/members`, `/team/invitations`; - push: `POST /devices` (the APNs token, on every launch), `DELETE /devices/{token}`, `GET`/`PATCH /me/notification_preferences`, and the badge: `GET /me/badge` on launch, `POST /me/badge/clear` (optionally `through` the newest event shown) when the person opens the feed; every push carries the same count; - purchase approvals: `POST /approvals/{id}/approve` and `/decline` (edit_billing); `GET /approvals` lists what's waiting; - account deletion: `GET`/`POST`/`DELETE /me/deletion_request` (asking needs a sign-in under 5 minutes old, otherwise `reauthentication_required`); - in-app payment: `payment_method: "client_confirm"` on register, renew and transfer answers `202 requires_payment` with `payment.client_secret` for Stripe PaymentSheet (card, Apple Pay, in-app 3-D Secure). Fulfillment starts when Stripe confirms the payment; `POST /operations/{id}/cancel` releases one the person backed out of, and an unconfirmed one is released after 30 minutes. ### Hosted MCP clients (OAuth) A hosted MCP client (Claude, ChatGPT and the like) can't hold an API key, so the account MCP server at `https://www.orbitfour.com/mcp/account` also takes OAuth. A 401 there names `https://www.orbitfour.com/.well-known/oauth-protected-resource/mcp/account`, which points at the authorization server. Clients identify themselves with a client ID metadata document (the `client_id` is an https URL serving `client_id`, `client_name` and `redirect_uris`); there is no dynamic registration. The person signs in on the web, picks ONE account and ticks what the client may do (read-only by default). Third-party apps can never get `billing:write`, `domains:transfer` or `webhooks:write`, and their tokens work only at `/mcp/account`, only in the chosen account. ### Rate limits Every authenticated response carries the current bucket state, so pace proactively rather than discovering the limit with a 429: ``` x-ratelimit-limit: 600 x-ratelimit-remaining: 594 x-ratelimit-reset: 41 ``` `x-ratelimit-reset` is **seconds until the window rolls**, not a timestamp. A 429 adds `Retry-After` (also seconds). Card payments carry a second, separate anti-card-testing budget per account — see `velocity_limited` in the error catalog; the prepaid balance is not velocity-limited and is the intended path for volume automation. ## Environments | | Production | Sandbox | | --- | --- | --- | | Base URL | `https://www.orbitfour.com` | `https://sandbox.orbitfour.com` | | Keys | `o4_live_...` | `o4_test_...` | | Registry | the real registries | an in-process fake registry | | Money | real cards and balance | Stripe test mode: test cards only, nothing is charged | | Accounts | | separate: sign up again at `https://sandbox.orbitfour.com/register` | A key works only where it was created. Sending it to the other host returns `401 key_environment_mismatch`, with `details.base_url` naming where it works. ### What the sandbox simulates The same code as production, so registration, renewal and transfer operations, operation polling, DNS records, contacts, deposits, card payments, refunds on a failed operation, idempotency and every scope and error path below behave as in production. Email is real (subjects start with `[Sandbox]`). ### What the sandbox doesn't simulate - **Premium names, mostly.** A name starting `premium-` is quoted as premium ($250 a year wholesale) on extensions whose registry prices premiums, so you can exercise `max_price`, `quote_changed` and the MCP premium refusal. Every other name is the standard price. - **Registry failures on demand.** A name starting `fail-` is refused at create, so a paid registration fails and is refunded. - **Availability is deterministic, not real.** Famous names (`google`, `example`, ...) and about one name in five are taken; a name anyone registered on the sandbox stays taken. - **Transfers in** complete in about a minute (a real one takes up to five days). Transfer a name the sandbox reports as taken. - **DNS** records are stored and returned, but never published to nameservers. - **Data** may be reset, with notice. ### Sandbox test cards Any future expiry, any CVC, any postal code. | Card | Behavior | | --- | --- | | `4242 4242 4242 4242` | Succeeds. | | `4000 0027 6000 3184` | Requires 3-D Secure on every charge, so an off-session API charge returns `402 authentication_required`. | | `4000 0000 0000 0341` | Can be saved, but every charge is declined: `402 payment_failed`. | | `4000 0000 0000 9995` | Declined for insufficient funds. | ## How money settles Two settlement paths, chosen per request with the `payment_method` field: | `payment_method` | Behavior | | --- | --- | | omitted | Prepaid balance first, falling back to the account's default card. | | `"balance"` | Prepaid balance only. `402 insufficient_balance` if short — never falls back to a card. | | `"default"` | The account's default card. | | a payment-method UUID | That specific card. | | a last-four, e.g. `"4242"` | The card with that last-four; `409 ambiguous_payment_method` (with candidate UUIDs) if several match. | **Money always settles before registry work, and unwinds if the registry work fails.** A `202` never means the domain exists yet — read the operation's status for that. If fulfillment fails, the charge is refunded (card) or the ledger debit reversed (balance), and the status reflects it. For unattended automation, prefer the prepaid balance: it is a single atomic debit (no 3-D Secure, no `authentication_required`, no card velocity limit). API card charges run off-session, so a card that demands 3-D Secure at charge time returns `402 authentication_required`. A card added through `POST /payment_methods/setup_intents` is authenticated for later off-session use when it's added, which makes that much rarer. See "Add a card" below. Amounts are always objects, never bare numbers: ```json { "amount": "12.20", "currency": "USD" } ``` The amount is a decimal **string** to avoid float rounding. The prepaid balance is USD-only; the minimum deposit is $5.00. **Cap what an operation can charge with `max_price`.** Register, renew and transfer accept an optional `max_price`, a USD decimal string compared against the full charge (every year, the ICANN fee included: the same figure the operation reports as `amount`). A premium name is charged at the live registry quote, so set it to the most you'll pay for the whole operation. `GET /domains/check` quotes a per-year price without the ICANN fee: multiply it by the years you're buying and add each year's ICANN fee (`icann_fee` on `GET /pricing`; $0.20 on gTLDs, none on ccTLDs). A higher charge returns `422 max_price_exceeded` with the real price in `details.price`, and nothing is charged. **Or quote first.** `"dry_run": true` on register, renew or transfer runs every check and stops before anything is charged, reserved or written, and answers `200` with the total, how it would pay, a one-line `summary` and a `quote_token`. Send the token back on the same request within 10 minutes to buy exactly that: a higher live price is `409 quote_changed` (with a fresh quote in `details.quote`), a lower one proceeds lower, and a token spends once (sending it again returns the first operation). ## Idempotency Every mutating endpoint accepts an `Idempotency-Key` header — the paid ones (`POST /domains`, `POST /domains/:id/renew`, `POST /transfers`, `POST /balance/deposits`) and the free ones (`POST/PATCH/DELETE` on `/domains/:id/dns`, `/contacts` and `/payment_methods`) alike. Any opaque unique string works (a UUID, or something like `reg-2026-07-24-001`). Pick one per logical operation and **reuse it verbatim on every retry of that operation**. The key is claimed atomically *before* the mutation, so: - Retrying the same key with the same payload replays the original outcome. A replay carries the response header `idempotent-replayed: true` — use it to tell a fresh charge from a cached one. - Retrying while the first attempt is still running returns `409 idempotency_key_in_flight` — wait and retry the same key. - Reusing the key with a *different* payload returns `409 idempotency_key_reused`. - Only `2xx` responses are cached. Any other status **releases** the key. That last point matters more than it looks. Because a non-2xx releases the key, a **5xx or a network timeout on a paid operation is ambiguous** — the charge may or may not have landed — and a same-key retry *re-executes* rather than replaying. The key is not what protects you there. What protects you is the duplicate guard: a second attempt at an operation already in flight for the same domain is rejected with `409 duplicate_operation`, and if a charge did slip through it is refunded automatically. So the recovery rule for an ambiguous paid request is: retry the identical request with the same Idempotency-Key (never worse than a fresh key, and it keeps your retry path uniform), and treat `409 duplicate_operation` as "the first attempt won" rather than an error — poll `details.operation_id` when it is present. Never assume failure and never re-issue as a new operation. ## Workflows Integrations fail at the seams between endpoints, not at the endpoints. These are the end-to-end paths. `$O4_KEY` is an API key; `$IDEM` is a fresh key per operation. **Scopes do not imply each other.** `domains:write` does not grant `domains:read`, so a key that can register a domain but not read one gets `403 insufficient_scope` at the polling step — after the money has moved. Grant every scope a whole workflow touches; each step below names its own. ### 1. Register a domain ```bash # 1. Is it available, and what does it cost? (unauthenticated; `names` takes # a comma-separated batch, so check alternates in one call) curl -sS "https://www.orbitfour.com/api/v1/domains/check?names=example.com,example.net&years=1" # 2. Make sure the balance covers it (scope: account:read) curl -sS https://www.orbitfour.com/api/v1/balance \ -H "Authorization: Bearer $O4_KEY" # 3. Top up if needed — synchronous, a 201 means the credit is committed # (scopes: billing:write) curl -sS -X POST https://www.orbitfour.com/api/v1/balance/deposits \ -H "Authorization: Bearer $O4_KEY" \ -H "Idempotency-Key: $IDEM_DEPOSIT" \ -H "Content-Type: application/json" \ -d '{"amount": "250.00", "payment_method": "default"}' # 4. Register. Returns 202 + an operation id; the money has already settled. # (scopes: domains:write + billing:write) curl -sS -X POST https://www.orbitfour.com/api/v1/domains \ -H "Authorization: Bearer $O4_KEY" \ -H "Idempotency-Key: $IDEM" \ -H "Content-Type: application/json" \ -d '{"domain_name": "example.com", "years": 1, "payment_method": "balance", "max_price": "20.00"}' # 5. Poll the operation until status leaves pending_payment/processing. # Honor the `retry_after` field in the response as your polling interval. # (scope: domains:read — NOT implied by domains:write) curl -sS https://www.orbitfour.com/api/v1/operations/ \ -H "Authorization: Bearer $O4_KEY" # 6. On "completed", the operation carries domain_id — fetch the domain. # (scope: domains:read) curl -sS https://www.orbitfour.com/api/v1/domains/ \ -H "Authorization: Bearer $O4_KEY" ``` Minimum key for this whole workflow: `domains:read`, `domains:write`, `billing:write`, and `account:read` for the balance check. Operation statuses and what to do with them: | status | meaning | next | | --- | --- | --- | | `requires_payment` | The app must confirm the payment (`client_confirm` only). | Present PaymentSheet, then poll. | | `pending_payment` | Card authentication in flight. | Keep polling. | | `processing` | Paid; registry work running. | Keep polling. | | `completed` | Done. `domain_id` is set. | Stop. | | `action_required` | Transfer held on a user-fixable problem (e.g. wrong auth code); the charge is held for a retry window. | Resolve in the web UI. | | `failed_refund_pending` | Fulfillment failed; refund in progress. | Stop; the money is coming back. | | `failed_refunded` | Fulfillment failed; money returned. | Safe to try again as a new operation. | | `payment_failed` | Nothing was charged. | Fix the payment method and retry. | | `canceled` | Payment canceled. | Nothing charged. | `years` in the response is the term **actually purchased** — it is clamped up to the TLD minimum, so always read it back rather than assuming your requested value. ### 2. Transfer a domain in ```bash # 1. Get the auth (EPP) code from the losing registrar, and check the domain is # transferable and what it costs (unauthenticated, up to 5 names, never takes # the code). transferable is true, false (reasons says why: locked, # pending_transfer, ...) or null (the registry couldn't be asked just now). curl -sS "https://www.orbitfour.com/api/v1/transfers/check?domains=example.com" # 2. Request the transfer. The term is fixed by registry policy per TLD and is # NOT client-selectable. (scopes: domains:write + billing:write) curl -sS -X POST https://www.orbitfour.com/api/v1/transfers \ -H "Authorization: Bearer $O4_KEY" \ -H "Idempotency-Key: $IDEM" \ -H "Content-Type: application/json" \ -d '{"domain_name": "example.com", "auth_code": "aBcD1234!", "payment_method": "balance"}' # 3. Poll the operation (scope: domains:read). NOTE: a transfer settles # asynchronously at the REGISTRY — approval takes up to 5 days. # `completed` here means the transfer REQUEST was accepted, and the domain # then appears with a "Transferring In" status until the registry finishes. curl -sS https://www.orbitfour.com/api/v1/operations/ \ -H "Authorization: Bearer $O4_KEY" ``` The transfer request asks the registry again (with the code) before charging: a definitive no is `422 transfer_ineligible` with `details.reasons`, and nothing is charged. If the registry can't be asked, the transfer goes ahead. Inbound transfers keep the domain's existing nameservers and enable WHOIS privacy. Change either after the transfer completes — not before. ### 3. Manage DNS ```bash # List the zone (scope: dns:read) curl -sS https://www.orbitfour.com/api/v1/domains//dns \ -H "Authorization: Bearer $O4_KEY" # Create a record — name/type/value required; use "@" for the zone root. # (scope: dns:write) curl -sS -X POST https://www.orbitfour.com/api/v1/domains//dns \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "www", "type": "A", "value": "203.0.113.10", "ttl": 3600}' # Update (partial) / delete by record id curl -sS -X PATCH https://www.orbitfour.com/api/v1/domains//dns/ \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"value": "203.0.113.11"}' curl -sS -X DELETE https://www.orbitfour.com/api/v1/domains//dns/ \ -H "Authorization: Bearer $O4_KEY" ``` `type` is one of A, AAAA, ALIAS, CNAME, MX, TXT, NS, SRV, CAA, WR (a web redirect to the http(s) URL in `value`); MX and SRV also take `priority`, and `ttl` is in seconds. DNS writes are free, so they do not need `billing:write` — but they do honor `Idempotency-Key` if you send one. A new domain on OrbitFour's DNS starts with parking records: `@ ALIAS` and `www CNAME`, both to `parking.orbitfour.com`. A CNAME must be the only record at its name and an ALIAS can't share a name with an A or AAAA, so a clashing create is `422 validation_failed` whose `details.name` names the record in the way (type, name, id, value). PATCH that record instead (its `type` can change) or delete it. The NS records at `@` can't be changed or deleted (`422 protected_record`), and a record with `enabled: false` is still published. ### 3a. Change a domain's settings, nameservers and lock ```bash # Auto-renew and WHOIS privacy; send only what changes (scope: domains:write) curl -sS -X PATCH https://www.orbitfour.com/api/v1/domains//settings \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"auto_renew": false}' # Auto-renew's terms: years per renewal, days before expiry it runs, and the most a # year may cost (USD, ICANN fee included; null is no limit). The same choices as the # domain page: days 7/14/30/60/90, price 25.00/50.00/75.00/100.00/200.00/500.00. curl -sS -X PATCH https://www.orbitfour.com/api/v1/domains//settings \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"auto_renew": true, "auto_renew_years": 2, "auto_renew_days_before": 60, "auto_renew_max_price": "50.00"}' # Replace the nameserver set; the registry gets it first (scope: domains:write) curl -sS -X PUT https://www.orbitfour.com/api/v1/domains//nameservers \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"nameservers": ["ns1.o4dns.com", "ns2.o4dns.com"]}' # Lock (scope: domains:write) / unlock (scope: domains:transfer) curl -sS -X POST https://www.orbitfour.com/api/v1/domains//lock -H "Authorization: Bearer $O4_KEY" curl -sS -X POST https://www.orbitfour.com/api/v1/domains//unlock -H "Authorization: Bearer $O4_KEY" # Read or replace the transfer code of an unlocked domain (scope: domains:transfer) curl -sS -X POST https://www.orbitfour.com/api/v1/domains//auth_code -H "Authorization: Bearer $O4_KEY" curl -sS -X POST https://www.orbitfour.com/api/v1/domains//auth_code/rotate -H "Authorization: Bearer $O4_KEY" # Answer a transfer out another registrar requested (scope: domains:transfer) curl -sS -X POST https://www.orbitfour.com/api/v1/domains//transfer_out/approve -H "Authorization: Bearer $O4_KEY" curl -sS -X POST https://www.orbitfour.com/api/v1/domains//transfer_out/reject -H "Authorization: Bearer $O4_KEY" ``` Every change answers with the domain as `GET /api/v1/domains/{id}` shows it, including `transfer_lock.days_until_transferable` and `transfer_lock.protective_lock_until`. The registry is asked first, so a failure there is `502 registry_unavailable` and nothing is saved here. Unlocking is refused under a protective lock or a registry server lock. The transfer code is refused while the domain is locked (`409 domain_locked`); rotating it is also refused inside the 60-day hold and while a transfer is pending. The code is returned with `Cache-Control: no-store` and is never kept by OrbitFour, so those two routes ignore `Idempotency-Key`; every read and rotation is audited. ### 3b. Contacts (required for nexus TLDs) Registrations for `.us` and other nexus TLDs need a registrant contact that already carries the eligibility data — create it first, then pass its id as `contact_id` on the registration. ```bash # List (scope: contacts:read) curl -sS https://www.orbitfour.com/api/v1/contacts -H "Authorization: Bearer $O4_KEY" # Create (scope: contacts:write). Required: first_name, last_name, address1, # city, zip, country_code, phone, email. The three nexus_* / app_purpose # fields are what a .us registration additionally needs. curl -sS -X POST https://www.orbitfour.com/api/v1/contacts \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com", "phone": "+1.5551234567", "address1": "1 Main St", "city": "Columbus", "state": "OH", "zip": "43004", "country_code": "US", "nexus_category": "C11", "app_purpose": "P1" }' # Update (partial) / delete by contact id curl -sS -X PATCH https://www.orbitfour.com/api/v1/contacts/ \ -H "Authorization: Bearer $O4_KEY" -H "Content-Type: application/json" \ -d '{"nexus_category": "C21"}' curl -sS -X DELETE https://www.orbitfour.com/api/v1/contacts/ \ -H "Authorization: Bearer $O4_KEY" ``` `nexus_category` is one of C11/C12/C21/C31/C32 and `app_purpose` one of P1–P5; C31/C32 additionally need `nexus_country`. If any are missing at registration time you get `422 nexus_required` with `details.missing_fields` naming them — PATCH the contact and retry. Full property list: `ContactCreateInput` in `https://www.orbitfour.com/api/v1/openapi`. ### 4. Fund and audit the prepaid balance ```bash # Current balance (scope: account:read) curl -sS https://www.orbitfour.com/api/v1/balance -H "Authorization: Bearer $O4_KEY" # Deposit — synchronous. 201 = credited. (scope: billing:write) curl -sS -X POST https://www.orbitfour.com/api/v1/balance/deposits \ -H "Authorization: Bearer $O4_KEY" \ -H "Idempotency-Key: $IDEM" \ -H "Content-Type: application/json" \ -d '{"amount": "250.00"}' # Append-only ledger: deposits, draws, reversals, refunds, adjustments curl -sS "https://www.orbitfour.com/api/v1/balance/ledger?page=1&per_page=50" \ -H "Authorization: Bearer $O4_KEY" # Transactions and invoices for accounting reconciliation curl -sS https://www.orbitfour.com/api/v1/transactions -H "Authorization: Bearer $O4_KEY" curl -sS https://www.orbitfour.com/api/v1/invoices -H "Authorization: Bearer $O4_KEY" ``` A `502 deposit_pending_reconciliation` means **the card was charged** but the ledger credit has not committed. It reconciles automatically. Retry the same `Idempotency-Key` to check — do not submit a second deposit. Set a low-balance alert so a draining balance doesn't surprise your automation (scope: billing:write). The first purchase that takes the balance under it emits a `balance.low` event (webhooks, app push) and emails the owners, once per crossing; `GET /balance` reports `low_balance_threshold` and `below_threshold`. ```bash curl -sS -X PATCH https://www.orbitfour.com/api/v1/balance/settings \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"low_balance_threshold": "50.00"}' ``` Or keep the balance funded without watching it: with `auto_top_up` on, that crossing charges the default card off-session for enough to bring the balance back to `target`, never more than what is left of `monthly_limit` this UTC month, instead of emailing. A failed charge turns it off and emails the owners. `GET /balance` reports `auto_top_up` with `spent_this_month`; `null` turns it off. ```bash curl -sS -X PATCH https://www.orbitfour.com/api/v1/balance/settings \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"low_balance_threshold": "50.00", "auto_top_up": {"target": "250.00", "monthly_limit": "1000.00"}}' ``` ### 5. Add a card Card details never pass through this API. You mint a Stripe SetupIntent here, the card is entered and confirmed with Stripe directly (Stripe.js / Elements in a browser, or the Stripe iOS/Android SDK), and then you register the result. ```bash # 1. Start a setup (scope: billing:write). Returns setup_intent_id, # client_secret and publishable_key. curl -sS -X POST https://www.orbitfour.com/api/v1/payment_methods/setup_intents \ -H "Authorization: Bearer $O4_KEY" \ -H "Idempotency-Key: $IDEM" \ -H "Content-Type: application/json" \ -d '{"make_default": true, "nickname": "Ops card"}' ``` 2. Confirm it client-side: in Stripe.js, `stripe = Stripe(publishable_key)` and `stripe.confirmCardSetup(client_secret, {payment_method: {card: cardElement}})`; in the iOS SDK, `STPPaymentHandler.confirmSetupIntent`. Any 3-D Secure challenge happens here, once, with the cardholder present. ```bash # 3. Register the confirmed card. 201 = added, 200 = already registered. curl -sS -X POST https://www.orbitfour.com/api/v1/payment_methods \ -H "Authorization: Bearer $O4_KEY" \ -H "Idempotency-Key: $IDEM2" \ -H "Content-Type: application/json" \ -d '{"setup_intent_id": "seti_..."}' # Make a card the default, or rename it curl -sS -X PATCH https://www.orbitfour.com/api/v1/payment_methods/$PM_ID \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"is_default": true}' # Remove a card curl -sS -X DELETE https://www.orbitfour.com/api/v1/payment_methods/$PM_ID \ -H "Authorization: Bearer $O4_KEY" ``` - `409 setup_incomplete` on register means the client hasn't finished confirming; `details.status` says where it is. Finish, then retry. - If the client dies after confirming and never calls register, the card is still recorded by a Stripe webhook within a minute or so. - Removing the default card does **not** promote another one. Until you set a new default, operations without balance cover fail with `422 no_default_payment_method` and auto-renew can't charge a card. - With no browser or app of your own, a person can add the card on the web instead: `details.add_card_url` on `no_default_payment_method` and `authentication_required` links to that page. ### 6. Events and webhooks Everything that happens to the account is an event, kept 30 days: poll `GET /api/v1/events` (`after=` returns only what's new, `type=` filters), or have them POSTed to you. - `domain.registered`: A registration completed. data: domain, years, operation_id, actor. - `domain.renewed`: A renewal completed (manual, API or auto-renewal). data: domain, years, operation_id, actor. - `domain.transfer_completed`: A transfer in completed and the domain is in the account. data: domain, operation_id, actor. - `domain.operation_failed`: A paid registration, renewal or transfer failed. data: domain, operation (registration|renewal|transfer), operation_id, reason, refunded, actor. - `domain.action_required`: A transfer in can't proceed until you act (usually a wrong or expired auth code); it can be retried until retry_deadline. data: domain, operation_id, reason, retry_deadline, actor. - `domain.transfer_out_requested`: Another registrar asked for the domain. Approve or reject it, or it completes at the registry's deadline. data: domain. - `domain.transferred_out`: The domain left for another registrar. data: domain. - `domain.expiring`: The domain expires soon. Sent 30, 7 and 1 days ahead. data: domain, expires_on, days_left, auto_renew. - `domain.renewal_payment_failed`: Auto-renewal couldn't take payment. data: domain, reason. - `domain.settings_changed`: Auto-renew, its terms, privacy or the transfer lock changed. data: domain, changes (field names), actor. - `domain.nameservers_changed`: The nameservers changed at the registry. data: domain, nameservers, actor. - `dns.record_changed`: A DNS record was created, updated or deleted. data: domain, record_id, record_type, name, action, actor. - `watchlist.domain_available`: A domain on the watchlist with notify became available. data: domain_name, watchlist_item_id. - `balance.deposited`: Funds were added to the prepaid balance. data: amount, balance. - `balance.low`: The prepaid balance fell below the account's low-balance threshold. data: balance, threshold. - `purchase.approval_requested`: A key that requires approval for purchases asked for one; it runs if someone who can edit billing approves it before expires_at. data: approval_id, action, domain_name, years, total, api_key_name, expires_at, actor. - `webhook.test`: Sent on demand from the webhook endpoint's Test button or POST .../test. data: message. `actor` says who did it: `{"type", "id", "name"}` with type `api_key` (id is whoami's `api_key.id`), `app` or `oauth_client` (id is `app_session.id`) or `user` (a person on the website; name is null). It is null when OrbitFour acted itself, as in an auto-renewal. Balance ledger entries for API purchases carry the same field. ```bash # Add an endpoint (scope: webhooks:write; owners and admins). The signing secret # is in this response only. curl -sS -X POST https://www.orbitfour.com/api/v1/webhook_endpoints \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "'"$YOUR_HOOK_URL"'", "events": ["domain.registered", "domain.operation_failed", "balance.low"]}' # Send it a webhook.test event, then read how the delivery went curl -sS -X POST https://www.orbitfour.com/api/v1/webhook_endpoints//test -H "Authorization: Bearer $O4_KEY" curl -sS https://www.orbitfour.com/api/v1/webhook_endpoints//deliveries -H "Authorization: Bearer $O4_KEY" ``` Each delivery is a POST of the event object (`id`, `type`, `created_at`, `account_id`, `api_version`, `data`) with `X-OrbitFour-Signature: t=,v1=`. Verify it before trusting the body: `v1` is HMAC-SHA256 with your secret over `"."`; compare in constant time and reject a `t` more than 5 minutes old. Answer 2xx quickly and do the work afterwards. A non-2xx or a timeout (10 s) is retried 7 times (8 attempts) over about 3 days with the same event `id`, so drop ids you've seen. After 15 deliveries in a row fail every retry, the endpoint is switched off (`disabled_reason`); `PATCH` it `{"active": true}` once fixed. URLs must be https on a public address; redirects aren't followed. ```python import hmac, hashlib, time def verify(secret, header, body): parts = dict(p.split("=", 1) for p in header.split(",")) if abs(time.time() - int(parts["t"])) > 300: return False expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"]) ``` ### 7. Watchlist and team ```bash # Watch a name; "notify" checks it daily and emails when it frees up # (scope: domains:write; reading: domains:read) curl -sS -X POST https://www.orbitfour.com/api/v1/watchlist \ -H "Authorization: Bearer $O4_KEY" \ -H "Content-Type: application/json" \ -d '{"domain_name": "dream.com", "intent": "notify"}' ``` Team management (members, invitations, permissions) is only for the OrbitFour app signed in as a person; API keys get `first_party_app_required`. ## Registry constraints to check before you call These are registry and ICANN rules, not OrbitFour preferences. Each one otherwise surfaces as a 422 after you have already built the request. **Read them programmatically instead of hardcoding them.** `GET https://www.orbitfour.com/api/v1/pricing` is unauthenticated and cacheable, and every constraint below is a field in it — per-TLD (`min_years`, `max_years`, `transfer_years`, `transfer_years_fixed`, `requires_nexus`, `whois_privacy.supported`, `eligibility`, `premium_possible`, `https_required`) and account-wide in the top-level `constraints` object (`transfer_lock_days`, `grace_period_days`, `redemption_period_days`, `balance_currency`, `min_deposit`, `premium_max_retail`). For one TLD, `GET https://www.orbitfour.com/api/v1/pricing/{tld}` (or the MCP tool `get_tld_requirements`) returns the same entry plus two JSON Schemas: `registration_schema`, the TLD-specific part of the `POST /domains` body, and `registrant_schema`, the fields the registrant contact must carry (`.us` Nexus). Both are `{}` when the TLD adds nothing. The prose here explains the *why*; the feed is the current truth, so prefer it — TLD policy changes without this document changing. - **Minimum registration term varies by TLD.** Most are 1 year; some (e.g. `.ai`) require 2. `years` is clamped **up** to the minimum rather than rejected, and the response reports the term purchased — read it back and expect to be charged for it. - **Transfer terms are fixed per registry, not chosen by you.** Some registries mandate exactly 1 year (PIR/`.org`), others allow a range, and some require 2. `POST /transfers` takes no `years` field at all. - **60-day transfer lock.** ICANN forbids transferring a domain within 60 days of its initial registration or of a previous completed transfer. The losing registrar may also apply a 60-day lock after a registrant contact change. - **Transfers need the domain unlocked and an auth code** from the losing registrar. Auth codes are at least 8 characters. - **US Nexus TLDs need eligibility data on the contact.** `.us` (and any TLD flagged as requiring nexus) requires NexusCategory + application purpose on the **registrant contact** before the registration can be attempted. Create the contact with that data, pass it as `contact_id`, and expect `422 nexus_required` with `details.missing_fields` if anything is absent. - **WHOIS privacy is forbidden on some TLDs.** `.us` is the notable one — privacy is forced off there regardless of what you request. - **Premium domains are priced by the registry per name.** Always read the price from `/api/v1/domains/check` (or the operation response) rather than the TLD catalog price. Above an automated cap you get `422 premium_requires_review` instead of a charge. - **The prepaid balance is USD-only**, minimum deposit $5.00. - **A domain already inside an OrbitFour account cannot be transferred in** — `422 domain_already_managed`. Human-readable per-TLD legal and eligibility requirements are published at `https://www.orbitfour.com/legal/tld-requirements` (the same copy the feed's `eligibility.summary` carries). `/api/v1/pricing/{tld}` is rendered by the same serializer as the catalog, so terms and prices can never disagree between the two. ## Error catalog Every error the API raises deliberately uses one envelope: ```json { "error": { "code": "insufficient_scope", "message": "This API key lacks the required scope: domains:write.", "details": { "required_scope": "domains:write" } } } ``` `code` is the stable, machine-readable identifier — branch on it, never on `message`. `details` is present only when there is structured context to give. Rate limits use it on every endpoint, authenticated or not: a `429 rate_limited` carries `details.retry_after` (seconds) alongside the `Retry-After` header. **Framework-level errors use it too**, with the HTTP reason as the `code`: an unknown path is `404 not_found`, an unacceptable `Accept` header `406 not_acceptable`, an unhandled server exception `500 internal_server_error`. These reason codes aren't listed in the catalog below. The catalog's `operation_failed` covers the paid-operation catch-all, not every possible 500. A robust client reads `error.code` and treats anything else as an unmapped failure keyed off the HTTP status alone. Retryability, in three kinds: - **no — fix the request**: deterministic. Retrying the same request always fails the same way. - **yes — with backoff**: transient. Retry with exponential backoff; honor `Retry-After` when present. - **yes — same Idempotency-Key only**: the original attempt may have moved money. Retry the identical request with the identical key. Never mint a new key. | code | HTTP | retryable | meaning | | --- | --- | --- | --- | | `unauthorized` | 401 | no — fix the request | The Authorization header is missing, malformed, or the API key is revoked, expired, IP-blocked, or no longer backed by an account owner (the person who created it left the account or lost the owner role). For an app access token (`o4at_`): it expired (they last 15 minutes) or was revoked. Refresh once and retry; sign in again only if the refresh returns `invalid_grant`. | | `session_expired` | 401 | no — fix the request | App tokens only. The account named in `X-OrbitFour-Account` signs out idle sessions, and this sign-in was idle longer than its timeout, so it was revoked. Sign in again. | | `account_required` | 400 | no — fix the request | App tokens only. The endpoint acts in an account and the request named none: send `X-OrbitFour-Account` with an id from `GET /api/v1/me/accounts`. | | `two_factor_required` | 403 | no — fix the request | App tokens only. The account requires two-factor authentication and the sign-in behind this token didn't use it. Turn on 2FA, then sign in again; other accounts keep working. | | `ip_not_allowed` | 403 | no — fix the request | App tokens only. The account named in `X-OrbitFour-Account` only allows access from approved IP addresses, and this request came from elsewhere. The sign-in stays valid for other accounts and other networks. | | `key_environment_mismatch` | 401 | no — fix the request | The key belongs to the other environment: `o4_test_` keys work only on the sandbox, `o4_live_` keys only in production. `details.base_url` is where this key works. | | `insufficient_scope` | 403 | no — fix the request | The key authenticated but lacks the scope this endpoint requires. `details.required_scope` names it; re-create the key with that scope. | | `reauthentication_required` | 403 | no — fix the request | The action (asking to delete the account) needs a sign-in from the last few minutes, and this app's sign-in is older. Run the sign-in again (a fresh OAuth authorization), then retry with the new token. | | `first_party_app_required` | 403 | no — fix the request | Only the OrbitFour app, signed in as a person, can call this endpoint (team management). API keys and third-party OAuth apps are refused by design: a credential that could invite members could grant itself a way back in. | | `insufficient_permission` | 403 | no — fix the request | The credential has the scope, but the person it acts for isn't allowed to do this in the account (the same role permissions as the web UI). `details.required_permission` or `details.required_role` names what's missing. | | `forbidden` | 403 | no — fix the request | The resource exists but belongs to another account. | | `account_suspended` | 403 | no — fix the request | The account is not active, so it cannot be charged or add a card. Contact support — retrying will not clear it. | | `quote_changed` | 409 | no — fix the request | The live price is above the quote's total, so nothing was charged. `details.quote` is a fresh quote (with its own quote_token) to approve instead. A lower live price proceeds at the lower price. | | `quote_expired` | 409 | no — fix the request | The quote_token is more than 10 minutes old. Ask for a fresh one with dry_run: true (or the MCP quote tool). | | `invalid_quote` | 422 | no — fix the request | The quote_token doesn't verify, or was issued for another account, credential, action, domain or term. | | `quote_required` | 422 | no — fix the request | A purchase over MCP needs a quote first: call quote_registration or quote_renewal and pass its quote_token and total. | | `mcp_balance_only` | 422 | no — fix the request | Purchases over MCP are paid from the prepaid balance only. `details.add_funds_url` is where a person adds funds. | | `premium_not_available_via_mcp` | 422 | no — fix the request | Premium names can't be bought over MCP: they need a person's one-time identity check. Buy it on orbitfour.com or over REST. | | `spend_limit_exceeded` | 422 | no — fix the request | The credential's daily spend limit (UTC day) would be exceeded; nothing was charged. `details.daily_limit` and `details.remaining_today` say how much is left, and `details.resets_at` when the day resets (the next midnight UTC). `details.exceeds_daily_limit` true means the total is over the whole limit, so it can't succeed under this credential until the limit is raised. An MCP credential with no limit set is capped at $100 a day; the account owner sets a key's limit on the API keys page (`details.limit_settings_url`). | | `transfer_ineligible` | 422 | no — fix the request | Checked with the registry before charging: the domain can't be transferred now. `details.reasons` is any of `locked` (ask the current registrar to unlock it), `pending_transfer`, `pending_delete`, `auth_code_rejected` (get a fresh code) or `not_registered`. Nothing was charged; fix the cause and retry. If the registry couldn't be asked, the transfer proceeds instead. | | `approval_required` | 403 | no — fix the request | The key requires approval for purchases: nothing was charged, and the purchase is parked for 30 minutes. It runs when someone who can edit billing approves it at `details.approval_url` (or in the app), even after the quote's 10 minutes, at no more than the quoted total. Poll `details.status_url` (GET /approvals/{id}; MCP: `get_purchase_approval`) no faster than `details.retry_after` seconds for its status and operation; sending the same purchase again returns the same approval. | | `key_requires_approval` | 403 | no — fix the request | The key requires approval for purchases, so it can't move money another way: it can't deposit, or turn auto top-up on or change it. Nothing was charged or changed. An account owner can do these on the balance page. Turning auto top-up off and changing only the low-balance alert still work. | | `approval_exists` | 409 | no — fix the request | One quote parks one approval: this `quote_token` already has a purchase waiting for approval, or approved, with different terms (`details.approval_id`, `details.status`). Approving that one runs its terms. Nothing was charged; to buy with these terms instead, get a fresh quote (`dry_run: true`, or MCP's quote tool). | | `too_many_pending_approvals` | 429 | yes — with backoff | The key already has 5 purchases waiting for approval; retry once some are decided or have lapsed (30 minutes). | | `approval_not_pending` | 409 | no — fix the request | The approval was already decided, or it expired; `details.status` says which. | | `approval_not_supported` | 403 | no — fix the request | Transfers can't be approved yet, so a key that requires approval for purchases can't start one. Start it on the web. | | `invalid_auto_top_up` | 422 | no — fix the request | `auto_top_up` needs a `low_balance_threshold` below its `target`, a `target` up to 100000.00 and a `monthly_limit` of at least the minimum deposit; the message names which. | | `threshold_out_of_range` | 422 | no — fix the request | `low_balance_threshold` must be a USD amount from 1.00 to 100000.00, or null to turn the alert off. | | `payment_method_not_allowed` | 422 | no — fix the request | `payment_method: "client_confirm"` is for the OrbitFour app, where the person holding the phone confirms the payment with Stripe PaymentSheet. API keys pay from the balance or a saved card. | | `client_payment_pending` | 429 | no — fix the request | The person already has an in-app purchase waiting for payment (`details.operation_id`). Confirm it, or cancel it with `POST /api/v1/operations/{id}/cancel`; unconfirmed purchases are released after 30 minutes. | | `not_awaiting_payment` | 409 | no — fix the request | Only an operation in `requires_payment` can be canceled, and only while its payment hasn't started authenticating or processing. | | `payment_canceled` | 409 | no — fix the request | The operation's in-app payment was canceled (by the app or after 30 minutes unconfirmed). Start a new operation. | | `demo_account` | 403 | no — fix the request | The account belongs to the App Store review demo sign-in, so paid operations stop before any charge, debit or registry call. Everything else works. | | `identity_verification_required` | 403 | no — fix the request | The domain is premium and the key's acting user has not completed identity verification. Verify once in the web UI (Settings), then retry. Nothing was charged. | | `no_acting_user` | 422 | no — fix the request | The key's creating user is no longer an active member of the account. Re-create the key from an active member. | | `not_found` | 404 | no — fix the request | No such resource on this account. | | `tld_not_found` | 404 | no — fix the request | OrbitFour does not sell that extension (unknown or disabled). GET /api/v1/pricing lists every TLD it sells. | | `bad_request` | 400 | no — fix the request | A query parameter could not be parsed (e.g. a non-ISO-8601 date filter). `message` names the offending value. | | `validation_failed` | 422 | no — fix the request | Request body or params failed schema validation. `details.errors` (schema violations) or `details` (field → messages) says which. | | `protected_record` | 422 | no — fix the request | A PATCH or DELETE of an NS record at `@`: those name the nameservers that answer for the domain. To move its DNS, change the nameservers with PUT /domains/{id}/nameservers. | | `names_required` | 400 | no — fix the request | GET /domains/check needs a comma-separated `names` query parameter. | | `too_many_names` | 400 | no — fix the request | GET /domains/check was given more names than one request allows. Split the batch. | | `query_required` | 400 | no — fix the request | GET /domains/search needs a non-empty `q` query parameter. | | `rate_limited` | 429 | yes — with backoff | Per-key (or per-IP, unauthenticated) request rate exceeded. Honor `Retry-After`; pace with the `x-ratelimit-*` headers on every response. | | `velocity_limited` | 429 | yes — with backoff | Too many CARD payment attempts for this account (shared anti-card-testing budget). Prepaid balance is not velocity-limited — use it for volume. | | `idempotency_key_in_flight` | 409 | yes — same Idempotency-Key only | A request with this Idempotency-Key is still executing. Retry the same key after a short delay to get the original outcome. | | `idempotency_key_reused` | 409 | no — fix the request | This Idempotency-Key was already used with a DIFFERENT payload. Use a fresh key, or resend the original payload byte-for-byte. | | `invalid_domain_name` | 422 | no — fix the request | `domain_name` is not a syntactically valid domain. | | `unsupported_tld` | 422 | no — fix the request | The TLD is not offered (or not offered for this operation). Check GET /api/v1/pricing for what is sellable. | | `invalid_years` | 422 | no — fix the request | `years` must be an integer 1–10. It is clamped UP to the TLD minimum; the response reports the term actually purchased. | | `invalid_auth_code` | 422 | no — fix the request | The transfer `auth_code` is missing or shorter than 8 characters. Get it from the losing registrar. | | `invalid_contact_role` | 422 | no — fix the request | `contacts` keys must be registrant, admin, technical, or billing. | | `contact_not_found` | 422 | no — fix the request | A referenced contact UUID does not exist on this account. | | `nexus_required` | 422 | no — fix the request | The TLD requires US Nexus eligibility data on the registrant contact. `details.missing_fields` lists what to set before retrying. | | `invalid_amount` | 422 | no — fix the request | `amount` must be a positive decimal string, e.g. "250.00". | | `domain_unavailable` | 422 | no — fix the request | The registry says the domain is not available to register. Nothing was charged. | | `domain_already_managed` | 422 | no — fix the request | The domain is already in an OrbitFour account, so it cannot be transferred in. | | `not_renewable` | 422 | no — fix the request | The domain's current status forbids renewal (e.g. mid-transfer, or past the renewal window into redemption). | | `not_priced` | 422 | no — fix the request | The TLD has no retail price configured for this operation. Contact support; retrying will not help. | | `invalid_max_price` | 422 | no — fix the request | max_price was not a positive decimal string in USD, such as "25.00". | | `max_price_exceeded` | 422 | no — fix the request | The operation's full charge is above the max_price sent, so nothing was charged. details.price is the charge; resend with a higher max_price to accept it. | | `premium_requires_review` | 422 | no — fix the request | The registry quoted a premium price above the automated cap. Contact support to buy it — the API will not charge it. | | `transfer_conflict` | 409 | no — fix the request | A previous transfer attempt for this domain is in a conflicting state (possibly in another account). Contact support. | | `duplicate_operation` | 409 | no — fix the request | An operation for this domain is already in flight. Poll `details.operation_id` when present instead of retrying; any duplicate charge is refunded automatically. | | `availability_unknown` | 502 | yes — with backoff | The registry did not answer the availability check. Nothing was charged — retry shortly. | | `price_unconfirmed` | 502 | yes — with backoff | The registry did not confirm the price (premium fee check failed). Nothing was charged — retry shortly. Deliberately fails closed. | | `registry_unavailable` | 502 | yes — with backoff | The registry did not answer, or did not confirm the change. Retry shortly; `GET /api/v1/domains/{id}` shows the state we hold. For the transfer code the request can fail closed, when the registry's answer doesn't show the lock state. | | `privacy_not_available` | 422 | no — fix the request | The domain's TLD doesn't allow WHOIS privacy (`.us` and other nexus TLDs). `whois_privacy.supported` in `GET /api/v1/pricing/{tld}` says so ahead of time. | | `already_member` | 409 | no — fix the request | The invited address already belongs to a member of this account. Nothing was sent. | | `invitation_rate_limited` | 429 | yes — with backoff | An invitation was sent to this address within the last minute. Wait a minute before sending another. | | `cannot_remove_owner` | 409 | no — fix the request | Account owners can't be removed from the account. Ownership changes go through support. | | `cannot_remove_self` | 409 | no — fix the request | Nobody can remove themselves from an account. Another owner or admin can. | | `permissions_beyond_role` | 422 | no — fix the request | A permission was sent as true that the member's role doesn't grant (`details.permissions`). A role is the ceiling: permissions can only be switched off below it. Change the role instead. | | `endpoint_limit_reached` | 422 | no — fix the request | The account already has the most webhook endpoints allowed (10). Remove one, or subscribe an existing endpoint to more event types. | | `invalid_auto_renew_term` | 422 | no — fix the request | An auto-renew term isn't one of the choices the domain page offers for this domain, usually `auto_renew_years` below the TLD's minimum term. `details.field` names it and `details.allowed` lists the choices. Nothing was changed. | | `invalid_nameservers` | 422 | no — fix the request | The nameserver set is empty, too long, or has entries that aren't hostnames (`details.nameservers` lists them). Nothing was sent to the registry. | | `nameserver_rejected` | 422 | no — fix the request | The registry refused to create the nameserver host in `details.nameserver`, so the set was not changed. | | `domain_locked` | 409 | no — fix the request | The domain is locked against transfer, so its transfer code can't be read or replaced. Unlock it first (`POST /api/v1/domains/{id}/unlock`, scope `domains:transfer`). | | `pending_transfer` | 409 | no — fix the request | A transfer of the domain is pending at the registry, which refuses lock changes and a new code meanwhile. Approve or reject it, or wait for it to finish. | | `pending_delete` | 409 | no — fix the request | The domain is pending deletion at the registry and can't be changed. | | `registry_lock` | 409 | no — fix the request | The registry holds a server lock (serverTransferProhibited or serverUpdateProhibited) that only the registry lifts. Contact support. | | `protective_lock_active` | 409 | no — fix the request | The domain moved to OrbitFour recently and a protective lock holds it until `transfer_lock.protective_lock_until` (`GET /api/v1/domains/{id}`). It can't be unlocked before then without support. | | `within_transfer_lock` | 409 | no — fix the request | The domain is inside its 60-day hold after registration or a transfer in (`transfer_lock.days_until_transferable`), so its transfer code can't be replaced yet. Reading it is allowed. | | `no_pending_transfer` | 409 | no — fix the request | There is no transfer out to approve or reject: none was requested, it already finished, or the registry no longer has it. | | `insufficient_balance` | 402 | no — fix the request | `payment_method: "balance"` was requested and the prepaid balance does not cover the operation. Deposit first, or omit to allow card fallback. | | `no_default_payment_method` | 422 | no — fix the request | Neither prepaid balance nor a default card can cover this. Deposit funds, or add a card (POST /payment_methods/setup_intents; `details.add_card_url` is the web page). | | `payment_method_not_found` | 404 | no — fix the request | No card matches the `payment_method` reference given. | | `ambiguous_payment_method` | 409 | no — fix the request | Several cards share that last-four. `details.candidates` lists their UUIDs — resend with one. | | `payment_method_limit_reached` | 422 | no — fix the request | The account already has the maximum number of saved cards (10). Remove one (DELETE /payment_methods/:id) before adding another. | | `missing_billing_email` | 422 | no — fix the request | The account has no email or billing email, so no payment-provider customer can be created for a card. Set one in the web UI, then retry. | | `card_setup_limited` | 429 | yes — with backoff | Too many card setups for this account or IP within the hour (an anti-card-testing brake, separate from velocity_limited). Retry later. | | `setup_not_found` | 404 | no — fix the request | No card setup with that `setup_intent_id` exists on this account. A SetupIntent from another account looks the same as one that doesn't exist. | | `setup_incomplete` | 409 | yes — with backoff | The SetupIntent has not been confirmed with Stripe yet; `details.status` is its current state. Finish the client-side confirmation, then retry. | | `setup_canceled` | 422 | no — fix the request | The SetupIntent was canceled, or its card has since been removed. Start a new setup. | | `payment_method_detach_refused` | 422 | no — fix the request | The payment provider refused to detach the card (`message` says why), so it was not removed. Retrying won't change that; contact support. | | `payment_provider_unavailable` | 502 | yes — with backoff | The payment provider could not be reached while adding, registering, or removing a card. No card was charged. Retry shortly. | | `payment_method_expired` | 422 | no — fix the request | The selected card is past its expiry date. | | `payment_failed` | 402 | no — fix the request | The card was declined. `message` carries the normalized decline reason. Nothing was charged. | | `authentication_required` | 402 | no — fix the request | The card demands 3-D Secure, which an off-session charge can't complete. Fund the prepaid balance and pay from there, complete it in the web UI (`details.add_card_url`), or add the card via POST /payment_methods/setup_intents so it's authenticated for later charges. | | `payment_in_progress` | 409 | yes — with backoff | Another payment for this same operation is already settling. Wait and retry, or poll for the resulting operation. | | `settlement_failed` | 422 | yes — with backoff | The charge or balance debit could not be committed. Nothing is left half-paid. Retry; if it persists, contact support. | | `below_minimum_deposit` | 422 | no — fix the request | Deposits must be at least $5.00 USD. | | `deposit_failed` | 422 | no — fix the request | The deposit could not be started (e.g. the card was rejected before capture). Nothing was charged. | | `deposit_pending_reconciliation` | 502 | yes — same Idempotency-Key only | The card WAS charged but the ledger credit has not committed yet. It reconciles automatically. Retry the same Idempotency-Key to check — do NOT submit a new deposit. | | `pdf_generation_failed` | 502 | yes — with backoff | The invoice PDF could not be rendered. Retry later or use the JSON representation. | | `pdf_generation_busy` | 503 | yes — with backoff | Too many invoice PDFs are rendering at once. Honor the Retry-After header and retry in a few seconds. | | `pdf_generation_unavailable` | 503 | no — fix the request | PDF rendering is not installed on this server — a deployment defect, not a transient. Use the JSON representation and report it. | | `operation_failed` | 500 | yes — same Idempotency-Key only | An unmapped server-side failure; the detail is logged, not returned. Whether anything was charged is unknown — retry with the same Idempotency-Key and expect duplicate_operation if the first attempt did land. | ## MCP server The unauthenticated check and search are also served as Model Context Protocol tools at `https://www.orbitfour.com/mcp`: Streamable HTTP, JSON-RPC 2.0 over POST, with no session and no SSE stream (GET and DELETE answer 405). It speaks protocol 2026-07-28 (per-request `_meta`, `server/discover`, mirrored `MCP-Protocol-Version`/`Mcp-Method`/`Mcp-Name` headers) and the `initialize`-handshake revisions 2025-03-26 through 2025-11-25. Every tool declares an `outputSchema` and explicit read-only annotations. Client setup for Claude, Cursor and others: `https://www.orbitfour.com/developers/mcp`. - `check_domain`: Check domain availability and price. Parameters: `names` (required), `years`. - `search_domains`: Search for available domain names. Parameters: `query` (required), `include_premium`, `limit`, `max_price`, `tlds`, `years`. - `get_tld_requirements`: Get an extension's prices and registration rules. Parameters: `tlds` (required). - `check_transfer`: Check whether domains can be transferred in. Parameters: `names` (required). `check_domain` returns the `GET /api/v1/domains/check` payload (at most 10 names per call), `search_domains` the `GET /api/v1/domains/search` payload, `get_tld_requirements` one `GET /api/v1/pricing/{tld}` entry per extension (at most 10), and `check_transfer` the `GET /api/v1/transfers/check` payload (at most 5). Each result comes back twice: in `structuredContent`, and JSON-encoded as the text content. Requests share the per-IP rate limit of the unauthenticated REST endpoints. Bad input (a blank query, too many names) comes back in-band as `isError: true`, not as a JSON-RPC error. The tools are read-only. ### Account MCP server `https://www.orbitfour.com/mcp/account` works with one account through an API key sent as `Authorization: Bearer o4_live_...`, or an OAuth token for a hosted client (see "Hosted MCP clients" above). `tools/list` answers without a key and names the scope each tool needs; with a key it lists only the tools the key's scopes allow. Every `tools/call` needs the key (401 with `WWW-Authenticate: Bearer` otherwise; inside a legacy batch that call is a `-32001` error item and the batch stays 200), the public tools included. Each account tool runs exactly one REST operation through its own controller, so arguments are that operation's path, query and body fields, results are its JSON, failures come back as `isError: true` with the REST error envelope as the text, and the call is audited and rate limited like the REST call. Write tools take an optional `idempotency_key`, shared with the REST `Idempotency-Key`. - `whoami`: Show the credential and its account. `GET /api/v1/me`, scope `any`. - `list_domains`: List the account's domains. `GET /api/v1/domains`, scope `domains:read`. - `get_domain`: Get one domain. `GET /api/v1/domains/:id`, scope `domains:read`. - `get_operation`: Get a paid operation's status. `GET /api/v1/operations/:id`, scope `domains:read`. - `get_purchase_approval`: Get a purchase approval's status. `GET /api/v1/approvals/:id`, scope `billing:write`. - `list_dns_records`: List a domain's DNS records. `GET /api/v1/domains/:domain_id/dns`, scope `dns:read`. - `create_dns_record`: Add a DNS record. `POST /api/v1/domains/:domain_id/dns`, scope `dns:write`. - `update_dns_record`: Change a DNS record. `PATCH /api/v1/domains/:domain_id/dns/:id`, scope `dns:write`. - `delete_dns_record`: Delete a DNS record. `DELETE /api/v1/domains/:domain_id/dns/:id`, scope `dns:write`. - `list_contacts`: List the account's contacts. `GET /api/v1/contacts`, scope `contacts:read`. - `get_contact`: Get one contact. `GET /api/v1/contacts/:id`, scope `contacts:read`. - `get_balance`: Get the prepaid balance. `GET /api/v1/balance`, scope `account:read`. - `list_transactions`: List billing transactions. `GET /api/v1/transactions`, scope `account:read`. - `list_ledger_entries`: List prepaid balance movements. `GET /api/v1/balance/ledger`, scope `account:read`. - `list_invoices`: List invoices. `GET /api/v1/invoices`, scope `account:read`. - `update_domain_settings`: Change auto-renew, its terms, or privacy. `PATCH /api/v1/domains/:id/settings`, scope `domains:write`. - `set_nameservers`: Replace a domain's nameservers. `PUT /api/v1/domains/:id/nameservers`, scope `domains:write`. - `lock_domain`: Lock a domain against transfer. `POST /api/v1/domains/:id/lock`, scope `domains:write`. - `list_events`: List the account's recent events. `GET /api/v1/events`, scope `account:read`. - `list_watchlist`: List the watchlist. `GET /api/v1/watchlist`, scope `domains:read`. - `add_to_watchlist`: Watch a domain name. `POST /api/v1/watchlist`, scope `domains:write`. - `remove_from_watchlist`: Stop watching a domain name. `DELETE /api/v1/watchlist/:id`, scope `domains:write`. - `quote_registration`: Quote a domain registration. `POST /api/v1/domains`, scope `domains:write billing:write`. - `register_domain`: Register a domain (spends the balance). `POST /api/v1/domains`, scope `domains:write billing:write`. - `quote_renewal`: Quote a domain renewal. `POST /api/v1/domains/:id/renew`, scope `domains:write billing:write`. - `renew_domain`: Renew a domain (spends the balance). `POST /api/v1/domains/:id/renew`, scope `domains:write billing:write`. - `list_webhook_endpoints`: List webhook endpoints. `GET /api/v1/webhook_endpoints`, scope `account:read`. - `list_webhook_deliveries`: List a webhook endpoint's deliveries. `GET /api/v1/webhook_endpoints/:id/deliveries`, scope `account:read`. Buying over MCP is quote, approve, commit. `quote_registration` or `quote_renewal` returns the full total, a one-line `summary` and a `quote_token` (10 minutes). Show the person the summary, then call `register_domain` or `renew_domain` with the token, its exact `expected_total` and `currency` (and, to register, an explicit `auto_renew`). The domain, term and registrant come from the token: pass `contact_id` to `quote_registration` to choose who will own the domain (required, with nexus data, for .us). Purchases are paid from the prepaid balance only, never premium names, capped per key per day ($100 unless the owner set a limit), and the account owners are emailed about each one. The same token twice returns the first operation. Transfers, deposits, unlocking and transfer codes stay on the REST API, so a transfer code never passes through a model's context. ## Response conventions - **Authenticated** reads are `{"data": ...}`; list endpoints add `{"meta": {...}}` with pagination. - **Unauthenticated** endpoints predate that convention and each have their own top-level key — do NOT expect `data` from them: - `GET /domains/check` → `{"domains": [...]}` - `GET /domains/search` → `{"query": ..., "top_picks": [...], "count": N, "suggestions": [...], "unchecked": N, "unchecked_names": [...], "left_out": {...}, "tlds": [...] | null}`: `unchecked_names` are the names whose check came back unknown (check them again), `left_out` counts why the other names checked aren't suggested, and with `tlds` each extension asked for says how many names it found and, at zero, why (`unsupported`, `over_max_price`, `unchecked`, ...). `max_price` is compared with `first_term.total`, the ICANN fee included - A check entry (and each top pick and suggestion) carries `purchasable`, `purchase_blocker` (`premium_over_cap`, `price_unconfirmed`, `availability_unknown` when `available` is null), `icann_fee` and `first_term` (`years`, `total`: what the purchase charges); a name that isn't available carries `reason`, one of `taken`, `reserved`, `unavailable` or `unknown` (the check failed: not the same as taken). That set won't grow. - `GET /pricing` → `{"registrar": {...}, "currency": "USD", "count": N, "generated_at": "...", "constraints": {...}, "tlds": [...]}`, with flat decimal-string prices in `currency`, not money objects - Pagination is `?page=` / `?per_page=` (default 50, max 100). - Identifiers are UUIDs. - Timestamps are ISO 8601 UTC, to the whole second (`2026-10-08T14:03:00Z`). Lists come back in a defined order (newest first unless noted), and that order is authoritative: two entries in the same second keep it, so don't re-sort by timestamp. - Monetary values are `{"amount": "", "currency": "USD"}`, or `null` when unpriced. - `POST` of a paid operation returns `202 Accepted` with an operation id; poll it. - `POST /balance/deposits` is the one synchronous money path — `201` means credited. A `202` means the request was accepted and settlement was attempted — in the common case the money committed and fulfillment is queued. Two branches are weaker than that: an asynchronously-capturing card leaves the operation in `pending_payment` until the capture settles, and a charge whose fulfillment could not be scheduled is auto-refunded. Both are visible in the operation's `status`, which is why polling is mandatory rather than optional. ### Compatibility for generated clients The OpenAPI spec is fit for client generators (Swift, Kotlin, TypeScript): every operation has a stable `operationId` (`registerDomain`, `listDomains`, ...), pagination and money are named components (`PaginationMeta`, `Money`; `/pricing` is the exception, with flat decimal strings), and every operation declares the error envelope as its `default` response. Within `/api/v1` changes are additive only: - Fields and endpoints are added, never removed, renamed or retyped, and an `operationId` never changes. A breaking change would ship as `/api/v2`. - **Enum values do get added**: a new operation status, transaction type or error `code`. Decode an unknown value as a fallback case rather than failing; Swift and Kotlin generators make enums closed by default, so a shipped app that rejects unknown values breaks when the server adds one. - Ignore response fields you don't recognize. ### `/domains/check` per-name results A `200` does not mean every name resolved. An entry that failed comes back as `{"name": ..., "error": ...}` with **no** `available` field, so branch on the presence of `error` before reading `available`. Price fields are `null` when we have no price for the name. `min_years` on each entry is the smallest purchasable term (2 for `.ai`) — multiply the per-year price by it for the true minimum. The batch is capped at 10 names per request; over that you get `400 too_many_names`. `pricing_source` is `"registry"` (quoted live for this name), `"baseline"` (the extension's standard rate, final for this name: the extension has no premium names, or the registry's live check returned no premium price for it) or `"unconfirmed"` (the lookup did not complete; do not trust it for a name that may be premium). It describes REGISTRATION only. `pricing_sources` breaks the same values out per action as `{"registration", "renewal", "transfer"}`, and they can legitimately differ on one name — a registry may quote a registration and not a transfer. ## Getting started safely Build against the sandbox before spending money: 1. Sign up at `https://sandbox.orbitfour.com/register`, add test card `4242 4242 4242 4242`, and create an `o4_test_` key with every scope. 2. Run each workflow above end to end there: register, poll the operation, manage DNS, deposit. Prove your retry and idempotency handling on it. For the errors the sandbox can't produce (premium quotes, `max_price` against a premium, `identity_verification_required`; see "What the sandbox doesn't simulate"), test those branches with fixtures or mocks built from the error catalog below. 3. In production, start with a **read-only** key (`domains:read`, `dns:read`, `account:read`) and walk `GET /me`, `GET /domains`, `GET /balance`. 4. Only then add `domains:write` + `billing:write`. To cap what a runaway loop can spend, send `"payment_method": "balance"` on paid operations and keep the balance small. Without it, an operation the balance can't cover falls back to the default card, so the balance is no limit at all. ## Support Ask a question or report an API problem: `https://www.orbitfour.com/contact`.