Skip to content

Admin API

The relay exposes an admin HTTP API for operator tooling. It runs on a separate listener from the public SMTP/HTTP send paths, and every endpoint outside the public enroll-start and email-verify routes requires a bearer token.

This page catalogs the endpoints that are stable enough to script against. The relay implementation is in pkg/admin/api_*.go and cmd/relay/admin.go.

The listener defaults to 127.0.0.1:8080. Set the adminAddr config key to use another address.

Authentication header on every endpoint below (except /admin/enroll-start, /admin/enroll, /admin/enroll-marque, /verify-email, /member/*):

Authorization: Bearer <ADMIN_TOKEN>

ADMIN_TOKEN is required at relay startup. Comparison is constant-time. A missing or wrong token returns 401 unauthorized with no body.

Request and response bodies are JSON unless noted. Timestamps are RFC3339 UTC. DIDs and domains in the path are unescaped path segments. Most write endpoints log at info level so an audit trail lives in the relay’s stdout.

Member management

GET /admin/members

Returns every enrolled member with their domains, status, and counters.

Response:

{
"members": [
{
"did": "did:plc:...",
"domains": [{"domain": "example.com", "emailVerified": false}],
"status": "active",
"suspendReason": "",
"sendCount": 1284,
"hourlyLimit": 10,
"dailyLimit": 50,
"termsAcceptedAt": "2026-04-12T18:03:21Z",
"termsVersion": "2026-04-01",
"createdAt": "2026-04-12T18:00:00Z"
}
]
}

Note: in the list response, domains[].emailVerified is always returned as false. The list handler does not populate that field per-domain. For the authoritative verified state, call GET /admin/member/{did} which fills it from the member row.

Example:

curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
http://relay:8080/admin/members | jq '.members[] | {did, status}'
GET /admin/member/{did}

Single-member projection of the list payload. 404 if the DID is unknown.

curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
http://relay:8080/admin/member/did:plc:abc123
POST /admin/member/{did}/suspend?reason=<text>

Flips status to suspended with the reason recorded. Reason defaults to manual suspension if omitted. Active and pending members both accept this; use reject for pending-only semantics.

Response: {"status":"suspended","did":"..."}.

curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://relay:8080/admin/member/did:plc:abc123/suspend?reason=spam"
POST /admin/member/{did}/reactivate

Clears the suspend reason and returns the member to active. Use this for previously-active members. For pending members, use approve.

POST /admin/member/{did}/approve

Moves a pending member to active. Idempotent on already-active rows. Returns 409 if the member is suspended (use reactivate). Fires the welcome email best-effort on the contact address attached to the member’s first domain.

POST /admin/member/{did}/reject?reason=<text>

Pending-only counterpart to suspend. Returns 409 on active members. On already-suspended members it is a no-op.

POST /admin/member/{did}/regenerate-key
POST /admin/member/{did}/regenerate-key?domain=<domain>

Mints a new API key for one of the member’s domains and returns the plaintext key in the response (the only place it ever appears).

The domain query parameter is required when the member owns more than one domain. Single-domain members can omit it.

{
"did": "did:plc:...",
"domain": "example.com",
"apiKey": "atmos_..."
}
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://relay:8080/admin/member/did:plc:abc/regenerate-key?domain=example.com"
POST /admin/member/{did}/send-verification

Enqueues a fresh signup-verification email to the member’s pending contact address. Returns 204 on success.

DELETE /admin/member/{did}

Removes the member, all domains, and best-effort revokes their labels at the labeler. Local delete is authoritative; a labeler error logs without rolling the row back.

Domain management

GET /admin/domain/{domain}/forward-to

Returns the inbound-reply forwarding address for the domain. Empty forwardTo means inbound forwarding is disabled for that domain.

Response: {"forwardTo": "user@elsewhere.com"}.

PUT /admin/domain/{domain}/forward-to
Content-Type: application/json
{"forwardTo": "user@elsewhere.com"}

Pass an empty string to disable forwarding. Returns 204 on success, 404 if the domain is not registered.

curl -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"forwardTo":"replies@example.com"}' \
http://relay:8080/admin/domain/example.com/forward-to
GET /admin/domain/{domain}/warmup-eligible

Returns {"eligible": true|false}. New enrollments default to false and only flip to true once the send-test gate is satisfied.

POST /admin/domain/{domain}/mark-eligible
POST /admin/domain/{domain}/mark-ineligible

Manual override of the send-test gate. Idempotent. Both return 204.

POST /admin/domain/{domain}/send-test?receiver=user@external-mailbox.com

Runs the WarmupSender pipeline once: signs a test message, queues it for delivery to the receiver mailbox, and records a pending send_test_results row keyed by the message ID. Returns 202 if accepted, 501 if WARMUP_SEED_ADDRESSES is unset, 404 if the domain is not registered.

curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://relay:8080/admin/domain/example.com/send-test?receiver=ops@external.com"
GET /admin/domain/{domain}/test-results

Returns send-test result rows for the domain, newest first. Each row carries the parsed DKIM, SPF, and DMARC verdicts once a result has been recorded.

POST /admin/domain/{domain}/test-result
Content-Type: application/json
{
"messageId": "<...@example.com>",
"authResults": "mx.google.com; dkim=pass header.d=example.com; spf=pass; dmarc=pass"
}

Bridge for operators pasting the Authentication-Results header back from a receiver mailbox. The relay parses the header and updates the matching row. When two distinct receivers have reported dkim=pass for a domain, warmup eligibility auto-flips to true.

GET /admin/dkim-check/{domain}

Pulls the domain’s stored DKIM private keys, derives the expected public records, queries DNS for the published TXT records, and reports whether they match. Useful when a receiver returns dkim=fail and you need to confirm the published DNS matches the stored key.

Response:

{
"domain": "example.com",
"selector": "atmos20260412",
"rsa": {
"dns_name": "atmos20260412r._domainkey.example.com",
"stored_pub": "v=DKIM1; k=rsa; p=...",
"dns_pub": "v=DKIM1; k=rsa; p=...",
"match": true
},
"ed": { ... },
"healthy": true
}

Warming tier overrides

POST /admin/warmup?did=did:plc:...

Asks the WarmupSender to dispatch a batch of warmup messages on behalf of the DID. Returns 501 if WARMUP_SEED_ADDRESSES is not configured. On success returns the per-DID batch summary:

{"sent": 5, "failed": 0}
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://relay:8080/admin/warmup?did=did:plc:abc123"
POST /admin/members/{did}/warming-override
Content-Type: application/json
{"tier":"warmed","reason":"Approved after reviewing the sender's history"}

The accepted tiers are warming, ramping, and warmed. The requested tier cannot be below the account’s natural age-based tier. The API requires an audit reason and returns the DID and warmingOverride value.

Use DELETE /admin/members/{did}/warming-override with a JSON reason to clear the override. Domain-specific overrides take precedence over an account override. Warming overrides do not raise the configured account limits or clear a domain pause. Use the supported controls rather than editing the live database.

Stats and observability

GET /admin/stats

Cluster-wide totals.

{"members": 42, "domains": 71, "messages": 152834}
GET /admin/sender-reputation?did=did:plc:...
GET /admin/sender-reputation?did=did:plc:...&since=2026-04-01T00:00:00Z

Returns the rolling-window sends, bounces, complaints, and suspension state for one DID. since is optional, defaults to 30 days ago, and is capped at 365 days. Same view the labeler uses to compute the clean-sender label.

curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://relay:8080/admin/sender-reputation?did=did:plc:abc123"
GET /admin/notifications/dead-letter

Lists notification rows that exhausted MaxNotificationAttempts. A row landing here usually means a broken contact email, a downstream MTA that refuses our mail, or a misconfigured opmail relay.

{
"notifications": [
{
"id": 17,
"kind": "key_regenerated",
"recipient": "ops@example.com",
"payload": {"domain": "example.com"},
"attempts": 5,
"lastError": "550 No such user",
"createdAt": "2026-05-10T03:00:00Z",
"lastAttemptAt": "2026-05-10T05:14:22Z"
}
],
"count": 1
}
DELETE /admin/notifications/dead-letter?id={id}

Removes a single dead-lettered notification once the underlying issue is resolved. For example, a signup verification may bounce because a user mistyped their address, then the user may fix it and re-enroll. Only rows flagged dead_letter are removable; live rows still in the retry queue cannot be deleted this way. Returns 204 No Content on success, 400 for a missing or non-numeric id, and 404 when no dead-letter row matches.

OAuth audit

GET /admin/oauth-signins
GET /admin/oauth-signins?since=2026-05-01T00:00:00Z&limit=200

Append-only audit of OAuth callback completions. Powers the Grafana “who signed in and when” panel. Newest first. limit defaults to 500, capped at 5000.

Response:

{
"signins": [
{
"id": 1042,
"did": "did:plc:abc",
"handle": "alice.example.com",
"pds": "https://pds.example.com",
"domain": "example.com",
"flow": "signup",
"createdAt": "2026-05-15T18:21:09Z"
}
]
}

Member self-service

These endpoints are not admin-authenticated. Members hit them with their own SMTP API key. Documented here because operators frequently script against them when debugging member-reported issues.

GET https://smtp.atmos.email/member/status?did=did:plc:...
GET /member/deliverability?did=did:plc:...
PUT /member/forward-to?domain=example.com

Authentication is Authorization: Bearer <api_key> where the API key is the same credential used for SMTP and HTTP send. Both not found and wrong key return identical 401 bodies, with bcrypt timing equalized, to prevent DID enumeration.

/member/status permits 12 successful checks per member per rolling minute and one in-flight check per member. Excess or concurrency-limited checks return 429 with Retry-After; clients should wait that many seconds before retrying. This protects the authoritative safety query used by live submission.

Authentication notes

The admin mux binds on cfg.AdminAddr (default :8080). In production this address is firewalled to the tailnet so the admin API is unreachable from the public internet. Tokens travel in the Authorization: Bearer header.

Token rotation is supported via the dual-token slot in config: deploy with the new value as ADMIN_TOKEN and the previous value as ADMIN_TOKEN_PREV for the window in which downstream consumers cut over, then redeploy with ADMIN_TOKEN_PREV cleared. Both values are constant-time compared on every request, so timing cannot leak which slot accepted the call. See the operator runbook for the full procedure.

If you script against the API:

  • Treat 401 as a hard auth failure. Tokens are validated per request and never expire server-side; rotation is operator-initiated via the dual-token slot above.
  • Treat 404 the same way for member-shaped paths whether the DID is unknown or the action is unrecognized. Both render the same body.
  • Treat 5xx as retryable. The handlers log the underlying error to relay stdout for diagnosis.
  • The /admin/enroll-start endpoint is rate-limited per source IP because it is the one unauthenticated write surface. The exact limit is per-deployment via cfg.enrollStartRateLimit.

Back to docs