Mail Forwarding API referenceClick to copy this anchor link.
This route documents the API surface.
It is not a tour of the whole mail-forwarding system. It is the wire contract for this API: routes, methods, bodies, query strings, auth, return shapes, errors, and the places where the machine refuses garbage.
Examples use:
BASE='https://mail.thc.org/api'Other domains can serve the same API. mail.thc.org is the default public example in this document. In a whitelabel setup, add and verify your domain in the DNS Checker as type UI. The infrastructure uses on-demand TLS through Caddy and proxies /api/ to the local API instance running inside Haltman.IO infrastructure. Once the UI domain is verified, the API is reachable at:
https://your-domain/apiCheck it with:
curl -sS 'https://your-domain/api/stats'If that answers JSON, the wire is alive. If it does not, stop guessing and check DNS, TLS, Caddy, and the DNS Checker state.
Table of contentsClick to copy this anchor link.
- Start here: terminal calls
- 1. List public mail domains
- 2. Create an alias with email confirmation
- 3. Confirm alias creation
- 4. Remove an alias with email confirmation
- 5. Create a handle with email confirmation
- 6. Remove a handle with email confirmation
- 7. Disable one domain for a handle
- 8. Request an API key
- 9. Use an API key to create an alias directly
- 10. List aliases owned by an API key
- 11. Delete an alias with an API key
- 12. Create and delete a handle with an API key
- 13. Check DNS status for a domain
- Base URL and routing
- Transport rules
- Common input rules
- Error model
- Public routes
- GET /api/domains
- GET /api/stats
- GET /api/forward/subscribe
- GET /api/forward/unsubscribe
- GET /api/forward/confirm
- POST /api/forward/confirm
- GET /api/handle/subscribe
- GET /api/handle/unsubscribe
- GET /api/handle/domain/disable
- GET /api/handle/domain/enable
- GET /api/handle/confirm
- POST /api/handle/confirm
- POST /api/credentials/create
- POST /api/credentials/list/request
- POST /api/credentials/destroy-all/request
- GET /api/credentials/confirm
- POST /api/credentials/confirm
- POST /api/credentials/renew
- POST /api/credentials/automatic-renew
- POST /api/credentials/destroy
- POST /api/request/ui
- POST /api/request/email
- GET /api/checkdns/:target
- API-key routes
- Browser/admin auth routes
- Admin routes
- Counter route
- Rate limits
- CORS and whitelabel behavior
- Data semantics
- What this API does not support
- Other components
- Final notes for client authors
Start here: terminal callsClick to copy this anchor link.
These are the calls a normal shell user usually wants first.
The examples are written for Linux shells with curl. They do not need browser tooling. Responses are examples of the shape returned by the code, not a promise that your IDs, dates, or counts match.
1. List public mail domainsClick to copy this anchor link.
BASE='https://mail.thc.org/api'
curl -sS "$BASE/domains"Example response:
[
"thc.org",
"example.net"
]Interpretation:
- The array contains domains that are
active = 1,active_mx = 1, andvisible = 1. - These are public EMAIL-valid domains.
- If a domain is missing, it is not public for email alias creation through the normal domain list path. It may be inactive, hidden, not DNS-approved for EMAIL, or absent.
2. Create an alias with email confirmationClick to copy this anchor link.
This starts the alias creation flow. It does not create the alias yet. It sends a 6-digit token to the destination email.
BASE='https://mail.thc.org/api'
curl -sS --get "$BASE/forward/subscribe" \
--data-urlencode 'name=research' \
--data-urlencode 'domain=thc.org' \
--data-urlencode 'to=alice@example.org'Example response:
{
"ok": true,
"action": "subscribe",
"alias_candidate": "research@thc.org",
"to": "alice@example.org",
"confirmation": {
"sent": true,
"ttl_minutes": 10
}
}Interpretation:
sent: truemeans the confirmation email was sent.sent: falsewithreason: "cooldown"means a pending request already exists and the server refused to spam the mailbox.- No alias exists until the token is confirmed.
- The destination address must be a real mailbox syntax according to the API parser.
- The destination cannot already be an alias in this system.
- The destination cannot use a managed mail domain. The forwarding path checks managed-domain suffixes, not just an exact match.
3. Confirm alias creationClick to copy this anchor link.
The token is six digits and comes by email.
BASE='https://mail.thc.org/api'
TOKEN='123456'
curl -sS --get "$BASE/forward/confirm" \
--data-urlencode "token=$TOKEN"Or with JSON:
curl -sS -X POST "$BASE/forward/confirm" \
-H 'Content-Type: application/json' \
-d '{"token":"123456"}'Example response:
{
"ok": true,
"confirmed": true,
"intent": "subscribe",
"created": true,
"address": "research@thc.org",
"goto": "alice@example.org"
}Interpretation:
created: truemeans the row was inserted.created: falsewithreason: "already_exists"means the alias already existed for the same owner and the token was consumed.alias_owner_changedmeans someone or something changed the alias owner between request and confirmation. The token is not consumed in that failure path.
4. Remove an alias with email confirmationClick to copy this anchor link.
This sends a confirmation token to the current goto owner of the alias. It does not remove the alias yet.
BASE='https://mail.thc.org/api'
curl -sS --get "$BASE/forward/unsubscribe" \
--data-urlencode 'alias=research@thc.org'Example response:
{
"ok": true,
"action": "unsubscribe",
"alias": "research@thc.org",
"sent": true,
"ttl_minutes": 10
}Confirm with the same confirmation endpoint:
TOKEN='123456'
curl -sS -X POST "$BASE/forward/confirm" \
-H 'Content-Type: application/json' \
-d "{\"token\":\"$TOKEN\"}"Example response:
{
"ok": true,
"confirmed": true,
"intent": "unsubscribe",
"removed": true,
"address": "research@thc.org"
}Interpretation:
- The API deactivates the alias. It does not physically delete the row.
- Deactivation sets
active = 0and movesgototo the permanent sink valuealias@haltman.io. - If the alias is already inactive, the API returns
alias_inactive.
5. Create a handle with email confirmationClick to copy this anchor link.
A handle reserves one local part across all managed domains. If you own handle alice, then alice@any-visible-mail-domain can route to your destination, except domains disabled for that handle.
BASE='https://mail.thc.org/api'
curl -sS --get "$BASE/handle/subscribe" \
--data-urlencode 'handle=alice' \
--data-urlencode 'to=alice@example.org'Example response:
{
"ok": true,
"action": "handle_subscribe",
"handle": "alice",
"to": "alice@example.org",
"confirmation": {
"sent": true,
"ttl_minutes": 10
}
}Confirm it:
TOKEN='123456'
curl -sS -X POST "$BASE/handle/confirm" \
-H 'Content-Type: application/json' \
-d "{\"token\":\"$TOKEN\"}"Example response:
{
"ok": true,
"created": true,
"handle": "alice",
"goto": "alice@example.org"
}Interpretation:
- Public handle creation refuses a handle if the same local part already exists as an active alias.
- Public handle creation refuses a handle if the handle has ever been reserved before.
- Reuse is not a feature. A dead handle is still reserved wreckage.
6. Remove a handle with email confirmationClick to copy this anchor link.
BASE='https://mail.thc.org/api'
curl -sS --get "$BASE/handle/unsubscribe" \
--data-urlencode 'handle=alice'Example response:
{
"ok": true,
"action": "handle_unsubscribe",
"handle": "alice",
"confirmation": {
"sent": true,
"ttl_minutes": 10
}
}Confirm it:
TOKEN='123456'
curl -sS -X POST "$BASE/handle/confirm" \
-H 'Content-Type: application/json' \
-d "{\"token\":\"$TOKEN\"}"Example response:
{
"ok": true,
"updated": true,
"handle": "alice",
"active": false
}Interpretation:
- The handle row stays in the database.
addressbecomesNULL,activebecomes0, andunsubscribed_atis written.- The handle is not freed for reuse.
- If the handle does not exist or is already inactive, the request endpoint returns a neutral
acceptedresponse instead of leaking ownership state.
7. Disable one domain for a handleClick to copy this anchor link.
This blocks handle@domain while leaving the same handle active elsewhere.
BASE='https://mail.thc.org/api'
curl -sS --get "$BASE/handle/domain/disable" \
--data-urlencode 'handle=alice' \
--data-urlencode 'domain=thc.org'Confirm it:
TOKEN='123456'
curl -sS --get "$BASE/handle/domain/disable/confirm" \
--data-urlencode "token=$TOKEN"Example response:
{
"ok": true,
"updated": true,
"handle": "alice",
"domain": "thc.org",
"disabled": true
}Enable it again:
curl -sS --get "$BASE/handle/domain/enable" \
--data-urlencode 'handle=alice' \
--data-urlencode 'domain=thc.org'Confirm with /api/handle/domain/enable/confirm or /api/handle/confirm. The service consumes the same pending intent either way.
8. Request an API keyClick to copy this anchor link.
API keys are created through email confirmation. The key is shown once, at POST confirmation time. Lose it and you do not get it back. That is not a bug.
BASE='https://mail.thc.org/api'
curl -sS -X POST "$BASE/credentials/create" \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.org","days":30,"automatic_renew":false}'Example response:
{
"ok": true,
"action": "api_credentials_create",
"email": "alice@example.org",
"days": 30,
"automatic_renew": false,
"confirmation": {
"sent": true,
"ttl_minutes": 15
}
}Preview the pending request without issuing the key:
TOKEN='123456'
curl -sS --get "$BASE/credentials/confirm" \
--data-urlencode "token=$TOKEN"Example response:
{
"ok": true,
"pending": true,
"mutation_required": true,
"action": "create",
"email": "alice@example.org",
"days": 30,
"automatic_renew": false,
"confirm_via": {
"method": "POST",
"path": "/api/credentials/confirm"
}
}Issue the key:
TOKEN='123456'
curl -sS -X POST "$BASE/credentials/confirm" \
-H 'Content-Type: application/json' \
-d "{\"token\":\"$TOKEN\"}"Example response:
{
"ok": true,
"action": "api_credentials_confirm",
"confirmed": true,
"email": "alice@example.org",
"token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"token_type": "api_key",
"expires_in_days": 30,
"automatic_renew": false
}Interpretation:
- Save
tokennow. The database stores only a SHA-256 hash. - API keys are sent as
X-API-Key, not as bearer tokens. - Owner emails using managed domains are refused with
managed_domain_not_allowed.
9. Use an API key to create an alias directlyClick to copy this anchor link.
BASE='https://mail.thc.org/api'
API_KEY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
curl -sS -X POST "$BASE/alias/create" \
-H "X-API-Key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"alias_handle":"research","alias_domain":"thc.org"}'Example response:
{
"ok": true,
"created": true,
"address": "research@thc.org",
"goto": "alice@example.org"
}Interpretation:
gotois the owner email bound to the API key.- The alias domain must be EMAIL-valid in the domain table.
- This path mutates immediately. There is no confirmation email because the API key already proves owner control.
10. List aliases owned by an API keyClick to copy this anchor link.
curl -sS "$BASE/alias/list?limit=50&offset=0" \
-H "X-API-Key: $API_KEY"Example response:
{
"items": [
{
"id": 100,
"address": "research@thc.org",
"goto": "alice@example.org",
"active": 1,
"domain_id": 1,
"created": "2026-06-19T12:00:00.000Z",
"modified": "2026-06-19T12:00:00.000Z"
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0
}
}11. Delete an alias with an API keyClick to copy this anchor link.
curl -sS -X POST "$BASE/alias/delete" \
-H "X-API-Key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"alias":"research@thc.org"}'Example response:
{
"ok": true,
"deleted": true,
"alias": "research@thc.org"
}Interpretation:
- This is a deactivation, not a physical delete.
- If the alias exists but belongs to another key owner, the response is
403 {"error":"forbidden"}.
12. Create and delete a handle with an API keyClick to copy this anchor link.
curl -sS -X POST "$BASE/handle/create" \
-H "X-API-Key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"handle":"alice"}'Example response:
{
"ok": true,
"created": true,
"handle": "alice",
"goto": "alice@example.org"
}Delete it:
curl -sS -X POST "$BASE/handle/delete" \
-H "X-API-Key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"handle":"alice"}'Example response:
{
"ok": true,
"updated": true,
"handle": "alice",
"active": false
}Interpretation:
- API-key handle deletion requires ownership. The handle row address must match the key owner email.
- If it belongs to another owner, the API returns
forbidden.
13. Check DNS status for a domainClick to copy this anchor link.
BASE='https://mail.thc.org/api'
curl -sS "$BASE/checkdns/example.com"Example response:
{
"target": "example.com",
"normalized_target": "example.com",
"summary": {
"has_ui": true,
"has_email": true,
"overall_status": "PENDING"
},
"ui": {
"status": "ACTIVE",
"missing": []
},
"email": {
"status": "PENDING",
"missing": [
{
"key": "MX",
"expected": {
"host": "mail.example.net",
"priority": 10
},
"found": []
}
]
}
}Interpretation:
- This API relays the DNS Checker response. It does not invent DNS state.
UIandEMAILare separate approval lanes.ACTIVEmeans that lane passed.PENDINGmeans DNS is not there yet or has not been observed as complete.missingtells you what the checker still wants.
Base URL and routingClick to copy this anchor link.
The Nest app uses a global prefix:
/apiAll normal public routes in this document start under that prefix.
The sane base URL is:
https://mail.thc.org/apiThe code also exposes compatibility duplicates for domains and stats because those controllers are registered as ["domains", "api/domains"] and ["stats", "api/stats"] under a global prefix. That means these also exist:
/api/api/domains
/api/api/statsDo not build new clients on that doubled prefix. It is a compatibility scar, not a clean contract.
Transport rulesClick to copy this anchor link.
- Use HTTPS in production.
- Use
Content-Type: application/jsonwhen sending JSON bodies. /api/request/uiand/api/request/emailexplicitly reject non-JSON bodies with415 {"error":"unsupported_media_type"}.- Some POST routes also read query parameters for compatibility. New clients should send JSON bodies unless this document shows a GET flow.
- API-key routes use
X-API-Key: <64-char-key>. - Bot-integrated clients may send
X-Bot-Auth-TokenandX-Bot-Sourcefor rate-limit bucketing. If the token is missing or wrong, the request is treated like a normal request and falls back to IP bucketing. - Browser/admin auth uses cookies plus CSRF. It is not an API-key surface.
Common input rulesClick to copy this anchor link.
Email addressesClick to copy this anchor link.
The parser lowercases and trims.
Local part:
RFC 5322 dot-atom style
max 64 charsDomain part:
strict DNS domain
at least one dot
TLD letters, length 2..63Maximum mailbox length is 254 chars.
Rejected junk includes:
- missing
@ - multiple
@ - empty local part
- local part over 64 chars
- invalid dots
- domain without a valid TLD
- domains that fail the strict DNS parser
Alias names and handlesClick to copy this anchor link.
Alias local parts and handles use the same local-part parser.
Acceptable:
research
root.ops
build-01
a_bNot acceptable:
.research
research.
two..dots
bad space
bad/slashDomain targetsClick to copy this anchor link.
DNS Checker targets are not URLs. They are bare domain names.
Accepted:
example.com
mail.example.com
Example.COM.The last one normalizes to example.com.
Rejected:
https://example.com
http://example.com
127.0.0.1
::1
example..com
-bad.example
bad-.exampleThe error is:
{
"error": "target must be a domain name without scheme"
}or, in admin DNS request forms:
{
"error": "invalid_params",
"field": "target",
"reason": "target must be a domain name without scheme"
}API keysClick to copy this anchor link.
The guard accepts:
^[a-z0-9]{64}$Send it as:
X-API-Key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefDo not send:
Authorization: Bearer ...That is not implemented here.
PaginationClick to copy this anchor link.
Most list routes use:
limit: integer, 1..200
offset: integer, >=0Defaults:
limit=50
offset=0Bad pagination returns:
{
"error": "invalid_params",
"field": "limit"
}or:
{
"error": "invalid_params",
"field": "offset"
}Error modelClick to copy this anchor link.
Most deliberate errors are small JSON objects with an error string.
Examples:
{ "error": "invalid_params", "field": "email" }{ "ok": false, "error": "alias_taken", "address": "research@thc.org" }{ "error": "banned", "ban": { "ban_type": "domain", "ban_value": "bad.example", "reason": "abuse", "banned_at": "2026-06-19T12:00:00.000Z" } }Unhandled server errors are hidden:
{ "error": "internal_error" }Transaction deadlock or lock wait exhaustion is surfaced as:
{ "error": "temporarily_unavailable" }Rate limits return 429 and set Retry-After. Some global limits return a plain string:
Too many requests, please try again later.Most route-specific limits return JSON:
{
"error": "rate_limited",
"where": "alias_create",
"reason": "too_many_requests_key"
}Public routesClick to copy this anchor link.
These routes do not require an API key unless explicitly stated.
GET /api/domainsClick to copy this anchor link.
Lists public EMAIL-valid domains.
curl -sS "$BASE/domains"Response:
["thc.org"]Cache:
Cache-Control: public, max-age=10The internal query requires:
domain.active = 1
domain.active_mx = 1
domain.visible = 1GET /api/statsClick to copy this anchor link.
Returns public counters.
curl -sS "$BASE/stats"Response:
{
"domains": 4,
"aliases": 1200,
"forwarded": 980000
}Interpretation:
domains: count of visible EMAIL-valid domains.aliases: active alias count plus active handle expansion across visible EMAIL-valid domains, minus disabled handle/domain pairs.forwarded: value frommail_forward_counter.id = 1.
Cache:
Cache-Control: public, max-age=120GET /api/forward/subscribeClick to copy this anchor link.
Starts an alias creation request and sends a confirmation token.
Query mode A, normal domain mode:
name=research
domain=thc.org
to=alice@example.orgdomain is optional. If omitted, the server uses DEFAULT_ALIAS_DOMAIN.
Query mode B, full address mode:
address=research@thc.org
to=alice@example.orgIn full address mode, name and domain are forbidden. The code treats them as incompatible and returns:
{
"error": "invalid_params",
"field": "name",
"reason": "address_incompatible_with_name"
}or:
{
"error": "invalid_params",
"field": "domain",
"reason": "address_incompatible_with_domain"
}Important implementation edge:
- Normal
name + domainmode requires the alias domain to exist and be EMAIL-valid. - Full
addressmode parses the alias address as a mailbox and skips the domain table check. - That means clients must not assume every alias created through address mode belongs to
/api/domains.
Success:
{
"ok": true,
"action": "subscribe",
"alias_candidate": "research@thc.org",
"to": "alice@example.org",
"confirmation": {
"sent": true,
"ttl_minutes": 10
}
}Common failures:
{ "error": "invalid_params", "field": "to" }{ "error": "invalid_domain", "field": "domain", "hint": "domain must exist in database and be active" }{ "ok": false, "error": "alias_taken", "address": "research@thc.org" }{
"ok": false,
"error": "invalid_params",
"field": "to",
"reason": "destination_cannot_be_an_existing_alias",
"to": "alice@example.org"
}{
"ok": false,
"error": "invalid_params",
"field": "to",
"reason": "destination_cannot_use_managed_domain",
"to": "alice@thc.org",
"managed_domain_match": "thc.org"
}GET /api/forward/unsubscribeClick to copy this anchor link.
Starts alias removal by emailing the current alias owner.
Query:
alias=research@thc.orgSuccess:
{
"ok": true,
"action": "unsubscribe",
"alias": "research@thc.org",
"sent": true,
"ttl_minutes": 10
}Failures:
{ "error": "alias_not_found", "alias": "research@thc.org" }{ "error": "alias_inactive", "alias": "research@thc.org" }GET /api/forward/confirmClick to copy this anchor link.
Executes a pending alias create/delete confirmation.
Query:
token=123456Success for create:
{
"ok": true,
"confirmed": true,
"intent": "subscribe",
"created": true,
"address": "research@thc.org",
"goto": "alice@example.org"
}Success for delete:
{
"ok": true,
"confirmed": true,
"intent": "unsubscribe",
"removed": true,
"address": "research@thc.org"
}Failures:
{ "ok": false, "error": "invalid_params", "field": "token" }{ "ok": false, "error": "invalid_token" }{ "ok": false, "error": "invalid_or_expired" }POST /api/forward/confirmClick to copy this anchor link.
Same mutation as GET, but body-based.
Body:
{
"token": "123456"
}GET /api/handle/subscribeClick to copy this anchor link.
Starts handle creation.
Query:
handle=alice
to=alice@example.orgSuccess:
{
"ok": true,
"action": "handle_subscribe",
"handle": "alice",
"to": "alice@example.org",
"confirmation": {
"sent": true,
"ttl_minutes": 10
}
}Rules:
handlemust be a valid local part.tomust be a valid mailbox.tocannot already be an alias.tocannot use an exact active managed domain.- The handle cannot already exist.
- The handle cannot collide with an active alias local part.
Failures:
{ "error": "invalid_params", "field": "handle" }{ "error": "invalid_params", "field": "to" }{ "ok": false, "error": "alias_taken" }GET /api/handle/unsubscribeClick to copy this anchor link.
Starts handle removal.
Query:
handle=aliceIf active:
{
"ok": true,
"action": "handle_unsubscribe",
"handle": "alice",
"confirmation": {
"sent": true,
"ttl_minutes": 10
}
}If not found or inactive:
{
"ok": true,
"accepted": true
}That neutral response is deliberate. Enumeration is useless noise.
GET /api/handle/domain/disableClick to copy this anchor link.
Starts disabling a domain for a handle.
Query:
handle=alice
domain=thc.orgSuccess:
{
"ok": true,
"action": "handle_disable_domain",
"handle": "alice",
"domain": "thc.org",
"confirmation": {
"sent": true,
"ttl_minutes": 10
}
}GET /api/handle/domain/enableClick to copy this anchor link.
Starts enabling a domain for a handle.
Query:
handle=alice
domain=thc.orgSuccess:
{
"ok": true,
"action": "handle_enable_domain",
"handle": "alice",
"domain": "thc.org",
"confirmation": {
"sent": true,
"ttl_minutes": 10
}
}GET /api/handle/confirmClick to copy this anchor link.
Confirms any handle intent:
handle_subscribehandle_unsubscribehandle_domain_disablehandle_domain_enable
Query:
token=123456Success, create:
{
"ok": true,
"created": true,
"handle": "alice",
"goto": "alice@example.org"
}Success, unsubscribe:
{
"ok": true,
"updated": true,
"handle": "alice",
"active": false
}Success, disable domain:
{
"ok": true,
"updated": true,
"handle": "alice",
"domain": "thc.org",
"disabled": true
}Success, enable domain:
{
"ok": true,
"updated": true,
"handle": "alice",
"domain": "thc.org",
"disabled": false
}Aliases:
GET /api/handle/unsubscribe/confirm
POST /api/handle/unsubscribe/confirm
GET /api/handle/domain/disable/confirm
GET /api/handle/domain/enable/confirmThese all call the same confirmation service. The token intent decides the mutation.
POST /api/handle/confirmClick to copy this anchor link.
Body:
{
"token": "123456"
}Same behavior as GET.
POST /api/credentials/createClick to copy this anchor link.
Requests an API key creation token by email.
Body:
{
"email": "alice@example.org",
"days": 30,
"automatic_renew": false
}automaticRenew is also accepted as an alias for automatic_renew.
Rules:
emailmust be a valid mailbox.daysmust be an integer1..9999.automatic_renewaccepts booleans and common boolean strings:true,false,1,0,yes,no,on,off.- Owner email domains cannot be managed domains.
Success:
{
"ok": true,
"action": "api_credentials_create",
"email": "alice@example.org",
"days": 30,
"automatic_renew": false,
"confirmation": {
"sent": true,
"ttl_minutes": 15
}
}Cooldown/rate limit inside pending email flow:
{
"ok": true,
"action": "api_credentials_create",
"email": "alice@example.org",
"days": 30,
"automatic_renew": false,
"confirmation": {
"sent": false,
"ttl_minutes": 15,
"reason": "cooldown",
"status": "PENDING",
"expires_at": "2026-06-19T12:15:00.000Z",
"last_sent_at": "2026-06-19T12:00:00.000Z",
"next_allowed_send_at": "2026-06-19T12:01:00.000Z",
"send_count": 1,
"remaining_attempts": 2
}
}POST /api/credentials/list/requestClick to copy this anchor link.
Requests a confirmation token to list active API key metadata for an email.
Body:
{
"email": "alice@example.org"
}Success:
{
"ok": true,
"action": "api_credentials_list_request",
"email": "alice@example.org",
"confirmation": {
"sent": true,
"ttl_minutes": 15
}
}POST /api/credentials/destroy-all/requestClick to copy this anchor link.
Requests a confirmation token to destroy all API keys for an email.
Body:
{
"email": "alice@example.org"
}Success:
{
"ok": true,
"action": "api_credentials_destroy_all_request",
"email": "alice@example.org",
"confirmation": {
"sent": true,
"ttl_minutes": 15
}
}GET /api/credentials/confirmClick to copy this anchor link.
Preview only. It does not create, list, or destroy keys.
Query:
token=123456Response for create:
{
"ok": true,
"pending": true,
"mutation_required": true,
"action": "create",
"email": "alice@example.org",
"days": 30,
"automatic_renew": false,
"confirm_via": {
"method": "POST",
"path": "/api/credentials/confirm"
}
}If Accept: text/html is sent, this endpoint can return an HTML preview page.
POST /api/credentials/confirmClick to copy this anchor link.
Executes the pending API credentials action.
Body:
{
"token": "123456"
}Create success:
{
"ok": true,
"action": "api_credentials_confirm",
"confirmed": true,
"email": "alice@example.org",
"token": "64_chars_here",
"token_type": "api_key",
"expires_in_days": 30,
"automatic_renew": false
}List success:
{
"ok": true,
"action": "api_credentials_list_confirm",
"confirmed": true,
"email": "alice@example.org",
"items": [
{
"id": 3,
"owner_email": "alice@example.org",
"status": "active",
"created_at": "2026-06-01T00:00:00.000Z",
"expires_at": "2026-07-01T00:00:00.000Z",
"revoked_at": null,
"last_used_at": null,
"automatic_renew": 1,
"active": true
}
]
}Destroy-all success:
{
"ok": true,
"action": "api_credentials_destroy_all_confirm",
"confirmed": true,
"email": "alice@example.org",
"destroyed": true,
"deleted_count": 2
}Destroy-all with nothing to delete:
{
"ok": false,
"action": "api_credentials_destroy_all_confirm",
"error": "no_api_keys",
"email": "alice@example.org"
}The no-key case returns 404 after consuming the confirmation token.
POST /api/credentials/renewClick to copy this anchor link.
Renews one active API key by adding days to its current expiry.
Body:
{
"api_key": "64_chars_here",
"days": 10
}key is accepted as an alias for api_key.
Rules:
api_keymust match the 64-character key format.daysmust be1..999.- Key must be active, not revoked, and not expired.
- Key owner cannot be on a managed domain.
Success:
{
"ok": true,
"action": "api_credentials_renew",
"renewed": true,
"days_added": 10,
"item": {
"id": 11,
"owner_email": "alice@example.org",
"status": "active",
"created_at": "2026-06-01T00:00:00.000Z",
"expires_at": "2026-06-30T00:00:00.000Z",
"revoked_at": null,
"last_used_at": null,
"automatic_renew": 0,
"active": true
}
}POST /api/credentials/automatic-renewClick to copy this anchor link.
Toggles automatic renewal for one active key.
Body:
{
"api_key": "64_chars_here",
"automatic_renew": true
}Success:
{
"ok": true,
"action": "api_credentials_automatic_renew",
"updated": true,
"automatic_renew": true,
"item": {
"id": 12,
"owner_email": "alice@example.org",
"automatic_renew": 1,
"active": true
}
}Automatic renewal behavior:
- API-key guard extends keys with
automatic_renew = 1when they are active and within 7 days of expiry. - Extension adds 90 days.
POST /api/credentials/destroyClick to copy this anchor link.
Destroys one active API key.
Body:
{
"api_key": "64_chars_here"
}Success:
{
"ok": true,
"action": "api_credentials_destroy",
"destroyed": true,
"notification_sent": true
}If email notification fails after deletion:
{
"ok": true,
"action": "api_credentials_destroy",
"destroyed": true,
"notification_sent": false
}The deletion still happened. The email smoke failed afterward.
POST /api/request/uiClick to copy this anchor link.
Creates or refreshes a UI DNS verification request through the DNS Checker.
Headers:
Content-Type: application/jsonBody:
{
"target": "example.com"
}Success shape from DNS Checker:
{
"id": 10,
"target": "example.com",
"type": "UI",
"status": "PENDING",
"expires_at": "2026-06-19T13:00:00.000Z"
}Implementation:
- The public API validates
target. - It checks local active domain bans.
- It relays upstream to
CHECKDNS_BASE_URL/request/ui. - It sends
CHECKDNS_TOKENas upstreamx-api-key. - It mostly returns upstream status and body unchanged.
POST /api/request/emailClick to copy this anchor link.
Same as UI request, but type EMAIL.
Body:
{
"target": "example.com"
}Upstream path:
CHECKDNS_BASE_URL/request/emailGET /api/checkdns/:targetClick to copy this anchor link.
Reads aggregate DNS state.
curl -sS "$BASE/checkdns/example.com"Upstream path:
CHECKDNS_BASE_URL/api/checkdns/:targetAccepted status values in known response shapes:
PENDING
ACTIVE
EXPIRED
FAILEDKnown request response shape:
{
"id": 1,
"target": "example.com",
"type": "EMAIL",
"status": "PENDING",
"expires_at": "2026-06-19T13:00:00.000Z"
}Known aggregate response shape:
{
"target": "example.com",
"normalized_target": "example.com",
"summary": {
"has_ui": true,
"has_email": true,
"overall_status": "PENDING",
"expires_at_min": "2026-06-19T13:00:00.000Z",
"last_checked_at_max": "2026-06-19T12:00:00.000Z",
"next_check_at_min": "2026-06-19T12:05:00.000Z"
},
"ui": {
"status": "ACTIVE",
"missing": []
},
"email": {
"status": "PENDING",
"missing": []
}
}If the upstream times out, this API returns:
{ "error": "internal_error" }with HTTP 503.
If the upstream is otherwise unreachable, this API returns HTTP 502 and:
{ "error": "internal_error" }API-key routesClick to copy this anchor link.
These routes require:
X-API-Key: <64-character key>The guard:
- lowercases and trims the header;
- rejects missing keys with
missing_api_key; - rejects bad format with
invalid_api_key_format; - hashes the key with SHA-256;
- looks for an active, unrevoked, unexpired token;
- attaches
owner_emailto the request; - touches
last_used_atasynchronously; - extends automatic-renew keys when due.
Auth failures:
{ "error": "missing_api_key" }{ "error": "invalid_api_key_format" }{ "error": "invalid_or_expired_api_key" }GET /api/alias/listClick to copy this anchor link.
Query:
limit=50
offset=0Response:
{
"items": [
{
"id": 100,
"address": "research@thc.org",
"goto": "alice@example.org",
"active": 1,
"domain_id": 1,
"created": "2026-06-19T12:00:00.000Z",
"modified": "2026-06-19T12:00:00.000Z"
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0
}
}Only aliases whose goto equals the API-key owner email are returned.
GET /api/alias/statsClick to copy this anchor link.
Response:
{
"totals": 10,
"active": 9,
"created_last_7d": 2,
"modified_last_24h": 1,
"by_domain": [
{
"domain": "thc.org",
"total": 7,
"active": 7
}
]
}GET /api/activityClick to copy this anchor link.
Query:
limit=50
offset=0Response:
{
"items": [
{
"type": "alias_create",
"occurred_at": "2026-06-19T12:00:00.000Z",
"route": "/api/alias/create",
"intent": null,
"alias": "research@thc.org"
},
{
"type": "confirm_subscribe",
"occurred_at": "2026-06-18T12:00:00.000Z",
"route": "/api/forward/confirm",
"intent": "subscribe",
"alias": "old@thc.org"
}
],
"pagination": {
"limit": 50,
"offset": 0
}
}This blends successful API-key alias mutations with confirmed email-token mutations for the owner email.
POST /api/alias/createClick to copy this anchor link.
Body:
{
"alias_handle": "research",
"alias_domain": "thc.org"
}Query parameters with the same names are also read, but JSON is the cleaner path.
Rules:
alias_handlemust be a valid local part.alias_domainmust be a valid domain.- domain must be
active = 1andactive_mx = 1. - owner email comes from the API key.
- owner email must not be banned.
- alias local part must not be reserved as a handle.
- alias address must not already exist.
Success:
{
"ok": true,
"created": true,
"address": "research@thc.org",
"goto": "alice@example.org"
}Failures:
{ "error": "invalid_domain", "field": "alias_domain" }{ "ok": false, "error": "alias_taken", "address": "research@thc.org" }POST /api/alias/deleteClick to copy this anchor link.
Body:
{
"alias": "research@thc.org"
}Rules:
- alias must exist;
- alias must be active;
- alias
gotomust equal the API-key owner email.
Success:
{
"ok": true,
"deleted": true,
"alias": "research@thc.org"
}Failures:
{ "error": "alias_not_found", "alias": "research@thc.org" }{ "error": "alias_inactive", "alias": "research@thc.org" }{ "error": "forbidden" }POST /api/handle/createClick to copy this anchor link.
Body:
{
"handle": "alice"
}Owner is the API-key owner email.
Success:
{
"ok": true,
"created": true,
"handle": "alice",
"goto": "alice@example.org"
}Rules:
- handle must be a valid local part.
- handle must not be banned by name.
- owner email must not be banned.
- handle must not already exist.
- active alias with same local part blocks the handle.
POST /api/handle/deleteClick to copy this anchor link.
Body:
{
"handle": "alice"
}Success:
{
"ok": true,
"updated": true,
"handle": "alice",
"active": false
}Rules:
- handle must exist and be active.
- handle owner address must equal the API-key owner email.
- deletion deactivates; it does not free the handle for reuse.
POST /api/handle/domain/disableClick to copy this anchor link.
Body:
{
"handle": "alice",
"domain": "thc.org"
}Success:
{
"ok": true,
"updated": true,
"handle": "alice",
"domain": "thc.org",
"disabled": true
}POST /api/handle/domain/enableClick to copy this anchor link.
Body:
{
"handle": "alice",
"domain": "thc.org"
}Success:
{
"ok": true,
"updated": true,
"handle": "alice",
"domain": "thc.org",
"disabled": false
}Both domain-rule routes require handle ownership through the API key.
Browser/admin auth routesClick to copy this anchor link.
These routes are for the admin/browser surface. They use cookies. They are not API-key routes.
Cookie names:
__Host-access
__Host-refreshIn APP_ENV=prod, cookies are Secure. All auth cookies are HttpOnly, path /, with configured SameSite.
POST /api/auth/sign-inClick to copy this anchor link.
Body:
{
"identifier": "admin@example.org",
"password": "CorrectHorseBatteryStaple1"
}identifier can be an email or username.
Success:
{
"ok": true,
"action": "sign_in",
"authenticated": true,
"user": {
"id": 7,
"username": "admin",
"email": "admin@example.org",
"email_verified_at": "2026-06-19T12:00:00.000Z",
"is_active": 1,
"is_admin": true,
"created_at": "2026-06-19T12:00:00.000Z",
"updated_at": "2026-06-19T12:00:00.000Z",
"last_login_at": "2026-06-19T12:00:00.000Z"
},
"session": {
"session_family_id": "family-id",
"access_expires_at": "2026-06-19T12:10:00.000Z",
"refresh_expires_at": "2026-07-19T12:00:00.000Z"
}
}Failure:
{ "error": "auth_failed" }The service deliberately runs a slow dummy password verification when the user is missing or input is bad. Timing games get less signal.
GET /api/auth/sessionClick to copy this anchor link.
Requires valid access cookie.
Response:
{
"ok": true,
"authenticated": true,
"user": {
"id": 7,
"username": "admin",
"email": "admin@example.org",
"is_admin": true
},
"session": {
"session_family_id": "family-id",
"access_expires_at": "2026-06-19T12:10:00.000Z",
"refresh_expires_at": "2026-07-19T12:00:00.000Z"
}
}GET /api/auth/csrfClick to copy this anchor link.
Requires access or refresh session.
Response:
{
"ok": true,
"csrf_token": "base64url-hmac"
}The CSRF token is HMAC-SHA256 of the session family id using AUTH_CSRF_SECRET.
POST /api/auth/refreshClick to copy this anchor link.
Requires refresh cookie and header:
X-CSRF-Token: <csrf_token>Response:
{
"ok": true,
"action": "refresh",
"refreshed": true,
"session": {
"session_family_id": "family-id",
"access_expires_at": "2026-06-19T12:10:00.000Z",
"refresh_expires_at": "2026-07-19T12:00:00.000Z"
}
}POST /api/auth/sign-outClick to copy this anchor link.
Requires access or refresh session plus CSRF.
Response:
{
"ok": true,
"action": "sign_out",
"signed_out": true
}POST /api/auth/sign-out-allClick to copy this anchor link.
Requires access or refresh session plus CSRF.
Response:
{
"ok": true,
"action": "sign_out_all",
"signed_out_all": true,
"sessions_revoked": 4
}POST /api/auth/forgot-passwordClick to copy this anchor link.
Body:
{
"email": "admin@example.org"
}Response is the same whether the account exists or not:
{
"ok": true,
"action": "forgot_password",
"accepted": true,
"recovery": {
"ttl_minutes": 15
}
}POST /api/auth/reset-passwordClick to copy this anchor link.
Body:
{
"token": "123456",
"new_password": "CorrectHorseBatteryStaple1"
}Success:
{
"ok": true,
"action": "reset_password",
"updated": true,
"reauth_required": true,
"sessions_revoked": 4,
"user": {
"id": 7,
"email": "admin@example.org"
}
}Password length accepted by service:
8..256 charsAdmin routesClick to copy this anchor link.
Every /api/admin/* route requires an access session for a user with is_admin = 1.
Every admin mutation requires:
X-CSRF-Token: <csrf_token>Mutation methods:
POST
PATCH
DELETEAuth failures:
{ "error": "invalid_or_expired_session" }{ "error": "forbidden" }CSRF failures:
{ "error": "csrf_required" }{ "error": "invalid_csrf_token" }Minimal cURL admin session flow:
BASE='https://mail.thc.org/api'
COOKIE_JAR="$(mktemp)"
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-X POST "$BASE/auth/sign-in" \
-H 'Content-Type: application/json' \
-d '{"identifier":"admin@example.org","password":"CorrectHorseBatteryStaple1"}'
CSRF="$(
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE/auth/csrf" |
sed -n 's/.*"csrf_token":"\([^"]*\)".*/\1/p'
)"
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE/admin/me"Using sed to parse JSON is ugly. It is in the example because POSIX shells are ugly too. Use jq if you have it:
CSRF="$(curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" "$BASE/auth/csrf" | jq -r .csrf_token)"GET /api/admin/meClick to copy this anchor link.
Response:
{
"ok": true,
"authenticated": true,
"admin": {
"id": 7,
"username": "admin",
"email": "admin@example.org",
"is_active": 1,
"is_admin": true
},
"session": {
"session_family_id": "family-id",
"access_expires_at": "2026-06-19T12:10:00.000Z",
"refresh_expires_at": "2026-07-19T12:00:00.000Z"
}
}GET /api/admin/protectedClick to copy this anchor link.
Tiny probe endpoint.
{
"message": "This user is an administrator"
}Admin domainsClick to copy this anchor link.
Domain row:
{
"id": 1,
"name": "thc.org",
"active": 1,
"active_mx": 1,
"active_ui": 0,
"visible": 1
}Field meaning:
active: admin gate. If0, the domain is off.active_mx: EMAIL DNS approval gate.active_ui: UI DNS approval gate.visible: public listing/stats gate.
Admin create/update cannot set active_mx or active_ui. New admin-created domains start with:
active_mx = 0
active_ui = 0DNS approval belongs to DNS validation, not to an admin pretending the wire is fine.
Routes:
| Method | Path | Query/body | Response |
|---|---|---|---|
| GET | /api/admin/domains | limit, offset, active, visible, name | { items, pagination } |
| GET | /api/admin/domains/:id | id path param | { item } |
| POST | /api/admin/domains | { name, active?, visible? } | { ok, created, item } |
| PATCH | /api/admin/domains/:id | { name?, active?, visible? } | { ok, updated, item } |
| DELETE | /api/admin/domains/:id | none | { ok, deleted, item } |
| POST | /api/admin/domains/recheckdns/all | none | DNS Checker relay |
| POST | /api/admin/domains/:id/recheckdns | none | DNS Checker relay |
Admin domain delete physically deletes the domain row. It is not the same style of soft deactivation used by alias deletion.
Create example:
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-X POST "$BASE/admin/domains" \
-H "X-CSRF-Token: $CSRF" \
-H 'Content-Type: application/json' \
-d '{"name":"example.com","active":1,"visible":1}'Success:
{
"ok": true,
"created": true,
"item": {
"id": 10,
"name": "example.com",
"active": 1,
"active_mx": 0,
"active_ui": 0,
"visible": 1
}
}Common domain admin errors:
{ "error": "domain_not_found", "id": 10 }{ "error": "domain_taken", "name": "example.com" }{ "error": "invalid_params", "reason": "empty_patch" }Admin aliasesClick to copy this anchor link.
Alias row:
{
"id": 100,
"address": "research@thc.org",
"goto": "alice@example.org",
"active": 1,
"domain_id": 1,
"created": "2026-06-19T12:00:00.000Z",
"modified": "2026-06-19T12:00:00.000Z"
}Routes:
| Method | Path | Query/body | Response |
|---|---|---|---|
| GET | /api/admin/aliases | limit, offset, active, goto, domain, handle, address | { items, pagination } |
| GET | /api/admin/aliases/:id | id path param | { item } |
| POST | /api/admin/aliases | { address, goto, active? } | { ok, created, item } |
| PATCH | /api/admin/aliases/:id | { address?, goto?, active? } | { ok, updated, item } |
| DELETE | /api/admin/aliases/:id | none | { ok, deleted, item } |
Rules:
addressandgotomust parse as mailboxes.- alias domain must be EMAIL-valid.
- active reserved handle blocks alias creation/update.
- delete deactivates the alias and sets the sink
goto; it does not physically delete the row.
Admin handlesClick to copy this anchor link.
Routes:
| Method | Path | Query/body | Response |
|---|---|---|---|
| GET | /api/admin/handles | limit, offset, active, handle, address | { items, pagination } |
| GET | /api/admin/handles/:id | id path param | { item } |
| POST | /api/admin/handles | { handle, address, active? } | { ok, created, item } |
| PATCH | /api/admin/handles/:id | { handle?, address?, active? } | { ok, updated, item } |
| DELETE | /api/admin/handles/:id | none | { ok, deleted, item } |
Admin handle row:
{
"id": 20,
"handle": "alice",
"address": "alice@example.org",
"active": 1
}Important difference:
- Public and API-key handle creation check for active alias local-part collisions.
- Admin handle creation does not check active alias local-part collisions in this service.
- That gives admin enough rope. Use it like you know what rope does.
- Admin handle delete physically deletes the
alias_handlerow. Public/API-key handle delete deactivates ownership and preserves the reservation.
Admin bansClick to copy this anchor link.
Ban types:
email
domain
ip
nameRoutes:
| Method | Path | Query/body | Response |
|---|---|---|---|
| GET | /api/admin/bans | limit, offset, active, ban_type, ban_value | { items, pagination } |
| GET | /api/admin/bans/:id | id path param | { item } |
| POST | /api/admin/bans | { ban_type, ban_value, reason?, expires_at?, disable_matching_aliases? } | { ok, created, item, disabled_aliases, message } |
| PATCH | /api/admin/bans/:id | { ban_type?, ban_value?, reason?, expires_at?, revoked?, revoked_reason? } | { ok, updated, item } |
| DELETE | /api/admin/bans/:id | none | { ok, deleted, item } |
Create example:
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" \
-X POST "$BASE/admin/bans" \
-H "X-CSRF-Token: $CSRF" \
-H 'Content-Type: application/json' \
-d '{"ban_type":"domain","ban_value":"bad.example","reason":"abuse","disable_matching_aliases":true}'Success:
{
"ok": true,
"created": true,
"item": {
"id": 30,
"ban_type": "domain",
"ban_value": "bad.example",
"reason": "abuse",
"created_at": "2026-06-19T12:00:00.000Z",
"expires_at": null,
"revoked_at": null,
"revoked_reason": null,
"active": true
},
"disabled_aliases": 4,
"message": "Ban created. Also, 4 matching aliases were disabled."
}disable_matching_aliases is not supported for IP bans:
{
"error": "invalid_params",
"field": "disable_matching_aliases",
"reason": "not_supported_for_ip_bans"
}Ban matching behavior:
- email ban matches exact destination email.
- domain ban matches domain suffixes for destinations and domain targets.
- name ban matches alias/handle local part.
- IP ban matches IPv4, IPv4-mapped IPv6, and IPv6 forms through
INET6_ATON.
Admin API tokensClick to copy this anchor link.
Routes:
| Method | Path | Query/body | Response |
|---|---|---|---|
| GET | /api/admin/api-tokens | limit, offset, active, owner_email, status | { items, pagination } |
| GET | /api/admin/api-tokens/:id | id path param | { item } |
| POST | /api/admin/api-tokens | { owner_email, days?, user_agent? } | { ok, created, token, token_type, item } |
| PATCH | /api/admin/api-tokens/:id | { owner_email?, status?, expires_at?, revoked?, revoked_reason? } | { ok, updated, item } |
| DELETE | /api/admin/api-tokens/:id | none | { ok, deleted, item } |
Admin API-token delete physically deletes the token row. Public /api/credentials/destroy also deletes the active key row.
Create response includes the plaintext key once:
{
"ok": true,
"created": true,
"token": "64_chars_here",
"token_type": "api_key",
"item": {
"id": 50,
"owner_email": "alice@example.org",
"status": "active",
"created_at": "2026-06-19T12:00:00.000Z",
"expires_at": "2026-07-19T12:00:00.000Z",
"revoked_at": null,
"revoked_reason": null,
"created_ip": "203.0.113.10",
"user_agent": "curl/8",
"last_used_at": null,
"automatic_renew": 0,
"active": true
}
}Allowed status values:
active
revoked
expiredrevoked: 1 forces status to revoked and sets revoked_at. revoked: 0 forces status back to active, clears revoked_at, and clears revoked_reason unless a new reason is supplied. If status and revoked contradict each other, the service returns:
{
"error": "invalid_params",
"reason": "status_revoked_conflict"
}Admin DNS requestsClick to copy this anchor link.
Routes:
| Method | Path | Query/body | Response |
|---|---|---|---|
| GET | /api/admin/dns-requests | limit, offset, target, type, status | { items, pagination } |
| GET | /api/admin/dns-requests/:id | id path param | { item } |
| POST | /api/admin/dns-requests | DNS request object | { ok, created, item } |
| PATCH | /api/admin/dns-requests/:id | partial DNS request object | { ok, updated, item } |
| DELETE | /api/admin/dns-requests/:id | none | { ok, deleted, item } |
DNS request object:
{
"target": "example.com",
"type": "UI",
"status": "PENDING",
"activated_at": null,
"last_checked_at": null,
"next_check_at": null,
"last_check_result_json": {
"missing": []
},
"fail_reason": null,
"expires_at": "2026-06-20T12:00:00.000Z"
}Rules:
targetuses the strict domain-target parser.typemust beUIorEMAIL.statusis uppercased and max 16 chars. The admin CRUD layer does not restrict it to the DNS Checker enum.last_check_result_jsonmay be JSON text or a JSON value. It is stored as text and parsed back on output if possible.(target, type)must be unique.
Admin usersClick to copy this anchor link.
Routes:
| Method | Path | Query/body | Response |
|---|---|---|---|
| GET | /api/admin/users | limit, offset, active, is_admin, email | { items, pagination } |
| GET | /api/admin/users/:id | id path param | { item } |
| POST | /api/admin/users | { username, email, password, is_active?, is_admin? } | 201 { ok, created, item } |
| PATCH | /api/admin/users/me/password | { current_password, new_password } | { ok, updated, reauth_required, sessions_revoked } |
| PATCH | /api/admin/users/:id | { username?, email?, password?, is_active?, is_admin? } | { ok, updated, sessions_revoked, item } |
| DELETE | /api/admin/users/:id | none | { ok, deleted, sessions_revoked, item } |
Public user shape:
{
"id": 7,
"username": "admin",
"email": "admin@example.org",
"email_verified_at": "2026-06-19T12:00:00.000Z",
"is_active": 1,
"is_admin": true,
"created_at": "2026-06-19T12:00:00.000Z",
"updated_at": "2026-06-19T12:00:00.000Z",
"last_login_at": null
}Hard admin rules:
- Username must be 3..64 chars, lowercase normalized, and match the username parser.
- Password must be 8..256 chars.
- Creating a user defaults
is_active = 1andis_admin = 1. - Email and username must be unique.
- You cannot disable the last active admin.
- You cannot demote the last active admin.
- You cannot change your own password through
PATCH /api/admin/users/:id; use/api/admin/users/me/password. - Updating password, disabling a user, or changing admin status revokes sessions for that user.
- Deleting a user physically deletes the user and removes password reset tokens and auth sessions.
Self password route response:
{
"ok": true,
"updated": true,
"reauth_required": true,
"sessions_revoked": 4
}If the new password equals the current password:
{
"error": "invalid_params",
"field": "new_password",
"reason": "same_as_current"
}Counter routeClick to copy this anchor link.
GET /api/counter/incrementClick to copy this anchor link.
This is not for public clients. It increments the forwarded-mail counter.
Query:
key=<COUNTER_SECRET_KEY>Success:
{
"success": true
}Bad or missing key uses Nest's UnauthorizedException body:
{
"message": "Unauthorized",
"statusCode": 401,
"error": "Unauthorized"
}Rate limitsClick to copy this anchor link.
The middleware applies route-specific counters. Redis is used when configured; otherwise counters live in process memory. In-memory counters are not shared across instances. That is fine for a single box and weak for a fleet. No magic.
The default global limit is:
RL_GLOBAL_PER_MIN=300Important route buckets:
| Route | Bucket | Default |
|---|---|---|
GET /api/forward/subscribe | per IP, 10 min | 60 |
GET /api/forward/subscribe | per destination email, 1 hour | 6 |
GET /api/forward/subscribe | per alias, 1 hour | 20 |
GET/POST /api/forward/confirm | per IP, 10 min | 120 |
GET/POST /api/forward/confirm | per token, 10 min | 10 |
| forwarding subscribe + confirm | shared per IP, 1 hour | 10 |
GET /api/forward/unsubscribe | per IP, 10 min | 40 |
GET /api/forward/unsubscribe | per alias address, 1 hour | 6 |
POST /api/request/ui | per IP, 1 min | 60 |
POST /api/request/ui | per target, 10 min | 20 |
POST /api/request/email | per IP, 10 min | 20 |
POST /api/request/email | per target, 1 hour | 3 |
GET /api/checkdns/:target | per target, 10 min | 30 |
POST /api/credentials/create | per IP, 1 hour | 10 |
POST /api/credentials/create | per email, 1 hour | 3 |
POST /api/credentials/confirm | per IP, 10 min | 60 |
POST /api/credentials/confirm | per token, 10 min | 5 |
POST /api/auth/sign-in | failed attempts per IP, 15 min | 12 |
POST /api/auth/sign-in | failed attempts per identifier, 1 hour | 6 |
POST /api/auth/sign-in | failed attempts per identifier+IP, 6 hours | 3 |
GET /api/alias/list | per API key, 1 min | 600 |
POST /api/alias/create | per API key, 1 min | 120 |
POST /api/alias/delete | per API key, 1 min | 120 |
GET /api/handle/subscribe | per IP, 10 min | 60 |
GET /api/handle/subscribe | per destination email, 1 hour | 6 |
GET /api/handle/subscribe | per handle, 1 hour | 20 |
POST /api/handle/create | per API key, 1 min | 120 |
POST /api/handle/delete | per API key, 1 min | 120 |
Slow-down rules also exist for subscribe, confirm, unsubscribe, and handle request flows. They sleep before responding after the threshold. If your client hammers the endpoint and then complains it is slow, the machine is not confused. You are.
Bot-origin bucketing:
- If
BOT_AUTH_TOKENis configured, and the request sends a matchingX-Bot-Auth-Token, andX-Bot-Sourceis present, origin-based limits useX-Bot-Source. - If the bot token is missing or wrong, the request is ordinary IP traffic.
X-Bot-Sourceis sliced to 256 chars for the rate-limit key.
CORS and whitelabel behaviorClick to copy this anchor link.
Requests without an Origin header are not blocked by CORS. Terminal clients usually do not care.
Browser CORS is stricter:
CORS_ALLOWED_ORIGINSis a comma-separated allowlist.APP_PUBLIC_URLis also allowed.- Wildcard
*is rejected at startup. - Only
httpandhttpsorigins are valid. file://,null, and wildcard-style origins are rejected.
Whitelabel use is usually same-origin:
https://your-domain/apiSo the browser talks to its own origin and CORS is not the main fight. DNS and proxying are.
Data semanticsClick to copy this anchor link.
DomainClick to copy this anchor link.
The API reads the domain table as the gate for public mail domains.
Important columns:
id
name
active
active_mx
active_ui
visibleFor public email alias domains:
active = 1
active_mx = 1
visible = 1For UI whitelabel API availability, active_ui = 1 matters to the surrounding DNS/proxy workflow, but this API does not auto-create Caddy routes. It exposes /api; the infrastructure routes traffic.
AliasClick to copy this anchor link.
Aliases live in alias.
Important columns:
address
goto
active
created
modifiedDeletion from user/API flows is deactivation:
goto = alias@haltman.io
active = 0Admin user deletion is physical. Alias deletion is usually not.
HandleClick to copy this anchor link.
Handles live in alias_handle.
Important columns:
handle
address
active
unsubscribed_atDisabled handle domains live in alias_handle_disabled_domain.
Important columns:
handle_id
domain
active
modified_atAPI tokensClick to copy this anchor link.
Plain API keys are never stored. The server stores SHA-256 hashes in api_tokens.
Active key criteria:
status = active
revoked_at IS NULL
expires_at > NOW(6)Email confirmationsClick to copy this anchor link.
Six-digit tokens are stored as SHA-256 hashes. A token is valid only while:
status = pending
expires_at > NOW(6)Confirmation rows carry intent:
subscribe
subscribe_address
unsubscribe
handle_subscribe
handle_unsubscribe
handle_domain_disable
handle_domain_enableUnknown intent returns unsupported_intent.
What this API does not supportClick to copy this anchor link.
No Swagger/OpenAPI route is present in this codebase.
No bearer-token API-key auth exists.
No GraphQL exists.
No bulk alias creation endpoint exists.
No public route lists all aliases globally.
No public route reveals handle ownership.
No public route returns an API key after the creation confirmation response. Miss the key and you request another.
No admin mutation works without CSRF just because the cookie is valid.
No DNS Checker write should be considered active until DNS Checker state says so. Database rows are not DNS.
No URL-style DNS target is accepted. Send example.com, not https://example.com.
Other componentsClick to copy this anchor link.
This file documents this API. The surrounding system has its own repositories and its own documents. Read them when the question crosses the API boundary.
- haltman-io/mail-forwarding - overview, explanation, history, context, decisions.
- haltman-io/mail-forwarding-docs - documentation hub.
- haltman-io/mail-forwarding-ui - public UI.
- haltman-io/mail-forwarding-api - this API.
- haltman-io/mail-forwarding-dns-checker - DNS request creation, checking, status, UI/EMAIL activation.
- haltman-io/mail-forwarding-bot - bot clients and bot-authenticated rate-limit source handling.
- haltman-io/mail-forwarding-addon-mozilla-firefox - Firefox add-on.
- haltman-io/mail-forwarding-addon-google-chrome - Chrome add-on.
- haltman-io/mail-forwarding-core - mail stack, MTA behavior, forwarding mechanics.
- haltman-io/mail-forwarding-counter - forwarding counter component.
- haltman-io/mail-forwarding-ui-saas - SaaS UI surface.
- haltman-io/mail-forwarding-dkim-sync - DKIM sync.
Final notes for client authors
Build clients against observed status codes and error strings. Do not parse English text. Do not depend on object key order. Do not assume fields returned by the DNS Checker are frozen; this API deliberately relays upstream payloads.
Use the smallest mutation that expresses the action. Confirm by reading back state when the operation matters.
The API is blunt, mostly JSON, and hostile to sloppy input. That is the correct shape for a mail control plane.