Authentication
Two credentials, and the ways a request is refused.
The header
The base URL is api.openemail.uk. Every request carries a workspace key, or the OAuth access token of an app a person connected, in an Authorization header.
Authorization: Bearer oe_live_9f2c1a4b7e05d3862c1f0a44_kX7…Nothing else authenticates here. A session cookie and a session token are both refused with invalid_credential_type, which names the credentials it accepts rather than leaving you to guess at a bare 401.
Check a key works
GET /ping is the smoke test: it needs no scope and tells you what the key is.
curl "$OE/ping" -H "$AUTH"{ "ok": true, "keyId": "4c1b257a66287fd113bd89d0", "mode": "live", "scopes": ["emails:send", "emails:read"], "roleId": null, "grantedScopes": ["emails:send", "emails:read"], "workspaceId": "10417196-e324-4283-af98-66ec62167c47"}If this works and something else 401s, the problem is the scope, not the key.
scopes is the EFFECTIVE list and the only one that authorises anything. grantedScopes is what the key was issued with, and the two differ only when a role is capping the key. The Scopes page explains that intersection. A roleId of null means no ceiling, which is the widest a key gets.
See what a key may send as
GET /addresses is the answer to a 403 you did not expect.
curl "$OE/addresses" -H "$AUTH"{ "object": "list", "unrestricted": false, "data": [ { "object": "address", "address": "[email protected]", "enabled": true, "canSend": true }, { "object": "address", "address": "[email protected]", "enabled": true, "canSend": false } ], "domains": [ { "domain": "acme.com", "receivingVerified": true, "sendingVerified": true, "catchAll": false } ]}canSend: false has three causes: the address is switched off, the key's send scope leaves it out (neither its domain nor the address itself is on the key), or the domain cannot sign yet. enabled on the address and sendingVerified on its domain tell those apart, and that is most of the debugging time this endpoint saves. A domain can be verified for receiving and still unable to send.
unrestricted: true means any local-part on a verified domain is accepted, including ones nobody has created yet.
How a key is refused
| Code | Means |
|---|---|
| missing_api_key | No Authorization header at all. |
| invalid_credential_type | Neither an API key nor the OAuth access token of an app you connected. A cookie, a session token, any other token starting oe_ and a bearer longer than 512 characters all end up here. |
| invalid_api_key | Not a key we issued, or the secret does not match. |
| revoked_api_key | Issued here, then revoked. Distinct on purpose. It is the difference between a five-minute fix and an afternoon. |
| expired_api_key | Issued here, then expired. |
| insufficient_scope | A real key, without the scope this endpoint needs. |
| invalid_access_token | An OAuth access token whose app is gone or switched off. |
| expired_access_token | An OAuth access token past its expiry. Refresh it. |
| grant_required | An OAuth token for an app with no approval under the current rules. Send the person through authorization again. |
| grant_expired | The approval the person gave the app has run out. |
| grant_workspace_gone | The workspace the app was approved for no longer exists. |
| grant_access_lost | The person no longer has access to the approved workspace. |
| grant_no_reach | A member’s app whose approval reaches none of the addresses that member still holds. |
| mailbox_login_forbidden | A password sign-in for one address, which cannot connect apps. |
| owner_only | An OAuth token of someone who does not own the workspace, on an operation only the owner may do, such as managing API keys. Managing webhooks answers the same to a member whose role does not reach every address. |
| api_key_only | An OAuth token on an operation only a key can do, such as POST /keys/self/rotate. |
| step_up_required | An OAuth token on a sensitive operation, such as deleting a domain, before the app has verified a code in the last 60 minutes. An API key never gets it. Verification codes, below, has the list and the two calls that clear it. |
| ip_not_allowed | A key with an IP allowlist, called from an address outside it. Send the call from one of those addresses or change the list. |
Revoking takes effect on the next call. The row stays on the keys page afterwards, so you can still tell whether anything was using the key when you killed it. The most useful state on that screen is "never used", because it is how a leaked key is told from a live dependency.
Rotating is the other way to retire a secret. It mints a new secret for the same key, so the id, the name, the scopes, the role, the send scope and every request and activity row all carry on; only the secret changes. The old one stops working the instant the rotation completes, with no overlap window, and the replacement is shown once. From the console it is in the same menu as Revoke and asks you to re-verify first. A key holding keys:write can also rotate itself with POST /keys/self/rotate, which is how an integration rotates on a schedule without anyone opening the console.
Other keys can be managed over the API too, behind scopes no key holds unless somebody gave it one: keys:read lists keys and reads their request log and activity, and keys:manage creates, changes, rotates, revokes and deletes them, never wider than the key making the call. The API keys page has the rules.
A key can be held to the places it is used from. ipAllowlist on POST /keys and PATCH /keys/{id} takes up to 50 IP addresses and ranges, such as 203.0.113.7 or 2001:db8::/32, and every call or SMTP sign-in from anywhere else is refused with 403 ip_not_allowed. An empty list lets the key work from anywhere again. A key cannot change its own list, and a key with a list can only give the keys it makes a list inside its own. Set it in the console on the page of the key, under Edit.
OAuth access tokens
An app a person connected over OAuth receives an access token, and the same token authenticates here. It is held to the approval the person gave: one workspace, the permissions they chose intersected with their role, and any domains or addresses they picked. A member’s token reaches only the addresses that member reaches, the ones they were granted or every address when their role holds addresses:all, and sends only from the ones they may send from.
GET /ping answers "kind": "oauth" and the app’s clientId for one. An Idempotency-Key is honoured per person. Calls made with a token are not in the per-key request log: they are in the log of the app, which the person reads under Connected apps or with GET /account/connected-apps/{clientId}/requests. Operations that belong to a key or to the owner, such as rotating a key or managing API keys, are refused for anyone else with api_key_only or owner_only, and so is managing webhooks for a member whose role does not reach every address.
Verification codes
The web app asks a person for a verification code before a change that could lock them out or leak their mail, and an app acting for them with an OAuth access token is asked the same thing. Until the app has verified a code, the operations below answer 403 step_up_required and change nothing. One verified code unlocks all of them for that app for 60 minutes. An API key is never asked for a code.
| Operation | MCP tool | Asks for a code |
|---|---|---|
| POST /webhooks, PATCH /webhooks/{id} | createWebhook, updateWebhook | Always |
| POST /rules, PATCH /rules/{id} | createRule, setRuleEnabled, updateRule | Always |
| PATCH /roles/{id}, DELETE /roles/{id} | updateRole, deleteRole | Always |
| POST /members, PATCH /members/{userId}, DELETE /members/{userId} | inviteMember, setMemberRole, removeMember | Always |
| POST /members/{userId}/addresses, DELETE /members/{userId}/addresses/{addressId} | grantMemberAddress, revokeMemberAddress | Always |
| POST /members/{userId}/domains, DELETE /members/{userId}/domains/{domainId} | grantMemberDomain, revokeMemberDomain | Always |
| DELETE /domains/{id}, DELETE /domains/{id}/addresses/{addressId} | removeDomain, removeDomainAddress | Always |
| PATCH /domains/{id}/addresses/{addressId} | setAddressForwardMode, updateDomainAddress | When it sets destination |
| POST /domains/{id}/addresses/{addressId}/forwards, PATCH /domains/{id}/addresses/{addressId}/forwards/{forwardId}, DELETE /domains/{id}/addresses/{addressId}/forwards/{forwardId}, POST /domains/{id}/addresses/{addressId}/forwards/{forwardId}/resend | addAddressForwards, setAddressForwardEnabled, removeAddressForward, resendAddressForwardConsent | Always |
| PUT /domains/{id}/addresses/{addressId}/login | None. A password never goes through MCP. | Always |
| POST /domains/{id}/addresses/{addressId}/app-passwords | createAppPassword | Always |
| POST /domains/{id}/addresses/{addressId}/mail-app-setup | sendMailAppSetup | Always |
| PUT /domains/{id}/dns, POST /domains/{id}/dns/sync | setDomainDnsZone, syncDomainDns | Always |
| DELETE /dns-connections/{id} | disconnectDnsConnection | Always |
| DELETE /audiences/{id} | deleteAudience | For an audience you created. The built-in default audience cannot be deleted at all. |
| PUT /app-host | setAppHost | When it replaces a host the workspace already has |
| DELETE /app-host | removeAppHost | Always |
| DELETE /forms/{id} | deleteForm | Always |
| POST /encryption/keys | publishEncryptionKey | Always |
| POST /exports, GET /exports/{id}/content | startExport | Always |
| PATCH /billing/pay-as-you-go, PUT /billing/pay-as-you-go/limit, POST /billing/pay-as-you-go/confirm | setPayAsYouGo, setPayAsYouGoLimit, confirmPayAsYouGo | Always |
| POST /billing/checkout, POST /billing/portal | startPlanCheckout, openBillingPortal | Always |
| PUT /billing/invoices/{orderId}/details | saveBillingDetails | Always |
| DELETE /workspaces/{id} | deleteWorkspace | Always |
| POST /billing/add-ons/checkout, DELETE /billing/add-ons/{addOn} | startAddOnCheckout, cancelAddOn | Always |
| POST /sending/streams/{stream}/resume | resumeSendingStream | Always |
| DELETE /automations/{id} | deleteAutomation | Always |
The check runs after the scope, the request body and the resource itself have been checked, so a 422 or a 404 still means what it says, and before anything is written.
- Ask for a code with
POST /security/step-up.methodsays where it comes from.emailmeans a six-digit code has just gone to the address the person signs in with, shown masked insentTo.totpmeans they have two-factor sign-in on, so they read the code from their authenticator app or use one of their backup codes, and nothing is sent. - Ask the person for the code and send it to
POST /security/step-up/verifyas{ "code": "…" }. - Repeat the call that was refused.
elevatedUntilin the answer says when the app will be asked again.
curl -X POST "$OE/security/step-up" -H "$AUTH" { "object": "step_up_challenge", "method": "email", "sentTo": "a•••@acme.com", "reused": false}curl -X POST "$OE/security/step-up/verify" -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "code": "482913" }' { "object": "step_up", "elevated": true, "elevatedUntil": "2026-09-26T11:42:07.000Z"}GET /security/step-up says where the app stands without sending anything: elevated, elevatedUntil, the method the next code will use, and minutes, the length of the window. Check it before a run of changes, so the person is asked once at the start rather than halfway through. None of the three calls needs a scope, and an API key calling any of them gets 400 step_up_not_applicable.
- An emailed code works for 10 minutes. Asking again while one is waiting and still has tries left reuses it, sends nothing and answers
reused: true. Send{ "resend": true }for a fresh code, which stops the waiting one from working. - A wrong code is
400step_up_code_invalid, and the message says how many tries are left. The fifth wrong code is429step_up_locked, and so is every try after it. A locked code is never reused, so a plainPOST /security/step-upstarts a fresh one, with noresendneeded. - An expired code, or a verify with no code waiting, is
400step_up_code_expired. Ask for a new one withPOST /security/step-up. - Each app has a budget of its own with each person: 5 codes an hour and 20 in 24 hours. The next request is
429step_up_throttled, and the message says when to try again. A reused code does not count, and neither does one whose email could not be sent. The person’s other apps and the web app, which allows 10 codes an hour, never use it up. When the email cannot be sent the answer is503step_up_undeliverable, and no code is left waiting. - The 60 minutes belong to the person and this app together. Another app they connected is asked on its own, and revoking the app, or removing it in Account → Connected apps, ends the window at once. The person can also start the 60 minutes there with no code from the app: they choose Allow changes for 60 minutes in the app’s menu, after verifying on the website themselves, and End allowed changes stops them early.
- Wrong codes also count against the app for 24 hours, across every code it asked for, and a correct code does not clear them. The tenth pauses verification for that app: asking for a code and verifying one both answer
429step_up_locked, with a message that says it is paused until a given time, and until then even a correct code is refused. The person gets one email about it that names the app. Wrong codes from all of the person’s apps also count together: 20 in 24 hours pause verification the same way for every app they connected, even one that sent none of them, and removing an app does not lift that pause. Codes entered on the website count toward neither pause, and the person can still verify there. - An authenticator code is accepted once. Entering one that was already accepted, by any app or in the web app, counts as a wrong code, so wait for the next one.
- The email with the code says a connected app asked for it, and shows the name the app registered with, its client ID, when the person connected it and, when it was refused in the last 15 minutes, the change it last asked to make. It also says that the code unlocks more than that one change: for 60 minutes the app can make any change on the list above that the person gave it access to. The name is chosen by whoever registered the app, so it is never proof of who is asking.
The MCP server asks the same. Each tool in the table makes the change of the operation beside it, and until the app has verified a code it answers with an ordinary tool result whose text starts Refused (step_up_required):, and changes nothing. The app verifies a code with the same access token, through the calls above, and calls the tool again. A client that cannot make those calls asks the person to choose Allow changes for 60 minutes on it in Account → Connected apps, as the refusal says. REST and the tools share the 60 minutes, so one code covers both. emptyAudience, like POST /audiences/{id}/empty, never asks.
{ "content": [ { "type": "text", "text": "Refused (step_up_required): This action needs a verification code. Ask for one with POST /security/step-up, send it to POST /security/step-up/verify with the same access token, then call the tool again. If you cannot make those calls, ask the person to choose Allow changes for 60 minutes on this app in Account settings, Connected apps, on the Nextmail website, then call the tool again." } ]}