Connect a domain in three calls.
Mint a token server-side, hand it to the SDK (or call the HTTP API directly), and drive detect → connect → verify. Everything below is the real /v1 wire contract.
Service origin: https://latch.gbuild.app — the Cloudflare Worker gbuild-latch. Every response is JSON { ok: boolean, ... }; errors are { ok: false, error: { code, message } }.
Authentication
Every /v1/* call except /v1/token carries an Authorization: Bearer <token> header. The token is a 60-minute JWT minted from an applicationId + secret and verified by the Worker. Mint it server-side — never ship the secret to a browser.
// server-side only — mint a short-lived token
fetch("https://latch.gbuild.app/v1/token", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
applicationId: "app_gbuild",
secret: process.env.LATCH_APP_SECRET
})
})
// → { ok: true, token, expiresInSec: 3600 }
The SDK — @gbuild/latch
The SDK renders the connect modal (an iframe served by the Worker) and emits lifecycle events. Automated paths drive /v1/connect directly; OAuth and Domain Connect paths open authUrl in the system browser — never an in-webview popup — and return via the redirect URI.
import Latch from "@gbuild/latch";
Latch.showLatch({
applicationId,
token, // minted server-side (above)
domain: "acme-supply.com", // prefilled
email, // optional EmailPlan
onSuccess(e) {
// e → { domain, status, setupType: "automatic" | "manual" }
},
onStepChange(e) {},
onClose(e) {}
});
Two helpers cover the polling flows without the modal:
await Latch.checkDomain("acme-supply.com"); // → DetectResult
await Latch.checkRecords("acme-supply.com", jobId); // → VerifyResult, correlated by connect()'s jobId
Latch.close();
When a flow ends in manual_fallback, the modal shows a records table (host / type / value, each with a copy button), a deep-link to the detected host's DNS panel, and a Verify button that polls /v1/verify and surfaces the published / confirmed tier.
HTTP API
The SDK is a convenience over these endpoints — you can call them directly. The engine is the source of truth for the DetectResult, ConnectResult, and VerifyResult shapes; the wire responses are their JSON projections.
Detect
Give it a domain; it returns where the DNS is authoritatively hosted, the write method available, and the flags that matter (DNSSEC, parked, registered, existing MX).
// req
{ domain: "acme-supply.com" }
// res — { ok, detect: DetectResult }
{
ok: true,
detect: {
dnsHost: "cloudflare",
method: "api", // authUrl present when OAuth is required
confidence: "high",
dnssec: false,
registered: true,
parked: false,
existingMx: [],
nameservers: ["kai.ns.cloudflare.com", "uma.ns.cloudflare.com"]
}
}
Detection maps on the authoritative nameserver suffix, not the registrar — discovery order is live NS → SOA MNAME → WHOIS fallback.
Connect
Pass the domain and a plan (email and/or site). The result's status tells you which path you're on: applied (written), oauth_required (open result.authUrl), or manual_fallback (render result.manual[]).
// req — { domain, email?, site?, controlProof? }
{
domain: "acme-supply.com",
email: { provider: "resend", selector: "resend" }
}
// res — { ok, result: ConnectResult }
{
ok: true,
result: {
status: "applied", // applied | manual_fallback | oauth_required
applied: [ /* canonical records written */ ],
manual: [], // populated on manual_fallback
jobId: "7f3a1c9e-…",
warnings: [] // e.g. existing_mx
}
}
Connect merges rather than clobbers: it folds into an existing SPF record, defaults DMARC to p=none, and refuses to silently overwrite an existing MX — it emits an existing_mx warning so the UI can hard-stop for explicit confirmation.
Verify & recheck
Verification is two-tier: published / confirmed means authoritative resolvers agree — the real success gate — while propagating means public resolvers are still catching up. In v1 there are no webhooks, so poll /v1/verify, or force a synchronous re-run with /v1/recheck (5-minute cooldown).
// res — { ok, verify: VerifyResult }
{
ok: true,
verify: {
status: "published", // published | confirmed | propagating | pending | failed
records: [ /* each record's per-resolver state */ ]
}
}
// req
{ jobId: "7f3a1c9e-…" }
// res — re-verifies SYNCHRONOUSLY (v1 has no webhook delivery)
{ ok: true, accepted: true, verify: { status: "confirmed", records: [] } }
Under the hood, verify reads back from the CF API for owned zones and uses a DoH multi-resolver with cache-bust for external ones, following SPF include-chains, polling 10s → backoff → 72h. It never pre-queries a name before writing, which would poison the negative cache.
Providers health
Ask the service which adapters are live before you route a domain. automated tells you whether Latch can write directly; enabled reflects staged flags like Domain Connect.
{
ok: true,
providers: [
{ id: "cloudflare", displayName: "Cloudflare", automated: true, enabled: true },
{ id: "domainConnect", displayName: "Domain Connect", automated: true, enabled: false },
{ id: "namecheap", displayName: "Namecheap", automated: false, enabled: true }
]
}
Record types
Latch speaks in one canonical record shape internally — external provider formats convert to it once, at the adapter edge, and are never carried inward as a dual format.
{
type, // "MX" | "TXT" | "CNAME" | "A" | "AAAA" | "ALIAS" …
host, // e.g. "@", "_dmarc", "resend._domainkey"
value,
ttl, // >= 300
priority?, // MX only — unique priorities, FQDN with trailing dot
purpose // why this record exists (spf, dkim, dmarc, mx …)
}
The engine's email-DNS intelligence lives in how it generates and merges the four records that make deliverability work — or silently break it:
| Record | Type | What Latch does |
|---|---|---|
| SPF | TXT | Parses any existing v=spf1 and merges into one record — a second SPF TXT is a permerror. Preserves your all qualifier, dedupes includes, and refuses at the 10-lookup ceiling rather than silently flattening. |
| DKIM | CNAME / TXT | Uses the provider-specified type (Resend/SES hand a CNAME; SendGrid/Google hand a TXT) — never guessed. Splits a 2048-bit p= into multiple quoted strings inside one TXT record. One type per selector label. |
| DMARC | TXT | Defaults a new _dmarc to p=none (monitor-only) — never quarantines or rejects a novice on day one. Merges into an existing record, preserving p/rua/ruf/pct. |
| MX | MX | Treats any existing MX as a live mail provider: emits an existing_mx warning and will not clobber it. Unique priorities, FQDN targets with a trailing dot, honors null-MX (0 .). |
Webhooks
v1 is internal — the caller polls /v1/verify and /v1/recheck directly, so nothing emits webhooks yet. The V3 signing scheme is authored and unit-tested, but no per-app delivery URL or client secret is stored. It wires up in the external multi-tenant phase.
When live, the planned events are domain.added, domain.flow.completed, and domain.propagation.timeout. Each delivery carries a Latch-Signature-V3: sha256=<hex> header — an HMAC-SHA256 of "{timestamp}.{rawBody}" keyed by the app's client secret — with a Latch-Timestamp freshness window of 300 seconds and a constant-time compare.
v1 scope & gates
Straight talk about what ships today, so nothing surprises you in production:
- Single-tenant. One global app secret signs every JWT, so the
applicationIdis self-asserted today. Per-application secrets and an authenticated app record are a hard gate before a second tenant is onboarded — until then the only caller is the GBuild sidecar. - Domain Connect is off.
V1_COVERAGE.domainConnect === false. The mechanism is fully built and tested, but thegbuild-emailtemplate isn't registered with providers yet, so DC-capable domains route to the manual fallback. Flip the flag once the template is live. - The Cloudflare write token is zone-DNS-edit-scoped (
LATCH_CF_DNS_TOKEN), never a full-scope token — enforced at deploy, not in code.LATCH_CF_ACCOUNT_IDis mandatory; the owned-zone lookup fails closed without it.