Using the Admin API
The Admin API is a JSON-over-HTTP RPC API served by the admin-api Lambda. Every operation is a POST request with a JSON body; there are no GET, PUT, or DELETE routes.
Endpoint URL
Section titled “Endpoint URL”The Admin API is served at api.<your mail domain>. The ACM certificate and Route 53 record are created for you during deployment. The exact URL for your deployment is published as the adminApiUrl output — open your stack’s Outputs tab in the CloudFormation console to read it.
Your mail domain is derived from the Route 53 hosted zone you selected when you launched the stack. If you set the optional subdomainNamespace parameter, that label is inserted between the service name and the base domain:
Hosted zone / subdomainNamespace | Admin API endpoint |
|---|---|
example.com, namespace left empty | https://api.example.com |
example.com, namespace mailbox | https://api.mailbox.example.com |
Authentication
Section titled “Authentication”The Admin API requires a Bearer JWT issued by your deployment’s Cognito user pool — the same pool that backs Admin UI sign-in. The token must carry the adminApi audience and the ADMIN permission scope.
Machine-to-machine (headless) access
Section titled “Machine-to-machine (headless) access”Provisioning the client (CDK deployments)
Section titled “Provisioning the client (CDK deployments)”Machine-to-machine access requires a deployment built with the ServerlessInbox CDK constructs. A headless app client is not part of a template install — it exists only where the CDK app asks for one explicitly, by calling provisionHeadlessClient({ name, audiences, permissions }) on the MailboxUserPool construct. That call creates a dedicated client_credentials app client and registers it as an accepted audience on the APIs listed in audiences; it returns the client’s ID, its generated client secret as a SecretValue, and the scope strings the client may request.
Where those values go is the deployment’s own choice — the constructs do not store them anywhere. The recommended handling is for the CDK app to put the secret straight into AWS Secrets Manager and never surface it any other way:
const client = userPool.provisionHeadlessClient({ name: 'reporting', audiences: ['adminApi'], permissions: [Permission.ADMIN],});
// clientSecret is set for headless (client_credentials) clients.new secretsmanager.Secret(this, 'ReportingClientSecret', { secretName: 'serverlessinbox/headless/reporting', secretStringValue: client.clientSecret!,});Do not publish the secret as a CfnOutput and do not pass it into a construct property that lands verbatim in the synthesized template: a stack output and a plaintext template value are both readable by anyone with read access to the stack.
The client ID is not a secret and may be published as a stack output or hard-coded in the consumer’s configuration.
Using the client (any consumer)
Section titled “Using the client (any consumer)”The flow itself is standard OAuth 2.0 client_credentials. The client must carry the adminApi audience and the ADMIN permission scope.
Read the secret at run time from Secrets Manager rather than pasting it into the command, and authenticate to the token endpoint with HTTP Basic auth (client_secret_basic) so the secret is never in the POST body:
# 1 — Fetch the client secret at run time; never paste it into the command.CLIENT_ID=<headless-client-id>CLIENT_SECRET=$(aws secretsmanager get-secret-value \ --secret-id serverlessinbox/headless/reporting \ --query SecretString --output text)
# 2 — Obtain a token. --user sends Basic auth, keeping the secret out of the body.# The exact scope string depends on your IDP plugin configuration.TOKEN=$(curl -s -X POST \ --user "$CLIENT_ID:$CLIENT_SECRET" \ "https://<cognito-domain>.auth.<region>.amazoncognito.com/oauth2/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "scope=<admin-scope>" \ | jq -r '.access_token')unset CLIENT_SECRET
# 3 — Use the token in every Admin API callcurl -s -X POST "https://api.mail.example.com/domains/ListDomains" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{}'Never echo, log, or set -x over the secret or the token. Note that a process’s arguments are visible to other users on the host, so --user still exposes the secret on a shared machine — pass it on stdin with curl --config - there, or use an SDK that keeps it in memory.
The access token is short-lived (Cognito returns its lifetime in expires_in). Cache it in memory and reuse it until it is close to expiry rather than requesting a new token per call.
User-context access
Section titled “User-context access”Admin console UIs use the authorization_code flow. A short-lived access token is obtained via the Cognito hosted UI and sent in the same Authorization: Bearer header.
Authorization
Section titled “Authorization”Every Admin API method requires an admin principal. The caller’s authenticated principal must carry admin privileges (IsAdmin); this applies to all services and methods, including read-only operations. There is no method — not even listing domains or reading the audit log — that a non-admin principal may call.
Webmail mailbox users are issued tokens by the same Cognito user pool, so a token can authenticate successfully yet still belong to a non-admin user. Such a request is authenticated (401 does not apply) but is rejected with HTTP 403 Forbidden on every endpoint.
This is enforced centrally at the request registry, not per handler: a default-deny admin gate runs for every RPC before its handler executes, so an endpoint that omits an explicit check still returns 403 rather than exposing a privileged operation. A method is reachable by a non-admin principal only if it is explicitly allow-listed at registration time, and no method is allow-listed in the current version.
The admin gate runs immediately after the token is authenticated and before the request body is parsed or validated, so an unauthenticated or non-admin caller never receives schema or validation feedback.
Behavioural change. Earlier releases enforced admin access only in a handful of handlers. Operations for domains, suppression, email-identities, audit, change-requests, mail-feedback, and setup — as well as user reads — were previously reachable by any authenticated (including non-admin) mailbox user. They are now admin-only. Non-admin clients that relied on these endpoints will receive 403 Forbidden.
Request format
Section titled “Request format”Every method call follows the same pattern:
POST /<service>/<MethodName>Authorization: Bearer <token>Content-Type: application/json
{ ...request fields... }The service path and method name map directly to the protobuf service definitions in mailbox-idl/admin-api/proto/. For example, DomainsService.ListDomains is called at:
POST /domains/ListDomainsResponse format
Section titled “Response format”Successful responses return HTTP 200 with a JSON body containing the response fields defined in the IDL. There is no envelope wrapper — the response is the message directly.
Errors return a non-2xx status code with a JSON body:
{ "code": "not_found", "message": "Domain 'example.com' does not exist"}Common status codes:
| Code | Meaning |
|---|---|
200 | Success |
400 | Validation error — check the message field |
401 | Missing or invalid token |
403 | Token is valid but the principal is not an admin (see Authorization) |
404 | Requested resource not found |
409 | Conflict (e.g. DNS record already exists) |
500 | Internal error |
Example: verifying the deployment
Section titled “Example: verifying the deployment”The /status/Ping endpoint requires no specific scope and is useful for smoke-testing:
curl -s -X POST "https://api.mail.example.com/status/Ping" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{}'# → {}Example: provisioning a domain
Section titled “Example: provisioning a domain”# 1 — Register a domaincurl -s -X POST "https://api.mail.example.com/domains/CreateDomain" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"domain": "acme.com"}' | jq .
# 2 — List DNS records the domain requirescurl -s -X POST "https://api.mail.example.com/domains/GetDomainVerification" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"domain_id": "<id-from-step-1>"}' | jq .
# 3 — Once DNS is in place, verifycurl -s -X POST "https://api.mail.example.com/domains/VerifyDomain" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"domain_id": "<id-from-step-1>"}' | jq .Example: creating a user with a mailbox
Section titled “Example: creating a user with a mailbox”# 1 — Create the userUSER=$(curl -s -X POST "https://api.mail.example.com/users/CreateUser" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "email": "alice@acme.com", "display_name": "Alice" }' | jq .)echo $USER
# 2 — Add a mail alias so she can receive emailcurl -s -X POST "https://api.mail.example.com/aliases/CreateAlias" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "account_id": "<account_id from USER>", "local_part": "alice", "domain_id": "<domain_id for acme.com>", "is_active": true }' | jq .