DoDomain

@dodomain/node

The TypeScript server SDK — sessions, connections, apps, domains, webhook endpoints, key rotation, and the webhook verifier.

npm install @dodomain/node

A typed client over the whole /api/v1 surface, plus the webhook verifier, in one package with zero runtime dependencies. It ships dual ESM + CJS builds and self-contained type declarations, and requires Node 20+.

import { DoDomain, verifyWebhook } from "@dodomain/node";

const dodomain = new DoDomain({ secretKey: process.env.DODOMAIN_SECRET_KEY });

const session = await dodomain.sessions.create({
  domain: "app.customer.com",
  records: [{ type: "CNAME", host: "app", value: "cname.yourproduct.com" }],
  returnUrl: "https://yourproduct.com/settings/domains",
});
// session.connectUrl → hand to your user

Keep the secret key server-side only — it must never reach a browser.

The client

new DoDomain(options) takes:

OptionTypeNotes
secretKeystring — requiredYour app's dd_sk_... key. A value that doesn't start with dd_sk_ throws at construction, before any call is made.
baseUrlstringAPI origin. Defaults to https://app.dodomain.io — the one origin that actually serves /api/v1/* and the hosted connect page.
fetchImpltypeof fetchBring your own fetch (custom agent, instrumentation, a test double).

Errors

Every failure this SDK produces is a DoDomainError — never a raw SyntaxError from a proxy's HTML error page, never an unchecked cast.

import { DoDomainError } from "@dodomain/node";

try {
  await dodomain.connections.reverify(connectionId);
} catch (err) {
  if (err instanceof DoDomainError) {
    console.error(err.message, err.status, err.body);
  }
}
  • message is the API's error code (not_found, quota_exceeded, rate_limited, …), or non_json_response / invalid_response_shape when the body wasn't what the contract promises.
  • status is the HTTP status — or 0, meaning no request was sent: the SDK validates your arguments against the same schemas the server enforces, so a malformed call fails locally instead of round-tripping for the identical 400.
  • body is the decoded response body (or the raw text for a non-JSON one).

Responses are validated on the way back too, against the same schemas the routes are typed against — a body that doesn't match raises invalid_response_shape rather than handing you a mistyped object.

sessions

sessions.create(input)

Mints a connect session. records[].host is relative to domain ("app" under customer.com composes to app.customer.com; "@" means the domain itself), and MX records need a priority.

The returned records are the composed names — each requested record paired with the fqdn verification will look up. A warnings array appears only when there is something worth flagging about an accepted request; branch on warning.code, never on the message text. See Creating a session for the full contract.

sessions.get(sessionId)

Reads a session back by id with your credential — the id every webhook payload carries. This is how a session.abandoned or session.completed receiver asks what state a session actually ended in.

  • Its records are the composed names — type, host and fqdn, with no value. The record targets are what you sent; this arm reports the names DoDomain looks up.
  • It reads an expired session rather than refusing it. Check the returned expired flag, which is derived at read time (expiresAt <= now); never infer expiry from status alone.
  • Passing a dd_sess_... token throws a DoDomainError with status: 0. That token addresses the same path's public arm, which answers with a different shape — so the SDK names the mistake instead of failing opaquely later.
  • Unknown and not-yours both throw status: 404, deliberately indistinguishable.

connections

MethodDoes
connections.list(filters?)One page of connections, newest first. Filters: appId, domain, limit (1–100, server default 50), cursor, includeDisconnected.
connections.get(connectionId)One connection, disconnected ones included. Same shape as a list element; unknown or not-yours throws 404.
connections.delete(connectionId)Disconnect: archives it and stops DNS monitoring.
connections.reverify(connectionId)Queue an on-demand DNS recheck.

Only the filters you actually pass go on the wire, so an omitted one always means the server's current default rather than a default frozen into the SDK build you installed. Follow nextCursor to walk past a page; it is null on the last one.

delete is idempotent — a repeat returns the original disconnectedAt with alreadyDisconnected: true and emits no second webhook.

reverify returns as soon as the job is accepted (HTTP 202). The verdict arrives as a connection.verified / connection.failed webhook, never in the response. A connection that was already checked within the last 10 minutes throws status: 429.

apps and domains

