Resources
The api layer maps one-to-one to API routes. Models in
dinio_sdk::models mirror the API’s JSON; enum fields that may grow (levels, statuses, TLS
modes) are kept as String, so a newer API never breaks deserialization. Each has a typed
accessor that returns None for a value this SDK version doesn’t know — tls_mode(),
kind(), source_kind(), manager(). Input types use the typed enums.
Create methods that a package limit counts — zones(),
health_checks(), client_identities(), sinkhole_lists() and sinkhole_roles() — return
Created<T> { value, warnings }: the new object and the API’s warnings (usually empty; see
Packages and limits).
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(PageRequest) |
Page<Zone> |
GET /zones |
zones().list_all() |
Vec<Zone> |
every page |
zones().get(zone) |
Zone |
GET /zones/:ref |
zones().create(name) |
Created<Zone> |
POST /zones |
zones().create_api_key(zone) |
Secret |
POST /zones/:ref/api-key |
zones().revoke_api_key(zone) |
Zone |
DELETE /zones/:ref/api-key |
zones().delete(zone) |
() |
DELETE /zones/:ref |
zone is a &str: a uuid or a zone name. PageRequest { page, per_page } takes
Option<u32> for both; Default uses the API’s defaults. create_api_key returns the new
key — the only time it is shown; see API keys.
Zone files
Section titled “Zone files”| Method | Returns | Route |
|---|---|---|
zones().export(zone, ZoneFileFormat) |
String |
GET /zones/:ref/export |
zones().import(zone, ZoneFileFormat, file, dry_run) |
ImportReport |
POST /zones/:ref/import |
zones().discover(zone, &nameservers) |
Discovery |
POST /zones/:ref/discover |
ZoneFileFormat is Json, Bind or Csv; ZoneFileFormat::from_file_name guesses it from
an extension. The SDK takes and returns file content as a String — reading and writing files
is up to you.
use dinio_sdk::models::ZoneFileFormat;
let zones = client.zones();let file = zones.export("example.com", ZoneFileFormat::Bind).await?;
let preview = zones.import("example.org", ZoneFileFormat::Bind, file.clone(), true).await?;if !preview.has_errors() { let report = zones.import("example.org", ZoneFileFormat::Bind, file, false).await?; println!("{} records created", report.records.created);}A refused import is ErrorCode::ImportFailed, with the report in
ApiError::import_report(). discover returns a proposal in
Discovery::document to trim and import; it takes up to a minute, and the SDK waits at least
90 s for it, whatever the client’s timeout. An empty nameservers slice asks the zone’s
delegation.
let found = client.zones().discover("example.com", &["ns1.old-provider.example".into()]).await?;let mut document = found.document;document.records.retain(|r| r.name != "old");let file = serde_json::to_string(&document)?;client.zones().import("example.com", ZoneFileFormat::Json, file, false).await?;Free domains
Section titled “Free domains”| Method | Returns | Route |
|---|---|---|
free_domains().list() |
FreeDomainList |
GET /free-domains |
free_domains().available(domain, label) |
FreeLabelAvailability |
GET /free-domains/:domain/available |
Neither needs a key. A lease is zones().create with the full name.
Records
Section titled “Records”| Method | Returns | Route |
|---|---|---|
records().list(zone_id, &filter, PageRequest) |
Page<RecordValue> |
GET /records |
records().list_all(zone_id, &filter) |
Vec<RecordValue> |
every page |
records().get(zone_id, name, type, with_geo_bans) |
RecordSet |
GET /records/:name |
records().create(&NewRecordValue) |
RecordValue |
POST /records |
records().update(uuid, &RecordValuePatch) |
RecordValue |
PATCH /records/:uuid |
records().set_ttl(zone_id, name, type, ttl) |
RecordSet |
PATCH /records/:name/ttl |
records().delete(uuid) |
() |
DELETE /records/:uuid |
Lists return record values, each with its ttl. For a view by record set, pass them to
ops::group_values. filter is
RecordFilter { name, record_type }, both Option<String>. Names are full names; see
qualify_name.
RecordValue::managed_by marks a value Dinio keeps; is_locked() is true for Redirect
and Acme values (and kinds this SDK version doesn’t know), which the API refuses to change
with ErrorCode::ManagedRecord.
use dinio_sdk::models::NewRecordValue;
let value = client .records() .create(&NewRecordValue { zone_id: zone.uuid, name: "www.example.com".into(), record_type: "A".into(), data: "192.0.2.1".into(), ttl: Some(300), // only for a new record set geo_routing: None, balance_ratio: None, host_health: None, }) .await?;
client.records().set_ttl(zone.uuid, "www.example.com", "A", 600).await?;client.records().delete(value.uuid).await?; // with its geo bans and health checkHealth checks
Section titled “Health checks”| Method | Returns | Route |
|---|---|---|
health_checks().get(record_id) |
HealthCheck |
GET /health-checks/:record_id |
health_checks().create(&NewHealthCheck) |
Created<HealthCheck> |
POST /health-checks |
health_checks().update(record_id, &HealthCheckPatch) |
HealthCheck |
PATCH /health-checks/:record_id |
health_checks().delete(record_id) |
() |
DELETE /health-checks/:record_id |
The check’s kind and config are one enum, HealthCheckConfig::{Http, Tcp, Ping}. On a
returned HealthCheck, typed_config() gives it back as that enum. Times are in seconds; see
Timing for the limits. Checks work on A, AAAA, SRV
and MX values; other types are ErrorCode::Validation.
use dinio_sdk::models::{HealthCheckConfig, HttpCheck, NewHealthCheck};
client .health_checks() .create(&NewHealthCheck { record_id: value.uuid, config: HealthCheckConfig::Http(HttpCheck { url: "https://192.0.2.1/health".into(), expected_status: Some(vec![200]), ..Default::default() }), name: None, interval: 60, // s timeout: 5, // s retries: None, retry_interval: None, }) .await?;create switches the value’s host_health to HostHealthMode::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.
use dinio_sdk::models::{HostHealthMode, RecordValuePatch};
client .records() .update(value.uuid, &RecordValuePatch { host_health: Some(HostHealthMode::Down), // maintenance; back to Auto afterwards ..Default::default() }) .await?;RecordValue::host_health is the mode label as a string; host_health_mode() gives it as
HostHealthMode. In a HealthCheckPatch, config replaces the whole config and must keep
the check’s type.
Geo bans
Section titled “Geo bans”| Method | Returns | Route |
|---|---|---|
geo_bans().create(record_id, area) |
GeoBan |
POST /geo-bans |
geo_bans().update(uuid, area) |
GeoBan |
PATCH /geo-bans/:uuid |
geo_bans().delete(uuid, record_id) |
() |
DELETE /geo-bans/:uuid |
geo().catalog() |
GeoCatalog |
GET /geo |
area is a GeoArea { level, code }; build it from a name with
GeoCatalog::parse_area. record_id on delete is an
Option<Uuid> guard: when set, the ban must belong to that value. Read bans with
records().get(…, true) — each value’s geo_bans is filled in.
let catalog = client.geo().catalog().await?;client.geo_bans().create(value.uuid, catalog.parse_area("HR")?).await?;Zone members
Section titled “Zone members”| Method | Returns | Route |
|---|---|---|
zone_members().list(zone_id) |
Vec<ZoneMember> |
GET /zone-members |
zone_members().add(zone_id, email) |
() |
POST /zone-members |
zone_members().remove(zone_id, user_id) |
() |
DELETE /zone-members/:zone_id/:user_id |
add succeeds 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. ops::find_member finds a
member by email for remove.
Redirects
Section titled “Redirects”| Method | Returns | Route |
|---|---|---|
redirects().list(zone_id, PageRequest) |
Page<Redirect> |
GET /redirects |
redirects().list_all(zone_id) |
Vec<Redirect> |
every page |
redirects().get(name) |
Redirect |
GET /redirects/:name |
redirects().create(&NewRedirect) |
Redirect |
POST /redirects |
redirects().update(name, &RedirectPatch) |
Redirect |
PATCH /redirects/:name |
redirects().delete(name) |
() |
DELETE /redirects/:name |
name is the redirect’s host name; zone_id on the lists is an Option<Uuid> filter. Lists
don’t include certificate. NewRedirect::tls left None lets the API choose (None: no
certificate of its own).
use dinio_sdk::models::{NewRedirect, RedirectTls};use dinio_sdk::ops;
let zone = ops::find_zone_for_host(&client, "go.example.com").await?;let redirect = client .redirects() .create(&NewRedirect { zone_id: zone.uuid, name: "go.example.com".into(), code: 302, location: "https://www.example.com/campaign".into(), tls: Some(RedirectTls::Auto), }) .await?;if let Some(cert) = &redirect.certificate { println!("certificate: {}", cert.status); // "Pending", later "Valid"}Certificates
Section titled “Certificates”| Method | Returns | Route |
|---|---|---|
certificates().list(zone_id, PageRequest) |
Page<Certificate> |
GET /certificates |
certificates().list_all(zone_id) |
Vec<Certificate> |
every page |
certificates().covering(name) |
Vec<Certificate> |
GET /certificates?name= |
certificates().get(uuid) |
Certificate |
GET /certificates/:uuid |
certificates().create(&NewCertificate) |
Certificate |
POST /certificates |
certificates().replace(uuid, &CertificateRenewal) |
Certificate |
PUT /certificates/:uuid |
certificates().delete(uuid) |
() |
DELETE /certificates/:uuid |
NewCertificate { chain_pem, key_pem, zone_id } takes the PEM text; its Debug never shows
the key. replace and delete of a source: "Acme" certificate return
ErrorCode::ManagedCertificate.
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 ErrorCode::Forbidden on every method.
Client identities
Section titled “Client identities”| Method | Returns | Route |
|---|---|---|
client_identities().list(&ClientIdentityFilter) |
Vec<ClientIdentity> |
GET /client-identities |
client_identities().get(identity) |
ClientIdentity |
GET /client-identities/:ref |
client_identities().create(&NewClientIdentity) |
Created<ClientIdentity> |
POST /client-identities |
client_identities().update(uuid, &ClientIdentityPatch) |
ClientIdentity |
PATCH /client-identities/:uuid |
client_identities().regenerate_identify(uuid) |
ClientIdentity |
POST /client-identities/:uuid/identify |
client_identities().delete(uuid) |
() |
DELETE /client-identities/:uuid |
ClientIdentityFilter { uuid, identify }; get takes a uuid or the identify value. In a
NewClientIdentity, identify is Some(address) for Ip and None for Hostname and
Path — Dinio generates it. In a ClientIdentityPatch, name: Some(None) removes the name
and enabled: Some(false) switches the identity off.
Sinkhole lists, rules and links
Section titled “Sinkhole lists, rules and links”| Method | Returns | Route |
|---|---|---|
sinkhole_lists().list(shared) |
Vec<SinkholeList> |
GET /sinkhole-lists |
sinkhole_lists().get(uuid) |
SinkholeList |
GET /sinkhole-lists/:uuid |
sinkhole_lists().roles_page(uuid, limit, cursor) |
SinkholeRolePage |
GET /sinkhole-lists/:uuid?include=roles |
sinkhole_lists().all_roles(uuid) |
Vec<SinkholeRole> |
every page |
sinkhole_lists().create(&NewSinkholeList) |
Created<CreatedSinkholeList> |
POST /sinkhole-lists |
sinkhole_lists().update(uuid, &SinkholeListPatch) |
SinkholeList |
PATCH /sinkhole-lists/:uuid |
sinkhole_lists().set_shared(uuid, is_shared) |
SinkholeList |
PATCH /sinkhole-lists/:uuid |
sinkhole_lists().delete(uuid) |
() |
DELETE /sinkhole-lists/:uuid |
sinkhole_roles().get(uuid) |
SinkholeRole |
GET /sinkhole-roles/:uuid |
sinkhole_roles().create(&NewSinkholeRole) |
Created<SinkholeRole> |
POST /sinkhole-roles |
sinkhole_roles().update(uuid, &SinkholeRolePatch) |
SinkholeRole |
PATCH /sinkhole-roles/:uuid |
sinkhole_roles().delete(uuid) |
() |
DELETE /sinkhole-roles/:uuid |
sinkhole_links().list(&SinkholeLinkFilter) |
Vec<SinkholeLink> |
GET /sinkhole-links |
sinkhole_links().create(client_identity_id, list_id) |
SinkholeLink |
POST /sinkhole-links |
sinkhole_links().delete(client_identity_id, list_id) |
() |
DELETE /sinkhole-links/:cid/:sid |
NewSinkholeList::link_to applies the new list to your identities in the same request; the
created links come back in CreatedSinkholeList::links. In a SinkholeListPatch,
name: Some(None) removes the name. get returns the list with its source, without rules.
A list can have up to 500 000 rules, so they come in pages: roles_page takes a limit
(Option<u32>, 1–5 000, default 500) and the next_cursor of the previous page, and returns
SinkholeRolePage { roles, next_cursor, total }; all_roles reads every page.
use dinio_sdk::models::{ ClientIdentityType, NewClientIdentity, NewSinkholeList, NewSinkholeRole, SinkholeListType, SinkholeRoleType,};
let phone = client .client_identities() .create(&NewClientIdentity { identify: None, // generated for Path id_type: ClientIdentityType::Path, name: Some("phone".into()), }) .await?;println!("DoH path part: {}", phone.value.identify);
let created = client .sinkhole_lists() .create(&NewSinkholeList { list_type: SinkholeListType::Blacklist, name: Some("ads".into()), is_shared: None, link_to: vec![phone.value.uuid], }) .await?;
client .sinkhole_roles() .create(&NewSinkholeRole { list_id: created.value.list.uuid, sinkhole_type: SinkholeRoleType::Wildcard, pattern: "*.tracker.example".into(), }) .await?;Import, export and URL sources
Section titled “Import, export and URL sources”| Method | Returns | Route |
|---|---|---|
sinkhole_lists().import(uuid, file, SinkholeImportMode, dry_run) |
SinkholeImportReport |
POST /sinkhole-lists/:uuid/import |
sinkhole_lists().export(uuid, SinkholeExportFormat) |
String |
GET /sinkhole-lists/:uuid/export |
sinkhole_lists().set_source(uuid, &NewSinkholeSource, dry_run) |
SinkholeSourceResult |
PUT /sinkhole-lists/:uuid/source |
sinkhole_lists().set_refresh_interval(uuid, Option<u32>) |
SinkholeListSource |
PATCH /sinkhole-lists/:uuid/source |
sinkhole_lists().refresh(uuid, force) |
SinkholeSourceResult |
POST /sinkhole-lists/:uuid/refresh |
sinkhole_lists().delete_source(uuid) |
() |
DELETE /sinkhole-lists/:uuid/source |
file is the file’s content as a String — hosts, plain names, Adblock/AdGuard, dnsmasq or
the Csv export, mixed per line. SinkholeImportMode is Merge or Replace;
SinkholeExportFormat is Csv, Adblock, Hosts or Domains. NewSinkholeSource { url, refresh_interval_s } takes the interval in seconds, None for only on request. The report
carries the API’s warnings; SinkholeSourceResult { source, import, … } has import: None
when a refresh found the file unchanged. See
Import and export for the formats and limits.
import, set_source and refresh wait at least 120 s
(SinkholeLists::IMPORT_TIMEOUT), or the client’s timeout if it is longer.
use dinio_sdk::models::{NewSinkholeSource, SinkholeExportFormat, SinkholeImportMode};
let lists = client.sinkhole_lists();let list_id = created.value.list.uuid;
let preview = lists.import(list_id, hosts.clone(), SinkholeImportMode::Replace, true).await?;println!("+{} -{}", preview.added, preview.removed);lists.import(list_id, hosts, SinkholeImportMode::Replace, false).await?;
let csv = lists.export(list_id, SinkholeExportFormat::Csv).await?;
// Or let Dinio download it, now and once a day. Such a list takes no manual rules.lists .set_source(list_id, &NewSinkholeSource { url: "https://blocklists.example.com/hosts.txt".into(), refresh_interval_s: Some(86_400), }, false) .await?;Statistics
Section titled “Statistics”| Method | Returns | Route |
|---|---|---|
stats().zones_queries(group_by, &query) |
StatsSeriesResult |
GET /stats/zones/queries |
stats().zones_top(&query) |
ZoneTopList |
GET /stats/zones/top |
stats().zone_queries(zone, group_by, name, &query) |
StatsSeriesResult |
GET /stats/zones/:ref/queries |
stats().zone_nx(zone, &query) |
ZoneNxStats |
GET /stats/zones/:ref/nx |
stats().zone_names(zone, &query) |
ZoneNamesStats |
GET /stats/zones/:ref/names |
stats().zone_nx_names(zone, &query) |
StatsTopList |
GET /stats/zones/:ref/nx-names |
stats().client_events(scope, group_by, &query) |
StatsSeriesResult |
GET /stats/clients/events |
stats().client_rcodes(scope, group_by, &query) |
StatsSeriesResult |
GET /stats/clients/rcodes |
stats().client_blocked(scope, &query) |
StatsTopList |
GET /stats/clients/blocked |
Series take a StatsSeriesQuery { from, to, step, compare, mode, fill, limit }, top lists a
StatsTopQuery { from, to, limit }; Default leaves everything to the API. from and to
are Timestamps, made from a SystemTime or an RFC 3339 string. group_by is an
Option<StatsGroup>. ClientScope is Mine (default: all your identities) or
Client(uuid). zones_queries and zones_top sum over every zone you can see. A range
further back than the package allows is ErrorCode::PlanLimit. Times in responses are
milliseconds; see
Statistics for the fields.
use std::time::{Duration, SystemTime};use dinio_sdk::api::{StatsGroup, StatsSeriesQuery, StatsStep};
let week = StatsSeriesQuery { from: Some((SystemTime::now() - Duration::from_secs(7 * 86_400)).into()), step: Some(StatsStep::Day), ..Default::default()};let result = client .stats() .zone_queries("example.com", Some(StatsGroup::Qtype), None, &week) .await?;for series in &result.series { println!("{} {}", series.label, series.total);}Audit log
Section titled “Audit log”| Method | Returns | Route |
|---|---|---|
audit_logs().list(&AuditLogFilter) |
CursorPage<AuditLog> |
GET /audit-logs |
audit_logs().list_all(&AuditLogFilter) |
Vec<AuditLog> |
every page |
AuditLogFilter has from, to, zone_id, action (exact, or a prefix ending in *),
outcome, category, actor_type, actor_user_id, resource_type, resource_id,
request_id, limit and cursor, all optional. list returns
CursorPage { items, next_cursor }; pass next_cursor back as cursor for the next page,
or let list_all do it.
Your account
Section titled “Your account”| Method | Returns | Route |
|---|---|---|
users().get(user) |
User |
GET /users/:ref |
users().create_api_key(uuid) |
Secret |
POST /users/:uuid/api-key |
users().revoke_api_key(uuid) |
User |
DELETE /users/:uuid/api-key |
user is your uuid or email. A new personal key replaces the one the client is using, so
build a new client with it.
Packages and limits
Section titled “Packages and limits”| Method | Returns | Route |
|---|---|---|
plans().list() |
Vec<Plan> |
GET /plans |
account().subscriptions() |
Vec<Subscription> |
GET /account/subscriptions |
account().choose(PlanLine, code, Option<BillingPeriod>) |
Subscription |
PUT /account/subscriptions/:line |
account().usage() |
Vec<LineUsage> |
GET /account/usage |
plans().list() needs no key. PlanLine is Auth or Filter; BillingPeriod (Monthly
or Yearly) is required for a paid package and refused for a free one. account() needs a
personal key. See Packages and usage.
ApiWarning::plan_usage() reads a PLAN_USAGE warning as (key, limit, used);
ApiError::plan_limit() reads a PLAN_LIMIT error (see Errors).
ops::next_plan_for(&plans, key, limit) is the first self-service package of that line that
allows more — the upgrade to offer; ops::next_plan_for_minimum does the same for a setting
where less is better (auth.healthchecks.min_interval_s).
use dinio_sdk::models::PlanLine;use dinio_sdk::ops;
let plans = client.plans().list().await?;client.account().choose(PlanLine::Auth, "free", None).await?;
let created = client.zones().create("example.com").await?;for warning in &created.warnings { if let Some((key, limit, used)) = warning.plan_usage() { let next = ops::next_plan_for(&plans, key, limit); eprintln!("{key}: {used} of {limit}; more with {:?}", next.map(|p| &p.name)); }}Don’t hard-code limits or prices: read them from plans().list() and account().usage().