Skip to content

Resources

The resources on the client map one-to-one to API routes and add no logic of their own. Bodies and returned objects use the API’s snake_case field names; SDK-only options (pagination, filters, queries) are camelCase. Every method also takes { signal } as its last argument, left out below.

Create methods that a package limit counts — zones, healthChecks, clientIdentities, sinkholeLists and sinkholeRoles — return Created<T>: { value, warnings }, the new object and the API’s warnings (usually empty; see Packages and limits).

Where the API has no uuid, methods take the natural key: a record set is (zoneId, name, type), a health check is keyed by its record value’s uuid, a redirect by its host name, and zone members and sinkhole links by their pair of uuids (a member is added by email).

Everything in a zone: a zone key reaches its own zone, a personal key the zones its user is Owner or Administrator of.

Method Returns Route
zones.list(page?) Page<Zone> GET /zones
zones.iterate() AsyncGenerator<Zone> every page
zones.listAll() Zone[] every page
zones.get(zone) Zone GET /zones/:ref
zones.getWithRecords(zone) ZoneWithRecords GET /zones/:ref?include=records
zones.create(name) Created<Zone> POST /zones
zones.createApiKey(zone) IssuedApiKey POST /zones/:ref/api-key
zones.revokeApiKey(zone) Zone DELETE /zones/:ref/api-key
zones.delete(zone) void DELETE /zones/:ref

zone is a uuid or a zone name. page is { page?, perPage? }. createApiKey returns { api_key } — the only time the key is shown; see API keys.

Method Returns Route
zones.export(zone, format?) string GET /zones/:ref/export
zones.import(zone, format, file, { dryRun? }) ImportReport POST /zones/:ref/import
zones.discover(zone, { nameservers? }) Discovery POST /zones/:ref/discover

format is 'json' (default for export), 'bind' or 'csv'. The SDK takes and returns file content as text — reading and writing files is up to you; zoneFileFormatFromName guesses the format from a file name. For json, file can also be a ZoneDocument.

import { importReport } from '@dinio/sdk';
const zoneFile = await dinio.zones.export('example.com', 'bind');
const preview = await dinio.zones.import('example.org', 'bind', zoneFile, { dryRun: true });
console.log(`${preview.records.created} to create, ${preview.records.errors.length} errors`);
try {
await dinio.zones.import('example.org', 'bind', zoneFile);
} catch (err) {
const report = importReport(err); // IMPORT_FAILED: nothing was written
if (!report) throw err;
for (const issue of report.records.errors) console.log(`line ${issue.ref}: ${issue.message}`);
}

discover asks the previous provider’s nameservers and returns a proposal in document. It takes up to a minute; the SDK waits at least 90 s for it, whatever timeoutMs is (unless it is 0).

const found = await dinio.zones.discover('example.com', {
nameservers: ['ns1.old-provider.example'],
});
found.document.records = found.document.records.filter((r) => r.name !== 'old');
await dinio.zones.import('example.com', 'json', found.document);
Method Returns Route
freeDomains.list() FreeDomainList GET /free-domains
freeDomains.available(domain, label) FreeLabelAvailability GET /free-domains/:domain/available

Neither needs a key. A lease is zones.create with the full name:

const { domains } = await dinio.freeDomains.list();
const { available, reason } = await dinio.freeDomains.available(domains[0]!, 'myhome');
if (available) await dinio.zones.create(`myhome.${domains[0]}`); // counts against auth.free_subdomains.max
else console.log(reason);
Method Returns Route
records.list(zoneId, filter?, page?) Page<RecordValue> GET /records
records.iterate(zoneId, filter?) AsyncGenerator<RecordValue> every page
records.listAll(zoneId, filter?) RecordValue[] every page
records.get(zoneId, name, type) RecordSet GET /records/:name
records.getWithGeoBans(zoneId, name, type) RecordSetWithGeoBans GET /records/:name?include=geoBans
records.create(value) RecordValue POST /records
records.update(uuid, patch) RecordValue PATCH /records/:uuid
records.setTtl(zoneId, name, type, ttl) RecordSet PATCH /records/:name/ttl
records.delete(uuid) void DELETE /records/:uuid

