Skip to content

API reference

Status: implemented (auth + domains)

The backend exposes a tRPC API mounted at /trpc. The frontend proxies requests to the Worker in dev (/trpchttp://localhost:8787).

Conventions

  • Transport: tRPC over HTTP (fetch adapter)
  • Auth: session cookie (session, httpOnly). Send cookies with credentials: '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).

appCodeHTTPMessage
AUTH_EMAIL_ALREADY_REGISTERED409Email already registered
AUTH_INVALID_CREDENTIALS401Invalid email or password
AUTH_UNAUTHORIZED401Unauthorized
DOMAIN_NOT_FOUND404Domain not found
DOMAIN_INVALID_HOSTNAME400Invalid hostname
DOMAIN_ALREADY_ADDED409You have already added this domain
DOMAIN_VERIFICATION_SUPERSEDED409Another user verified this hostname first
CLAIM_NOT_APPLICABLE400Domain is not eligible for claiming
CLAIM_IN_PROGRESS409Another 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"
}
FieldTypeRules
emailstringvalid email
passwordstringmin 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 DomainDetail with claim: null.
  • On miss within the window: stays pending; updates dnsCheckedAt only.
  • Window elapsed (expiresAt): marks claim + claimant failed.
  • Retry after failed: revives the same claim with the same code, a new expiresAt, refreshed incumbent, reset verificationStartedAt, and cleared dnsCheckedAt, then continues polling.

Normal verification path

  • Starts verification when status is not_started, failed, or pending with verificationStartedAt null (the usual case after domains.create, which inserts pending without a start timestamp): sets verificationStartedAt to now, status → pending, and forces an immediate DNS check.
  • Already verified with all required records verified: returns current DomainDetail without a lookup.
  • Already verified with newly required records incomplete (e.g. after enabling receiving): runs an immediate DNS check that updates dnsRecords / dnsCheckedAt only — does not change status or verifiedAt (ownership stays verified).
  • Pending but outside DOMAIN_VERIFICATION_WINDOW_MS: marks failed and returns.
  • Always runs an immediate DNS check on first start, restart from failed, or when dnsCheckedAt is null — the response includes the result of that lookup (not a deferred/cron-only poll).
  • Later calls within DNS_POLL_INTERVAL_MS of the last check may return the cached result without re-querying DNS; cron uses the same interval.
  • Updates per-record status and dnsCheckedAt when a lookup runs.
  • Sets domain to verified when 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; calling domains.verify again 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_records to not_started (e.g. inbound_mx when enabling receiving).
  • Does not demote verifiedpending (that would free the ownership slot for claims). Call domains.verify to check newly required records while staying verified.
  • Rejects revoked rows as DOMAIN_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 / get and free the hostname ownership slot (partial unique index excludes deleted_at IS NOT NULL).
  • The same user can re-add the hostname afterward via domains.create.

Output: { "ok": true }

Errors: DOMAIN_NOT_FOUND