Prove you own a domain. Vouch issues a one-time token, you publish it as a DNS TXT record, and Vouch verifies you control the domain's DNS. That is the whole product, and everything in it exists to make that single claim trustworthy and the failures legible.
Live at vouchfor.xyz. Public API docs at /docs.
| Record name | _vouch-challenge.<domain> |
| Record type | TXT |
| Record value | vouch-domain-verify=<token> |
| Token | 256-bit, crypto.randomBytes(32).toString("base64url"), 43 URL and DNS safe characters |
| Claim lifetime | 7 days, evaluated lazily on read. No cron. |
The record name follows the _<provider>-challenge convention recommended by draft-ietf-dnsop-domain-verification-techniques. A dedicated leaf label keeps the token off the apex, where SPF, DKIM and other vendors' strings already collide.
The token encodes nothing. Not the account, not the domain, not a timestamp. Published DNS is world readable, and the draft warns specifically that token construction can leak user-identifying information.
Contention: any number of organisations may hold a pending claim on the same domain. Only one can ever be verified, enforced by a partial unique index. Locking a domain on first claim would be a trivial denial of service, since anyone could start a claim on example.com and never finish it. The DNS proof is self-enforcing: you cannot win a race you have no ability to complete. The loser is told the domain was claimed and never told by whom.
Subdomains are independent. blog.example.com and example.com are separate claims and may be held by different organisations. Controlling a subdomain's DNS does not prove control of the parent zone.
Three steps, worked top to bottom. The full sequence, with action names and routes →
1. Create a claim. One call creates the claim and returns the exact record to publish. There is no separate "get my instructions" step.
2. Publish the record. The claim page shows the record, per-provider setup steps generated from that claim's actual hostname, and a collapsed note about the doubled-suffix trap.
3. Verify. Vouch polls with backoff until the record appears. On a match the claim advances to verified and you get a receipt naming the exact hostname queried, the value found, and which resolvers agreed.
The celebration fires on the transition to verified, never on the state. Returning later to a long-verified domain shows the detail page, not a party.
Eighteen error codes, each carrying two independent fields.
isRetriable asks whether the same call could succeed later. fault asks whose action is required, and it has three values rather than two:
| Fault | Meaning | Example |
|---|---|---|
user |
Someone has to change something | DNS_TOKEN_MISMATCH |
external |
Nobody controls it, it usually clears | DNS_LOOKUP_TIMEOUT |
system |
Ours to fix | INTERNAL_ERROR |
Two values would not survive contact with reality. Calling a DNS timeout the user's fault blames them for physics. Calling it a system fault pages an engineer for a transient hiccup and trains the team to ignore the dashboard within a week.
Both fields live in one table in @vouch/core and are stamped onto every error at construction, so no call site can decide that DNS_NXDOMAIN is retriable in one place and not another.
A DNS check that correctly determines the record is not published yet returns HTTP 200 with success: false.
That is not sloppiness. The request was received and handled completely correctly; the operation it asked for has a negative result. Clients read success, not the status code. Six codes map to 200 for this reason. An integrator who misses it writes error handling that treats normal DNS propagation as an outage.
A purpose-built operator surface at /admin, behind a separate shared-password session distinct from the end-user login. Not drizzle-kit studio, which is a local development tool and is never deployed.
Fleet overview (/admin). Claims by status, recent activity, stuck claims. Every number here excludes simulated activity.
Triage queue (/admin/claims). Stuck claims, filterable by fault. Filtering to fault: user gives you exactly the set where a human is blocked and a support reply would help, separated from the set that will clear on its own.
Claim detail (/admin/claims/[id]). Every verification attempt for one claim: outcome, error code, which resolver answered, and how long it took. This answers "why is this customer stuck" without asking the customer anything.
It also shows both step-2 timestamps. recordAssertedAt is when the user said they added the record; recordFirstSeenAt is when we first observed anything at that name. The gap between them is high signal: "asserted three days ago, never seen" is a different conversation from "seen within a minute, wrong value".
Audit event log. claim_event records actor, event type, severity, trace id, and whether the run was simulated, and the claim detail screen renders it as a timeline. Events are written inside the action executor rather than by each action, because that is the single point every action passes through on every surface: an action author cannot forget to emit one, and a new adapter inherits the audit trail without doing anything.
An operator with an admin session can make any action fail on demand, through an X-Vouch-Simulate header, to demo or debug error handling against a real claim.
Two properties hold absolutely:
Simulation can only ever produce failures. The parameter's type is the error-code union, which has no success member, so "simulate verified" is not expressible without changing an unrelated type signature. A mode that could fake a verified domain would be a verification bypass, and a forged claim is indistinguishable downstream from a real one.
A closed gate fails silent. An unauthorised caller sending the header gets the action run for real, never an error explaining that simulation is unavailable. An error would be an oracle advertising the feature.
Simulated attempts are written to the same audit table real ones are, tagged and badged, and excluded from every metric. Both halves matter: they happened, and they were not real.
@vouch/core is a headless platform. The web UI and the HTTP API are thin adapters over the same six actions and the same executor. The package declares no next dependency and renders no UI, so the boundary is enforced by module resolution rather than by review.
It does depend on react, for one thing only: the sign-in email is a React Email template, rendered to an HTML string and a plain-text string on the server. That is a rendering detail of a transactional email, not a UI framework leaking into the core, and nothing in the package imports a DOM API or a browser primitive.
| Action | Route | Changes state | DNS I/O |
|---|---|---|---|
createDomainClaim |
POST /api/v1/claims |
yes | no |
getDomainClaim |
GET /api/v1/claims/:id |
no | no |
listDomainClaims |
GET /api/v1/claims |
no | no |
checkDnsRecord |
POST /api/v1/claims/:id/check |
no | yes |
verifyAndClaimDomain |
POST /api/v1/claims/:id/verify |
yes | yes |
releaseDomainClaim |
DELETE /api/v1/claims/:id |
yes | no |
No GET performs I/O or mutates state. Browsers, proxies and Next's own link prefetching all treat GET as safe to issue speculatively, so a prefetch must never be able to burn the verification mutex or trigger a check nobody asked for.
Every response, success or failure, is one shape:
The discriminant is always success, never ok, because ok reads as an HTTP-status notion and failures are returned with a 200 as often as a 4xx here.
Architecture diagrams and the full request pipeline →
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router), React 19 |
| Language | TypeScript, strict, exactOptionalPropertyTypes in core |
| Database | Postgres on Neon, Drizzle ORM |
| Auth | Better Auth, magic links, storeToken: "hashed" |
| Validation | Zod 4, schemas shared by the API and the action registry |
| UI | Tailwind CSS v4, shadcn/ui, Radix, Lucide |
| Resend | |
| Domain parsing | tldts (Public Suffix List) |
| Tooling | pnpm workspaces, Biome, Vitest |
/docs is public and reachable signed out. It walks the three-step flow with a side panel of API examples that follows the step you are reading, explains the 200-with-success:false rule, and lists all eighteen error codes with their retriable and fault values.
The error table is generated from the same array the running code uses, so a nineteenth code cannot be added without appearing there.
An OpenAPI 3.1 spec is derived from the actions themselves, served at /openapi.json and browsable at /docs/api. Every action already carries a Zod schema for its input and its output, so the generator walks the registry, converts both schemas to JSON Schema, and joins them to the route table. Nothing is annotated by hand above a route handler, which means the spec cannot describe an API the code does not implement, and a seventh action cannot ship undocumented.
pnpm install
cp .env.example .env.local # then fill in the values below
pnpm db:migrate
pnpm devRequired environment variables, all documented in .env.example:
| Variable | Purpose |
|---|---|
DATABASE_URL |
Postgres connection string. Quote it. |
BETTER_AUTH_SECRET |
Session signing |
BETTER_AUTH_URL |
Absolute base URL for magic links |
RESEND_API_KEY |
Sending sign-in emails |
ADMIN_PASSWORD |
Admin console login |
ADMIN_SESSION_SECRET |
Admin session signing |
Both admin variables fail closed. Unset means nobody can log in, never everybody.
Reading a magic link without sending email: start the dev server with the key blanked and the link prints to the server log instead.
RESEND_API_KEY="" pnpm devpnpm test # the app
pnpm test:core # @vouch/coreA few core tests hit real public DNS and are skipped unless VOUCH_DNS_INTEGRATION=1 is set.
Working and deployed. Magic-link auth, all six actions behind /api/v1, the full claim flow on real data, the admin console, the audit event log, a generated OpenAPI spec, and error simulation. Verified against live Neon and real public DNS.
Known limits, each a recorded decision rather than an oversight:
verifiedis terminal. There is no continuous re-verification, which is the industry norm. A domain that was legitimately verified and later transferred or expired stays verified until someone releases it. The dangling-DNS risk is known.- The API needs a browser session. There are no API keys, so it is not callable from outside a browser yet.
- The registrar setup steps are unverified. The per-provider click-by-click instructions were never walked against the real provider UIs. Three providers were removed for exactly that reason rather than shipped on trust.
ROADMAP.md records what would come next and why, in order.








{ "success": true, "data": { /* ... */ } } { "success": false, "error": { "code": "DNS_NO_TXT_FOUND", "isRetriable": true, "fault": "external", /* ... */ } }