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).
DNS hosting
Section titled “DNS hosting”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.
Zone files
Section titled “Zone files”| 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);Free domains
Section titled “Free domains”| 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.maxelse console.log(reason);Records
Section titled “Records”| 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 checkHealth checks
Section titled “Health checks”| 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' }); // maintenanceawait dinio.records.update(value.uuid, { host_health: 'Auto' }); // the check decides againGeo bans
Section titled “Geo bans”| 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.
Zone members
Section titled “Zone members”| 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');Redirects
Section titled “Redirects”| 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"Certificates
Section titled “Certificates”| 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.
DNS filtering
Section titled “DNS filtering”Client identities and sinkhole lists belong to your user: they need a personal key, and a
zone key gets FORBIDDEN on every method.
Client identities
Section titled “Client identities”| 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 });Sinkhole lists, rules and links
Section titled “Sinkhole lists, rules and links”| 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);Import, export and URL sources
Section titled “Import, export and URL sources”| 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 changeawait dinio.sinkholeLists.deleteSource(list.uuid); // keeps the rules, manual againimport, setSource and refresh wait for the whole import — at least 120 s, whatever
timeoutMs is (unless it is 0).
Statistics
Section titled “Statistics”| 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 });Audit log
Section titled “Audit log”| 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);}Your account
Section titled “Your account”| 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 oncePackages and limits
Section titled “Packages and limits”| 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';