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" }
  • domain is 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.dev and its subdomains are reserved.
  • local_part is optional (default noreply): 1–64 lowercase letters, digits, ., _ or -, starting and ending with a letter or digit, without ...
  • Failures: 400 validation_failed (details[].field is domain or local_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_unavailable until the owner deletes it in Resend.

Source: docs/api-spec/auth4d-168.md