AUTH4D-168 custom sending domain
A project can send its verification code emails from its own domain, for example
noreply@mail.example.com, instead of the deployment’s EMAIL_FROM address. The domain is
registered with the email provider (Resend), which returns the DNS records to publish (SPF and
return path on the send subdomain, and DKIM) and reports the verification status. Emails use the
project’s address only while the status is verified. Every other status, a missing domain, a
lookup failure or a provider refusal at send time falls back to EMAIL_FROM automatically. The
sender name, subject and wording from AUTH4D-150 (repository file docs/api-spec/auth4d-150.md) apply either way.
The domain belongs to the project (tenant), so it applies to every application in it. Control-plane
(console) sign-in emails never use it. Wire shapes are SendingDomainSchema,
SendingDomainResponseSchema, CreateSendingDomainRequestSchema and
UpdateSendingDomainRequestSchema in packages/contracts. Rows are stored in sending_domains
(migration 0030).
Routes
All routes are under /management/tenants/{tenantId}/sending-domain and need a management token.
Responses are JSON with Cache-Control: no-store.
| Method | Path | Scope | Role | Success |
|---|---|---|---|---|
| GET | /management/tenants/{tenantId}/sending-domain |
tenant:read |
any | 200 |
| POST | /management/tenants/{tenantId}/sending-domain |
tenant:write |
owner | 201 |
| PATCH | /management/tenants/{tenantId}/sending-domain |
tenant:write |
owner | 200 |
| POST | /management/tenants/{tenantId}/sending-domain/verify |
tenant:write |
owner or admin | 202 |
| DELETE | /management/tenants/{tenantId}/sending-domain |
tenant:write |
owner | 200 |
POST …/verify and DELETE take an empty JSON object ({}). Writes on a suspended project return
409 project_suspended; other developers’ projects return 403 forbidden, like unknown projects.
A role that is not allowed gets 403 insufficient_role and a denied audit event.
Read
{
"sending_domain": {
"domain": "mail.example.com",
"local_part": "noreply",
"sender_address": "noreply@mail.example.com",
"status": "pending",
"in_use": false,
"records": [
{
"record": "SPF",
"type": "MX",
"name": "send",
"value": "feedback-smtp.us-east-1.amazonses.com",
"priority": 10,
"ttl": "Auto",
"status": "pending"
},
{
"record": "SPF",
"type": "TXT",
"name": "send",
"value": "\"v=spf1 include:amazonses.com ~all\"",
"ttl": "Auto",
"status": "pending"
},
{
"record": "DKIM",
"type": "TXT",
"name": "resend._domainkey",
"value": "p=MIGfMA0…",
"ttl": "Auto",
"status": "pending"
}
],
"created_at": "2026-10-07T09:12:00.000Z",
"checked_at": "2026-10-07T09:14:20.000Z"
}
}
A project without a domain returns { "sending_domain": null }. Record name is relative to the
domain, as Resend reports it. in_use is true only for verified. verified_at is present once
the domain has been verified.
status is one of Resend’s domain statuses: not_started, pending, verified,
partially_verified, partially_failed, failed or temporary_failure. An unknown provider
status is stored as pending, never as verified.
Polling. A read refreshes the status from the provider when the last check is older than 15
seconds for not_started, pending, partially_verified and temporary_failure, and 10 minutes
otherwise. The console polls the read every 20 seconds while the status can still change. If the
provider cannot be reached, the read returns the last stored status. The lifecycle cron
(*/10 * * * *) also re-checks up to 25 domains per run (changing statuses after 5 minutes, settled
ones about hourly), so a domain whose DNS records disappear stops being used without anyone opening
the console. A domain the provider no longer has (for example deleted in its dashboard) becomes
failed.
Add
{ "domain": "mail.example.com", "local_part": "noreply" }
domainis trimmed and lowercased. It must be an ASCII host name (use punycode for IDNs) with at least two labels and an alphabetic top-level label, at most 253 characters, without a scheme, path, port or@.auth4.devand its subdomains are reserved.local_partis optional (defaultnoreply): 1–64 lowercase letters, digits,.,_or-, starting and ending with a letter or digit, without...- Failures:
400 validation_failed(details[].fieldisdomainorlocal_part, also used when the provider rejects the name),409 sending_domain_exists(the project already has one; remove it first),409 domain_unavailable(another project or the provider already holds the domain; the answer does not say which; a domain never verified 7 days after it was added does not count, see AUTH4D-339),429 rate_limited(10 adds per project per day),503 sending_domains_unavailable(no provider is configured on this deployment; AUTH4D-200),503 temporarily_unavailable(provider unreachable). Nothing is stored after any failure. - The new domain starts unverified (usually
not_started) and is not used until it is verified.
Change the sender address
PATCH with { "local_part": "play" }. Saving the current value again is a no-op without an audit
event. 404 not_found if the project has no domain.
Check DNS now
POST …/verify asks the provider to check the DNS records now (Resend POST /domains/{id}/verify),
then reads the status. Answers 202 with the current document. Limited to 30 per project per hour.
Remove
DELETE deletes the domain at the provider first and then the row, and returns
{ "sending_domain": null }. Emails fall back to EMAIL_FROM at once. If the provider cannot be
reached the answer is 503 and the domain stays, so it can never be claimed by another project while
the old DKIM keys still exist.
Uniqueness and ownership
A domain belongs to at most one project (UNIQUE (domain)), and a project has at most one domain
(UNIQUE (tenant_id)). Removing a domain deletes it at the provider, so when another project (or the
same one) adds it again the provider creates a new domain with new DKIM keys. It starts unverified
and needs new DNS records and a new verification; nothing carries over from the previous owner.
Sending
When the API enqueues a verification email it reads the project’s domain by tenant id, joined to a
customer tenant, with status = 'verified'. The address goes into the encrypted outbox payload as
senderAddress and is checked against the contract again at delivery. The Resend adapter sends
From: "{sender_name}" <local_part@domain> (or the bare address without a sender name). If Resend
definitely refuses that sender (a non-retryable 4xx, for example after the domain stopped being
verified), the adapter sends once more from EMAIL_FROM under the idempotency key
{key}/fallback. Retryable failures keep the project’s sender, so a lost response can never produce
two emails.
Audit
All events have target type sending_domain and record the domain name, never DNS values or keys.
| Event | Metadata |
|---|---|
management.sending_domain.created |
domain, local_part, actor_role |
management.sending_domain.updated |
domain, changed_fields, actor_role |
management.sending_domain.verification_requested |
domain, actor_role |
management.sending_domain.status_changed |
domain, from, to (no actor when scheduled) |
management.sending_domain.removed |
domain, actor_role |
Provider and secrets
apps/api/src/runtime/email.ts picks the provider: Resend when RESEND_DOMAINS_API_KEY is set, the
deterministic local provider (createLocalSendingDomains in packages/email/src/domains.ts) in the
local environment, and none otherwise. Without a provider, add, check and remove answer
503 sending_domains_unavailable without calling Resend, and reads show the stored state. The
sending key RESEND_API_KEY is never used for the Domains API (AUTH4D-200).
Tests use the local provider and mock Resend over HTTP; no test calls Resend.
Owner action. Confirm that the Resend plan allows enough domains for the expected projects.
Resend’s Domains API needs a full-access key, and the deployed RESEND_API_KEY is a sending-only key
restricted to noreply.auth4.dev. Create a full-access key, set it as the optional Worker secret
RESEND_DOMAINS_API_KEY with infra/cloudflare/secrets.mjs --from-env, and replace RESEND_API_KEY
with a sending-access key that is not restricted to one domain (--replace RESEND_API_KEY). Verify
with secrets.mjs --status. Until then, adding a domain answers 503 sending_domains_unavailable and all emails keep
using EMAIL_FROM.
Known limits
- One domain per project, and verification emails only (billing and invitation emails keep
EMAIL_FROM). - When a project is deleted, its row goes with the tenant but the Resend domain stays. Adding that
domain to another project then answers
409 domain_unavailableuntil the owner deletes it in Resend.
Source: docs/api-spec/auth4d-168.md