Lists return record values, each with its ttl. geo_routing and balance_ratio are for A and AAAA only, host_health other than 'None' for A, AAAA, SRV and MX; see Options by type. For a view by record set, pass them to groupRecordValues. filter is { name?, type? }. Names are full names (www.example.com); see qualifyName.

A value with managed_by is kept by Dinio: update and delete of a Redirect or Acme value throw MANAGED_RECORD; a System value (the zone’s SOA/NS) can be changed, but NS pointing elsewhere takes the zone off Dinio’s nameservers.

const value = await dinio.records.create({
zone_id: zone.uuid,
name: 'www.example.com',
type: 'A',
data: '192.0.2.1',
ttl: 300, // only for a new record set
});
await dinio.records.setTtl(zone.uuid, 'www.example.com', 'A', 600);
await dinio.records.delete(value.uuid); // with its geo bans and health check
Method Returns Route
healthChecks.get(recordId) HealthCheck GET /health-checks/:record_id
healthChecks.create(check) Created<HealthCheck> POST /health-checks
healthChecks.update(recordId, patch) HealthCheck PATCH /health-checks/:record_id
healthChecks.delete(recordId) void DELETE /health-checks/:record_id

NewHealthCheck is typed per check type, so config must fit type. Times are in seconds; see Timing for the limits. Checks work on A, AAAA, SRV and MX values; other types throw VALIDATION.

import { healthCheckTarget } from '@dinio/sdk';
await dinio.healthChecks.create({
record_id: value.uuid,
type: 'http',
config: { url: 'https://192.0.2.1/health', expectedStatus: [200] },
interval: 60, // s
timeout: 5, // s
});
const check = await dinio.healthChecks.get(value.uuid);
const target = healthCheckTarget(check); // { type: 'http', config: HttpCheckConfig } | …

create switches the value’s host_health to 'Auto'; delete turns 'Auto' back into 'None'. A check has no on/off switch of its own: pause it by setting the value to 'Up' or 'Down', resume it with 'Auto'.

await dinio.records.update(value.uuid, { host_health: 'Down' }); // maintenance
await dinio.records.update(value.uuid, { host_health: 'Auto' }); // the check decides again
Method Returns Route
geoBans.create(recordId, area) GeoBan POST /geo-bans
geoBans.update(uuid, area) GeoBan PATCH /geo-bans/:uuid
geoBans.delete(uuid, { recordId? }) void DELETE /geo-bans/:uuid
geo.catalog() GeoCatalog GET /geo

Geo bans work on A, AAAA, SRV and CNAME values. area is { level, code }, e.g. { level: 'Country', code: 191 } — or build it from a name with parseGeoArea(catalog, 'HR'). Read bans with records.getWithGeoBans.

Method Returns Route
zoneMembers.list(zoneId) ZoneMember[] GET /zone-members
zoneMembers.add(zoneId, email) void POST /zone-members
zoneMembers.remove(zoneId, userId) void DELETE /zone-members/:zone_id/:user_id

add resolves the same way whether the user was added, already was a member, or no account has that email (there are no invitations); list shows the result, each member with their email.

await dinio.zoneMembers.add(zone.uuid, 'ana@example.com');
const added = (await dinio.zoneMembers.list(zone.uuid)).some((m) => m.email === 'ana@example.com');
Method Returns Route
redirects.list(filter?, page?) Page<Redirect> GET /redirects
redirects.iterate(filter?) AsyncGenerator<Redirect> every page
redirects.listAll(filter?) Redirect[] every page
redirects.get(name) Redirect GET /redirects/:name
redirects.create(redirect) Redirect POST /redirects
redirects.update(name, patch) Redirect PATCH /redirects/:name
redirects.delete(name) void DELETE /redirects/:name

name is the redirect’s host name. filter is { zoneId? }. Lists don’t include certificate; get, create and update do. Dinio adds and removes the redirect’s DNS records itself.