apps.list() returns the apps this credential can see. A secret key sees exactly its own app — listing siblings would widen one leaked key into team-wide reconnaissance. Only the publishable dd_pk_... key is ever returned; no secret material is in the contract.

domains.check({ domain }) is a stateless pre-flight: which provider hosts the domain, which zone owns its records, which connect tier and method it will get, its nameservers, whether Domain Connect was discovered, and the manual guide if it comes to that. Nothing is persisted and no session is created, so it is the call to make before deciding what UI to show. Available on every plan.

webhookEndpoints

The same delivery targets the dashboard's Webhook endpoints card manages — so they can live in your CI or infrastructure-as-code instead of in someone's browser history.

MethodDoes
webhookEndpoints.list()Every endpoint this app delivers to. Never returns secret.
webhookEndpoints.create({ url })Register a target. Returns the endpoint plus its signing secret.
webhookEndpoints.update(id, { url })Repoint an endpoint. The signing secret is deliberately untouched.
webhookEndpoints.delete(id)Stop delivering to it.
webhookEndpoints.rotateSecret(id)Mint a new signing secret and return it.

secret is shown once

secret appears in the results of create and rotateSecret and in no read surface ever again — a leaked read must not be able to recover the ability to forge our signatures. Persist it in the same code path that received it. It is the secret argument to verifyWebhook.

A non-https url, one resolving to a localhost/private/link-local address, and a duplicate of a url this app already registered all throw status: 400 with the reason in body.message. At the plan's endpoint cap it is status: 402. An unknown id and another app's id both throw status: 404.

Rotation is immediate and total: the worker reads the secret live at delivery time, so signatures switch at once — including retries of deliveries queued before you rotated. Deploy the new secret to your receiver promptly.

keys

keys.rotate() rotates the calling app's own secret key, which is what makes scheduled credential rotation automatable instead of a dashboard click.

No grace window

The key that authorized the call stops authenticating the instant it returns, so the returned secretKey is the only copy of the new credential — persist it before doing anything else. The client instance you called it on keeps using the old key; construct a new DoDomain with the returned secretKey to keep working. publicKey is echoed unchanged, so an automated job can assert it rewrote the app it meant to.

There is deliberately no create/list/revoke-another-key on this API: a stolen dd_sk_ must not be able to mint a second, hidden credential that survives the owner rotating the one they know about. See Rotating your secret key for a scheduled-rotation script.

verifyWebhook

import { verifyWebhook } from "@dodomain/node";

// signature header: x-dodomain-signature, "t=<unix ms>,v1=<hex>"
const ok = verifyWebhook(secret, rawBody, signatureHeader);

verifyWebhook(secret, body, header, toleranceMs?, nowMs?) returns a boolean. Pass the raw body — the signature covers the exact bytes sent, so a re-serialized JSON object will not verify. toleranceMs is the replay window (five minutes by default) and nowMs exists so you can verify an archived delivery in a test. The function is exported from this package precisely so an integrator needs only this one dependency to both mint sessions and verify what comes back.

Types

Every shape the client returns is exported as a type, and each one is pinned at compile time to the zod schema the API validates with — so a server-side schema change that drifted from these declarations would fail the SDK's own build rather than reach you as a runtime surprise.

Session, IntegratorSession, ComposedDnsRecord, DnsRecord, DnsRecordType, SessionWarning, Connection, ConnectionStatus, ListConnectionsInput, ListConnectionsResult, DisconnectConnectionResult, ReverifyConnectionResult, App, ListAppsResult, CheckDomainInput, CheckDomainResult, DetectionTier, DetectionMethod, DetectionConfidence, ProviderGuide, WebhookEndpoint, WebhookEndpointInput, WebhookEndpointWithSecret, ListWebhookEndpointsResult, DeleteWebhookEndpointResult, RotateSecretKeyResult, WebhookEvent, WebhookEventType, WebhookEventWire.

Type your webhook receiver against WebhookEvent{ id, type, occurredAt, data }. WebhookEventWire is the same envelope plus the deprecated event alias that is still on the wire for receivers written before 2026-08-06; reach for it only if you still read that field. CreateSessionResponse and SessionRecord remain exported as aliases of Session and DnsRecord for code written against the earliest releases.

On this page