Skip to content

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.

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 / subdomainNamespaceAdmin API endpoint
example.com, namespace left emptyhttps://api.example.com
example.com, namespace mailboxhttps://api.mailbox.example.com

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

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:

Terminal window
# 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 call
curl -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.

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.

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.

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

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:

CodeMeaning
200Success
400Validation error — check the message field
401Missing or invalid token
403Token is valid but the principal is not an admin (see Authorization)
404Requested resource not found
409Conflict (e.g. DNS record already exists)
500Internal error

The /status/Ping endpoint requires no specific scope and is useful for smoke-testing:

Terminal window
curl -s -X POST "https://api.mail.example.com/status/Ping" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
# → {}
Terminal window
# 1 — Register a domain
curl -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 requires
curl -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, verify
curl -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 .
Terminal window
# 1 — Create the user
USER=$(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 email
curl -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 .