Appearance
API reference
Status: implemented (auth + domains)
The backend exposes a tRPC API mounted at /trpc. The frontend proxies requests to the Worker in dev (/trpc → http://localhost:8787).
Conventions
- Transport: tRPC over HTTP (
fetchadapter) - Auth: session cookie (
session, httpOnly). Send cookies withcredentials: 'include'. - Mutations:
POST /trpc/<procedure>with JSON body - Queries:
GET /trpc/<procedure> - Procedures are namespaced:
auth.*,domains.*
Error shape
App errors include a stable appCode in error.data (see @send-again/shared).
| appCode | HTTP | Message |
|---|---|---|
AUTH_EMAIL_ALREADY_REGISTERED | 409 | Email already registered |
AUTH_INVALID_CREDENTIALS | 401 | Invalid email or password |
AUTH_UNAUTHORIZED | 401 | Unauthorized |
DOMAIN_NOT_FOUND | 404 | Domain not found |
DOMAIN_INVALID_HOSTNAME | 400 | Invalid hostname |
DOMAIN_ALREADY_ADDED | 409 | You have already added this domain |
DOMAIN_VERIFICATION_SUPERSEDED | 409 | Another user verified this hostname first |
CLAIM_NOT_APPLICABLE | 400 | Domain is not eligible for claiming |
CLAIM_IN_PROGRESS | 409 | Another claim is already in progress for this domain |
Validation errors (Zod) return 400 with field-level details.
Auth
All auth procedures live under the auth router.
mutation auth.register (no auth)
Create an account and start a session.
Input
json
{
"email": "user@example.com",
"password": "minimum8"
}| Field | Type | Rules |
|---|---|---|
email | string | valid email |
password | string | min 8 characters |
Output
json
{
"id": "abc123",
"email": "user@example.com"
}Also sets Set-Cookie: session=… (30-day session).
Errors: AUTH_EMAIL_ALREADY_REGISTERED
mutation auth.login (no auth)
Sign in with email and password.
Input — same as auth.register
Output — same as auth.register
Also sets Set-Cookie: session=….
Errors: AUTH_INVALID_CREDENTIALS
mutation auth.logout (no auth)
End the current session.
Input — none
Output
json
{
"success": true
}Clears the session cookie and deletes the session row when present.
query auth.me (no auth)
Return the currently authenticated user, or null.
Input — none
Output
json
{
"id": "abc123",
"email": "user@example.com"
}or null when not signed in.
Domains
All domain procedures live under the domains router and require authentication.
query domains.list (auth required)
List domains for the current user.
status is the user's domain status, except active claim rows are returned as pending_claim.
Output (simplified)
json
[
{
"id": "…",
"hostname": "example.com",
"status": "pending_claim",
"region": "us-east-1",
"dnsProvider": "cloudflare",
"createdAt": "2026-02-20T12:30:00.000Z"
}
]query domains.get (auth required)
Get one domain with DNS record status for the verification UI. Record name and value fields are computed at response time (not stored in DB); see domain-model.md. sendingReady / receivingReady are also computed (ownership vs readiness — ADR 011).
Input: { "id": "…" }
Output (simplified)
json
{
"id": "…",
"hostname": "example.com",
"status": "pending",
"region": "us-east-1",
"dnsProvider": "cloudflare",
"dnsProviderCheckedAt": "2026-02-20T12:30:00.000Z",
"sendingEnabled": true,
"receivingEnabled": false,
"sendingReady": false,
"receivingReady": false,
"verificationStartedAt": "2026-02-20T12:30:00.000Z",
"dnsCheckedAt": "2026-02-20T12:35:00.000Z",
"verifiedAt": null,
"createdAt": "2026-02-20T12:30:00.000Z",
"updatedAt": "2026-02-20T12:35:00.000Z",
"dnsRecords": [
{
"type": "dkim_txt",
"name": "sendagain._domainkey",
"value": "p=MIGfMA0GCSqGSIb3…",
"status": "pending",
"lastError": null
}
],
"claim": {
"claimId": "claim_abc123",
"status": "pending",
"expiresAt": "2026-02-20T18:30:00.000Z",
"verificationStartedAt": "2026-02-20T12:30:00.000Z",
"dnsRecord": {
"name": "@",
"value": "sendagain-claim=abc123"
},
"lastError": null
}
}claim is null when the domain is not in a claim flow or once the claim has succeeded.
mutation domains.create (auth required)
Add a domain the user can verify.
Input (simplified)
json
{
"hostname": "example.com",
"region": "us-east-1"
}Output: DomainDetail. When the hostname is open, claim is null and the user verifies normal SPF/DKIM/MX records. When another user already verified the hostname, the same mutation starts a claim (or refreshes an existing pending claim) and returns DomainDetail.claim with the apex TXT challenge.
Errors: DOMAIN_ALREADY_ADDED, DOMAIN_INVALID_HOSTNAME, CLAIM_IN_PROGRESS, CLAIM_NOT_APPLICABLE
mutation domains.verify (auth required)
Run DNS verification for a domain synchronously and return DomainDetail. When a relevant claim exists and is not verified, this mutation runs the claim path (apex TXT) instead of email DNS records.
Input: { "id": "…" } (user_domain id)
Output: DomainDetail (includes claim when in a claim flow; claim is null after a successful claim or for normal verification).
Claim path
Taken when the user_domain has a relevant claim that is not verified:
- Polls the apex TXT challenge (
sendagain-claim={code}) against DNS. - On match: atomically revokes the incumbent, verifies the claimant, rotates DKIM, and returns
DomainDetailwithclaim: null. - On miss within the window: stays
pending; updatesdnsCheckedAtonly. - Window elapsed (
expiresAt): marks claim + claimantfailed. - Retry after
failed: revives the same claim with the samecode, a newexpiresAt, refreshed incumbent, resetverificationStartedAt, and cleareddnsCheckedAt, then continues polling.
Normal verification path
- Starts verification when status is
not_started,failed, orpendingwithverificationStartedAtnull (the usual case afterdomains.create, which insertspendingwithout a start timestamp): setsverificationStartedAtto now, status →pending, and forces an immediate DNS check. - Already
verifiedwith all required records verified: returns currentDomainDetailwithout a lookup. - Already
verifiedwith newly required records incomplete (e.g. after enabling receiving): runs an immediate DNS check that updatesdnsRecords/dnsCheckedAtonly — does not changestatusorverifiedAt(ownership stays verified). - Pending but outside
DOMAIN_VERIFICATION_WINDOW_MS: marksfailedand returns. - Always runs an immediate DNS check on first start, restart from
failed, or whendnsCheckedAtis null — the response includes the result of that lookup (not a deferred/cron-only poll). - Later calls within
DNS_POLL_INTERVAL_MSof the last check may return the cached result without re-querying DNS; cron uses the same interval. - Updates per-record status and
dnsCheckedAtwhen a lookup runs. - Sets domain to
verifiedwhen all required records pass (pending path only). Soft-deletes other users' competing pending/not_started/failed rows for the same hostname and emails them that the domain was claimed. - If the verification window ends without success, status becomes
failed; callingdomains.verifyagain restarts the cycle.
Errors: DOMAIN_NOT_FOUND, DOMAIN_VERIFICATION_SUPERSEDED, CLAIM_NOT_APPLICABLE, CLAIM_IN_PROGRESS
mutation domains.updateSettings (auth required)
Update sendingEnabled and/or receivingEnabled on a user_domain.
Input (simplified)
json
{
"id": "…",
"sendingEnabled": true,
"receivingEnabled": true
}At least one of sendingEnabled or receivingEnabled is required. Both may be false (paused domain).
Behavior
- Persists the flags on
user_domains. - Initializes any newly required DNS record keys in
dns_recordstonot_started(e.g.inbound_mxwhen enabling receiving). - Does not demote
verified→pending(that would free the ownership slot for claims). Calldomains.verifyto check newly required records while staying verified. - Rejects
revokedrows asDOMAIN_NOT_FOUND.
Output: DomainDetail
Errors: DOMAIN_NOT_FOUND
mutation domains.delete (auth required)
Soft-delete the current user's user_domain (deleted_at = now). Does not remove the shared domains row.
Input: { "id": "…" } (user_domain id)
Behavior
- Fails any pending claim where this row is the claimant, then sets
deleted_at. - Soft-deleted rows disappear from
list/getand free the hostname ownership slot (partial unique index excludesdeleted_at IS NOT NULL). - The same user can re-add the hostname afterward via
domains.create.
Output: { "ok": true }
Errors: DOMAIN_NOT_FOUND
Related docs
- Domain model — tables, enums, and business flows
- Cron jobs — scheduled DNS verification