import { findZoneForName } from '@dinio/sdk';
const zone = await findZoneForName(dinio, 'go.example.com'); // or dinio.zones.get('example.com')
const redirect = await dinio.redirects.create({
zone_id: zone!.uuid,
name: 'go.example.com',
code: 302,
location: 'https://www.example.com/campaign',
tls: 'Auto', // the default is 'None'
});
console.log(redirect.certificate?.status); // "Pending", later "Valid"
Method Returns Route
certificates.list(filter?, page?) Page<Certificate> GET /certificates
certificates.iterate(filter?) AsyncGenerator<Certificate> every page
certificates.listAll(filter?) Certificate[] every page
certificates.listCovering(name, filter?) Certificate[] GET /certificates?name=
certificates.get(uuid) Certificate GET /certificates/:uuid
certificates.create(certificate) Certificate POST /certificates
certificates.replace(uuid, certificate) Certificate PUT /certificates/:uuid
certificates.delete(uuid) void DELETE /certificates/:uuid

filter is { zoneId? }. The SDK takes the PEM text, not file paths, so it runs where there is no file system:

import { readFile } from 'node:fs/promises';
await dinio.certificates.create({
zone_id: zone.uuid,
chain_pem: await readFile('fullchain.pem', 'utf8'),
key_pem: await readFile('privkey.pem', 'utf8'),
});

replace and delete of a certificate with source: 'Acme' throw MANAGED_CERTIFICATE.

Client identities and sinkhole lists belong to your user: they need a personal key, and a zone key gets FORBIDDEN on every method.

Method Returns Route
clientIdentities.list(filter?) ClientIdentity[] GET /client-identities
clientIdentities.get(identity) ClientIdentity GET /client-identities/:ref
clientIdentities.create(identity) Created<ClientIdentity> POST /client-identities
clientIdentities.update(uuid, patch) ClientIdentity PATCH /client-identities/:uuid
clientIdentities.regenerateIdentify(uuid) ClientIdentity POST /client-identities/:uuid/identify
clientIdentities.delete(uuid) void DELETE /client-identities/:uuid

filter is { uuid?, identify? }; get takes a uuid or the identify value. create takes { id_type: 'IP', identify, name? } with an address, or { id_type: 'Hostname' | 'Path', name? } without one — Dinio generates identify, and the types don’t let you send it.

const { value: phone } = await dinio.clientIdentities.create({ id_type: 'Path', name: 'phone' });
console.log(`DoH path part: ${phone.identify}`);
await dinio.clientIdentities.update(phone.uuid, { enabled: false });
Method Returns Route
sinkholeLists.list({ shared? }) SinkholeList[] GET /sinkhole-lists
sinkholeLists.get(uuid) SinkholeList GET /sinkhole-lists/:uuid
sinkholeLists.rolesPage(uuid, { limit?, cursor? }) SinkholeRolePage GET /sinkhole-lists/:uuid?include=roles
sinkholeLists.iterateRoles(uuid) AsyncGenerator<SinkholeRole> every page
sinkholeLists.allRoles(uuid) SinkholeRole[] every page
sinkholeLists.create(list) Created<SinkholeListWithLinks> POST /sinkhole-lists
sinkholeLists.update(uuid, patch) SinkholeList PATCH /sinkhole-lists/:uuid
sinkholeLists.delete(uuid) void DELETE /sinkhole-lists/:uuid
sinkholeRoles.get(uuid) SinkholeRole GET /sinkhole-roles/:uuid
sinkholeRoles.create(role) Created<SinkholeRole> POST /sinkhole-roles
sinkholeRoles.update(uuid, patch) SinkholeRole PATCH /sinkhole-roles/:uuid
sinkholeRoles.delete(uuid) void DELETE /sinkhole-roles/:uuid
sinkholeLinks.list({ clientIdentityId?, listId? }) SinkholeLink[] GET /sinkhole-links
sinkholeLinks.create(clientIdentityId, listId) SinkholeLink POST /sinkhole-links
sinkholeLinks.delete(clientIdentityId, listId) void DELETE /sinkhole-links/:cid/:sid

create takes { type, name?, is_shared?, link_to? }: link_to lists your client identities to apply the new list to in the same request. update takes { name?, is_shared? } — at least one; name: null removes the name. delete removes the list with its rules and every link to it.

