Developer quickstart

Connect a domain in three calls.

Mint a token server-side, hand it to the SDK (or call the HTTP API directly), and drive detectconnectverify. 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.

POST /v1/token
// 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.

showLatch.ts
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:

helpers.ts
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.

POST/v1/tokenMint a 60-min JWT
POST/v1/detectFind where DNS lives
POST/v1/connectApply, OAuth, or manual
GET/v1/verify?domain=&jobId=Check propagation
POST/v1/recheckRe-verify (5-min cooldown)
GET/v1/providers_healthAdapter status

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).

POST /v1/detect
// 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[]).

POST /v1/connect
// 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
  }
}
Safety by default

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).

GET /v1/verify?domain=&jobId=
// res — { ok, verify: VerifyResult }
{
  ok: true,
  verify: {
    status: "published",   // published | confirmed | propagating | pending | failed
    records: [ /* each record's per-resolver state */ ]
  }
}
POST /v1/recheck
// 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.

GET /v1/providers_health
{
  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.

CanonicalRecord
{
  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:

RecordTypeWhat 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

Deferred in v1

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 applicationId is 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 the gbuild-email template 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_ID is mandatory; the owned-zone lookup fails closed without it.

← Back to overview   Open latch.gbuild.app