@dodomain/node
The TypeScript server SDK — sessions, connections, apps, domains, webhook endpoints, key rotation, and the webhook verifier.
npm install @dodomain/nodeA 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 userKeep the secret key server-side only — it must never reach a browser.
The client
new DoDomain(options) takes:
| Option | Type | Notes |
|---|---|---|
secretKey | string — required | Your app's dd_sk_... key. A value that doesn't start with dd_sk_ throws at construction, before any call is made. |
baseUrl | string | API origin. Defaults to https://app.dodomain.io — the one origin that actually serves /api/v1/* and the hosted connect page. |
fetchImpl | typeof fetch | Bring 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);
}
}messageis the API'serrorcode (not_found,quota_exceeded,rate_limited, …), ornon_json_response/invalid_response_shapewhen the body wasn't what the contract promises.statusis the HTTP status — or0, 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.bodyis 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
recordsare the composed names —type,hostandfqdn, with novalue. 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
expiredflag, which is derived at read time (expiresAt <= now); never infer expiry fromstatusalone. - Passing a
dd_sess_...token throws aDoDomainErrorwithstatus: 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
| Method | Does |
|---|---|
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.
| Method | Does |
|---|---|
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.
SDKs
The four published DoDomain SDKs — @dodomain/node, @dodomain/connect and @dodomain/react on npm, dodomain-sdk on PyPI — and how to pick one.
dodomain-sdk (Python)
The Python server SDK — sync and async twins over the same /api/v1 surface, typed models, an exception hierarchy, and the webhook verifier.