A list can have up to 500 000 rules, so they come in pages: rolesPage returns { items, nextCursor, total } (limit 1–5 000, default 500; nextCursor is undefined on the last page), and iterateRoles reads every page for you. get returns the list with its source, without rules.

const { value: list } = await dinio.sinkholeLists.create({ type: 'Blacklist', name: 'ads', link_to: [phone.uuid] });
await dinio.sinkholeRoles.create({ list_id: list.uuid, sinkhole_type: 'Wildcard', pattern: '*.tracker.example' });
for await (const rule of dinio.sinkholeLists.iterateRoles(list.uuid)) console.log(rule.pattern);
Method Returns Route
sinkholeLists.import(uuid, file, { mode?, dryRun? }) SinkholeImportReport POST /sinkhole-lists/:uuid/import
sinkholeLists.export(uuid, { format? }) string GET /sinkhole-lists/:uuid/export
sinkholeLists.setSource(uuid, { url, refresh_interval_s? }, { dryRun? }) SinkholeSourceResult PUT /sinkhole-lists/:uuid/source
sinkholeLists.setRefreshInterval(uuid, seconds | null) SinkholeListSource PATCH /sinkhole-lists/:uuid/source
sinkholeLists.refresh(uuid, { force? }) SinkholeSourceResult POST /sinkhole-lists/:uuid/refresh
sinkholeLists.deleteSource(uuid) void DELETE /sinkhole-lists/:uuid/source

import takes the file’s content as text — hosts, plain names, Adblock/AdGuard, dnsmasq or the csv export, mixed per line; mode is 'merge' (default) or 'replace'. export returns the file as text; format is 'csv' (default), 'adblock', 'hosts' or 'domains'. The report and the source result carry the API’s warnings. See Import and export for the formats and limits.

const preview = await dinio.sinkholeLists.import(list.uuid, hostsFile, { mode: 'replace', dryRun: true });
console.log(`+${preview.added} −${preview.removed}, ${preview.parse.invalid} invalid lines`);
await dinio.sinkholeLists.import(list.uuid, hostsFile, { mode: 'replace' });
const csv = await dinio.sinkholeLists.export(list.uuid);
// Or let Dinio download it, now and once a day. Such a list takes no manual rules (SOURCE_MANAGED).
await dinio.sinkholeLists.setSource(list.uuid, { url: 'https://blocklists.example.com/hosts.txt', refresh_interval_s: 86_400 });
const { import: changed } = await dinio.sinkholeLists.refresh(list.uuid); // null if the file did not change
await dinio.sinkholeLists.deleteSource(list.uuid); // keeps the rules, manual again

import, setSource and refresh wait for the whole import — at least 120 s, whatever timeoutMs is (unless it is 0).

Method Returns Route
stats.zonesQueries(query?) StatsSeriesResult GET /stats/zones/queries
stats.zonesTop(query?) ZoneTopList GET /stats/zones/top
stats.zoneQueries(zone, query?) StatsSeriesResult GET /stats/zones/:ref/queries
stats.zoneNx(zone, query?) ZoneNxStats GET /stats/zones/:ref/nx
stats.zoneNames(zone, query?) ZoneNamesStats GET /stats/zones/:ref/names
stats.zoneNxNames(zone, query?) StatsTopList GET /stats/zones/:ref/nx-names
stats.clientEvents(query?) StatsSeriesResult GET /stats/clients/events
stats.clientRcodes(query?) StatsSeriesResult GET /stats/clients/rcodes
stats.clientBlocked(query?) StatsTopList GET /stats/clients/blocked

A series query takes from and to (a Date or an ISO 8601 string), step, compare, mode, fill, limit and groupBy; a top-list query from, to and limit. Client statistics cover all of your identities, or one with clientId; zonesQueries and zonesTop sum over every zone you can see. Times in responses are milliseconds. A range further back than the package allows throws PLAN_LIMIT. See Statistics for the fields.

