Integration

Two halves. The key stays on your server, and the page only gets a token.

One call from your server creates a setup. One script on your page lets your customer finish it. A signed webhook tells you when the domain is live.

// Your server. The API key is a secret and stays here.// Your dashboard's Integration page fills in API_URL.const res = await fetch(API_URL + "/v1/dns-sessions", {  method: "POST",  headers: {    "X-Api-Key": process.env.RELAYDNS_API_KEY,    "Content-Type": "application/json",  },  body: JSON.stringify({    domain: "app.customer.com",    records: [      { type: "CNAME", name: "@", value: "origin.yourapp.com" },    ],  }),}); const { widgetToken } = await res.json();// Send widgetToken to the browser. Never the API key.

The API

Three endpoints. One key.

Send your project's API key as X-Api-Key or as Authorization: Bearer. Keys begin rdns_, are shown once, and belong to one project, so every setup you create lands there. Errors come back as { "error": "…" } with a plain sentence.

  • POST/v1/dns-sessions

    Create a setup. Returns 202 with the widgetToken.

  • GET/v1/dns-sessions/:id

    Status, attempts and each record's verification.

  • DELETE/v1/dns-sessions/:id

    Remove a setup. Returns 204.

Create a setup

POST /v1/dns-sessions. Returns before DNS is touched.

  • domainrequiredstringThe full domain being set up, e.g. app.customer.com.
  • recordsrequiredarray, 1 to 50What the domain needs. Names are relative to domain; "@" is the domain itself.
  • records[].typerequiredstringA, AAAA, CNAME, TXT, MX, NS, SRV or CAA.
  • records[].valuerequiredstringWhere it points, or what it holds.
  • records[].ttlinteger60 to 604800 seconds. Defaults to 3600.
  • records[].priorityintegerMX and SRV only. Defaults to 10.
  • maxVerificationAttemptsinteger1 to 100. Defaults to 12.
  • widgetTokenTtlMinutesinteger5 to 1440. Defaults to 60.
  • widgetAllowedOriginURLPins the widget token to your app's origin.
response.json
// 202 Accepted
{
  "id": "…",
  "domain": "app.customer.com",
  "status": "created",
  "expiresAt": "…",
  "widgetToken": "rdws_…",
  "widgetTokenExpiresAt": "…"
}

The widget

One script. It works out the rest.

Load API_URL/widget/v1/relaydns.js and call RelayDNS.open(options). It renders inside a shadow root, so it cannot pick up or disturb your styles, and returns close and refresh.

  • tokenrequiredstringThe widgetToken your server received.
  • productNamestringYour product's name, used in the widget's heading.
  • brandingbooleanfalse hides the Secured by RelayDNS line.
  • onSuccess(state)functionThe records answer. The domain is live.
  • onStatusChange(status, state)functionEvery status change, as it happens.
  • onClose()functionYour customer closed the widget.
  • onError(error)functionThe setup could not be loaded, for example once the token has expired.

Webhooks

Told, not polled. Every delivery is signed.

  • dns/setup.requested

    A setup was created.

  • dns/verification.completed

    Every record answers. The domain is live.

  • dns/verification.failed

    Attempts ran out, or the setup expired first.

Signed with HMAC-SHA256 over timestamp.body, sent as RelayDNS-Signature: t=…,v1=…. Reject anything older than five minutes.

A delivery that does not get a 2xx within ten seconds is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. Redirects are not followed.

{
  "event": "dns/verification.completed",
  "sentAt": "…",
  "data": {
    "dnsSessionId": "…",
    "domain": "app.customer.com",
    "status": "verified",
    "verifiedAt": "…",
    "expiresAt": "…"
  }
}

Verifying a delivery

A few lines. The shape you know from Stripe.

The secret begins whsec_ and is shown once, when you add the endpoint. Verify against the raw body: parsing it first changes the bytes.

verify.js
import { createHmac, timingSafeEqual } from "node:crypto";

// header: the RelayDNS-Signature value, "t=<seconds>,v1=<hex>"
// body:   the raw request body, before any JSON parsing
export function verify(body, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=")),
  );
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${body}`, "utf8")
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

Get started

Try it on one domain. Nothing is charged until one connects in one click.

  1. 1Create an accountAn email and a password
  2. 2Make a project and an API keyThe key is shown once
  3. 3Connect your first domainOne call from your server
  4. The account and the key take about a minute.