const week = await dinio.stats.zoneQueries('example.com', {
from: new Date(Date.now() - 7 * 86_400_000),
step: '1d',
groupBy: 'qtype',
});
for (const s of week.series) console.log(s.label, s.total);
const blocked = await dinio.stats.clientBlocked({ clientId: phone.uuid, limit: 10 });
const busiest = await dinio.stats.zonesTop({ limit: 5 });
Method Returns Route
auditLogs.list(filter?) CursorPage<AuditLog> GET /audit-logs
auditLogs.iterate(filter?) AsyncGenerator<AuditLog> every page

filter takes from, to, zoneId, action (exact, or a prefix ending in *), outcome, category, actorType, actorUserId, resourceType, resourceId, requestId, limit and cursor. list returns { items, nextCursor }; pass nextCursor back as cursor for the next page, or let iterate do it.

for await (const e of dinio.auditLogs.iterate({ zoneId: zone.uuid, action: 'record.*' })) {
console.log(e.ts, e.actor_label, e.action, e.resource_name, e.outcome);
}
Method Returns Route
users.get(user) User GET /users/:ref
users.createApiKey(userId) IssuedApiKey POST /users/:uuid/api-key
users.revokeApiKey(userId) User DELETE /users/:uuid/api-key

user is your uuid or email. A new personal key replaces the one the client is using, so create a new client with it.

const me = await dinio.users.get('me@example.com');
const { api_key } = await dinio.users.createApiKey(me.uuid); // "du_…", shown once
Method Returns Route
plans.list() Plan[] GET /plans
account.subscriptions() Subscription[] GET /account/subscriptions
account.choose(line, code, { period? }) Subscription PUT /account/subscriptions/:line
account.usage() LineUsage[] GET /account/usage

plans.list needs no key. line is 'auth' or 'filter'; period ('monthly' or 'yearly') is required for a paid package and refused for a free one. account needs a personal key. See Packages and usage.

Helpers for the limits:

Function
planUsage(warning) { key, limit, used, level } of a PLAN_USAGE warning; undefined for other warnings.
planLimit(err) { key, limit, used?, requested? } of a PLAN_LIMIT error; undefined for any other error.
subscriptionLine(err) The line of a NO_SUBSCRIPTION error.
nextPlanFor(plans, key, limit) The first self-service package of the key’s line that allows more than limit — the upgrade to offer.
nextPlanForMinimum(plans, key, wanted) The same for a setting where less is better (auth.healthchecks.min_interval_s).
import { nextPlanFor, planLimit, planUsage } from '@dinio/sdk';
const plans = await dinio.plans.list();
await dinio.account.choose('auth', 'free');
try {
const { warnings } = await dinio.zones.create('example.com');
for (const w of warnings) {
const u = planUsage(w);
if (u) console.warn(`${u.key}: ${u.used} of ${u.limit}`);
}
} catch (err) {
const limit = planLimit(err);
if (!limit) throw err;
const next = nextPlanFor(plans, limit.key, limit.limit);
console.log(`limit ${limit.key} = ${limit.limit}; next package: ${next?.name ?? 'contact us'}`);
}

Don’t hard-code limits or prices: read them from plans.list and account.usage.

Every model and input type is exported: Zone, RecordSet, RecordValue, NewRecordValue, RecordValuePatch, HealthCheck, NewHealthCheck, GeoBan, GeoArea, GeoCatalog, ZoneMember, Redirect, NewRedirect, RedirectPatch, Certificate, NewCertificate, CertificateReplacement, User, IssuedApiKey, ClientIdentity, NewClientIdentity, ClientIdentityPatch, SinkholeList, NewSinkholeList, SinkholeListPatch, SinkholeRole, SinkholeRolePage, SinkholeLink, SinkholeListSource, SinkholeImportReport, SinkholeSourceResult, ZoneDocument, ImportReport, Discovery, FreeDomainList, StatsSeriesResult, StatsTopList, ZoneTopList, AuditLog, Plan, Subscription, LineUsage, Created, ApiWarning, Page, CursorPage, and the rest. Enum fields are string unions that also accept unknown strings, so a newer API never breaks your types at runtime.

import type { RecordValue, Zone } from '@dinio/sdk';