API reference
Every route, field and answer is also in the machine-readable OpenAPI 3.1 file. Prices are on the pricing page.
- Basics
- Paid routes
- The free check before payment
- Delegation: your zone's own nameservers
- Paying without an account
- Cashu mints
- Failures after payment
- Authentication
- NIP-98 signed requests
- API keys
- Orders with an account
- Zones and records
- DNSSEC
- Dynamic DNS
- Secondary DNS
- Outside secondaries for hosted zones
- LNURL renewals
- Expiry, grace and reminders
- Account features
- Domain registration
- Limits and quotas
- Rate limits
- Errors
- Status and discovery
Basics
| Name | What |
|---|---|
https://api.satsdns.com | The API, for agents and scripts |
https://app.satsdns.com | The dashboard (Nostr login); it uses the same API under /v1/ |
https://pay.satsdns.com | Lightning addresses for renewals (LNURL-pay) only |
<label>.ns1.satsdns.com, <label>.ns2.satsdns.com | The nameservers your domain is delegated to: each zone gets its own pair (see delegation) |
dynkite.com | The domain for dynamic-DNS hostnames (myhost.dynkite.com) |
- Request and response bodies are JSON (
Content-Type: application/json), at most 256 KiB. - Times are UTC in ISO 8601 (
2026-11-20T10:00:00Z). Quota days and months are UTC. - Errors look like
{"error": {"code": "...", "message": "...", "status": 409, "detail": {...}}}withCache-Control: no-store; see errors. Every answer carries anX-Request-Id. - Zone names are ASCII (internationalized names as
xn--). Hostnames are the single label underdynkite.com(myhostormyhost.dynkite.com).
Paid routes
All are POST with a JSON body, on https://api.satsdns.com.
| Route | Body | Sats | Result |
|---|---|---|---|
/v1/zones/create/30d | {"zone": "example.org", "ns_ticket": "nst_..."} (see delegation) | 500 | 201: zone status with its nameservers, token (shown once), DNSSEC DS data |
/v1/zones/create/1y | same | 5,000 | |
/v1/zones/renew/30d | {"zone": "example.org"} | 500 | 200: status with the new expires_at (old end date + period). No token needed. Works for hosted and secondary zones. |
/v1/zones/renew/1y | same | 5,000 | |
/v1/zones/queries/1m | {"zone": "example.org"} | 200 | 200: 1,000,000 more queries for the current UTC month |
/v1/secondary/create/30d | {"zone": "example.net", "primaries": ["198.51.100.7"], "ns_ticket": "nst_..."} | 500 | 201: status, token and the TSIG key (each shown once); see secondary DNS |
/v1/secondary/create/1y | same | 5,000 | |
/v1/ddns/create/30d | {"hostname": "myhost"} | 200 | 201: hostname status, token (shown once) |
/v1/ddns/create/1y | same | 2,000 | |
/v1/ddns/renew/30d | {"hostname": "myhost"} | 200 | 200: status with the new expires_at. No token needed. |
/v1/ddns/renew/1y | same | 2,000 |
The body must hold exactly the fields shown (ns_ticket and previous_token are optional and only
for the create routes). HEAD or GET on
/v1/zones/create/30d, /v1/zones/renew/30d, /v1/ddns/create/30d and
/v1/secondary/create/30d returns a shared 402 that only shows the price (for directories); to buy,
POST your body and pay your own 402. HEAD and GET on the other paid routes are
405.
The free check before payment
Before any invoice is made, the request is checked. A request that would fail gets its error and no invoice. The same checks run again after payment.
| Check | Error |
|---|---|
JSON body with exactly the expected fields; Content-Type: application/json | 400 missing_parameter, unknown_parameter, invalid_body; 415 |
| Zone name: letters, digits and hyphens per label, at least 2 labels, at most 253 characters | 400 invalid_zone_name |
Not a reserved or special-use name (.arpa, .example, .invalid, .local, .localhost, .onion, .test, .alt, .internal, example.com/.net/.org, common private suffixes), and not one of our own domains | 403 reserved_name |
| Not blocked, not on a public phishing list, not below a blocked or listed name | 403 name_blocked (zones), hostname_refused (hostnames) |
| Not hosted here already, and no hosted parent or child zone | 409 zone_exists, zone_clash |
| Delegation: the parent zone's servers list both of the zone's own nameservers (other nameservers may be listed too); see delegation | 409 delegation_missing, delegation_mismatch with detail.expected_ns and detail.found_ns; ns_ticket_invalid, ns_offers_exhausted; 429 ns_offer_limit |
| Not a domain registered through SatsDNS by someone else, and not a recently removed zone of someone else | 409 domain_owner_only, zone_reserved |
| Renewals: exists, not suspended, not past grace, and not more than 1,125 days ahead afterwards | 404, 403, 409 too_far_ahead |
Hostname: one label, 4 to 63 characters, a-z, 0-9 and hyphens, no -- in positions 3-4 (so no xn--), not reserved (www, mail, api, admin and similar), no brand or lure words | 400 invalid_hostname, 403 reserved_name, hostname_refused |
| Hostname not taken | 409 hostname_taken |
| Secondary: 1 to 4 public IPv4 primaries, at most 20 zones per primary address | 400 bad_primary, ipv6_primary_unsupported; 409 limit_reached |
| Our DNS servers reachable | 503 dns_backend_unavailable with Retry-After |
The delegation check asks the parent zone's authoritative servers directly (no resolver cache), so you can order as
soon as your registry publishes the new NS records. It gives up after 8 seconds (503
delegation_check_unavailable, retry).
Delegation: your zone's own nameservers
Each zone is served under its own pair of nameserver names, for example k7qd.ns1.satsdns.com and
m3x9.ns2.satsdns.com. Pointing a domain at us therefore cannot let anyone else take it over: only the
party that got the pair, and made the registry list it, can create the zone.
- Send the create request without payment:
{"zone": "example.org"}. The free check answers409 delegation_missingwithdetail.expected_ns(your pair),detail.ns_ticket(a secret, shown once) anddetail.ns_offer_expires_at(48 hours; an account's pair 7 days). Register the domain first: for a name its registry does not know, the answer has noexpected_ns(detail.registeredfalse). At most two open pairs per network and zone, and 20 new pairs a day per network (429ns_offer_limit). - At your registrar, set exactly those two names as the domain's nameservers (other providers' nameservers may stay listed). Wait until the registry publishes them.
- Send the request again with the ticket:
{"zone": "example.org", "ns_ticket": "nst_..."}. Now you get the402; pay as below. The create answer lists the zone'snameservers.
Keep the ticket: asking again without it gives a new pair (at most two per network and zone), and a pair the registry
already lists is never given to anyone else. With an account, the pair belongs to the account and no ticket is needed
(the order check, pay: "check", shows it). Domains registered through SatsDNS can be hosted only by
their owner's account and keep ns1.satsdns.com and ns2.satsdns.com.
When a zone is removed (deleted or expired), first remove its NS records at your registrar. For 90 days, and for as
long as the registry still lists the zone's nameservers, only its last owner can host the name again: the same
account, or the old zone token sent as "previous_token" in the create body; it gets the same nameservers
back.
Paying without an account
A valid request without payment gets 402 Payment Required with two ways to pay in the same answer.
L402 (Lightning)
- The
WWW-Authenticateheader holdsL402 macaroon="...", invoice="lnbc...". - Pay the invoice with any Lightning wallet and keep the preimage (64 hex characters).
- Repeat the same request with
Authorization: L402 <macaroon>:<preimage>.
The invoice and macaroon are valid for 1 hour and pay one request on that route and method. When the service's own
Lightning node cannot make an invoice, an accepted Cashu mint issues it and X-L402-Invoice-Issuer names the
mint; pay it the same way. If no invoice can be made at all, the 402 offers Cashu only, with Retry-After.
curl -i -X POST https://api.satsdns.com/v1/ddns/create/30d \
-H "Content-Type: application/json" -d '{"hostname": "myhost"}'
# pay the invoice from www-authenticate, then:
curl -X POST https://api.satsdns.com/v1/ddns/create/30d \
-H "Authorization: L402 <macaroon>:<preimage>" \
-H "Content-Type: application/json" -d '{"hostname": "myhost"}'
Cashu (NUT-24)
The 402 also carries X-Cashu: creqA..., a payment request with the amount, the unit (sat) and the
accepted mints. Repeat the request with a cashuB... token in the X-Cashu header.
| Mint | Price |
|---|---|
https://mint.minibits.cash/Bitcoin, https://21mint.me, https://mint.routstr.com, https://forge.flashapp.me, https://mint.cashu.chat | List price |
https://mint.coinos.io, https://mint.macadamia.cash | List price plus the mint's swap fee, ceil(sum of input_fee_ppk over the token's proofs / 1000) sats: send with your wallet's "include fees" option |
A token that is short, or from a mint that is not accepted right now, gets 400 with an
X-Cashu-Error header and is not spent. No change is returned on overpayment. The current list is always in
/.well-known/l402-services.
Failures after payment
If a paid request is refused after payment (for example the zone was taken by someone else in between, or the
delegation changed), the answer is 422 with the reason, or 503 if our side failed. Either way:
- L402: the answer says
X-L402-Retry: allowed, and the same proof works once more on the same route and method within the hour (fix the body and try again). - Cashu: the answer carries a refund token for the price in
X-Cashu-Refund. Redeem it promptly: until you do, anyone who sees it can take it.
At most one retry or refund per payment and 10 per IP address per hour, within a service-wide hourly budget. If no automatic refund could be made, ask through the contact page.
If the answer to a paid zone create is lost (a timeout), send the identical request again with the same L402 proof
within 15 minutes: you get the zone back with a new token (an earlier one, if it arrived, stops working). A create whose
checks run too long is refused with 422 create_timeout rather than finished late.
Authentication
Management routes are free per call. Each accepts any of these credentials:
| Credential | Header | Grants |
|---|---|---|
Zone token jdz_... | Authorization: Bearer jdz_... | Everything for that zone |
Hostname token jdh_... | Authorization: Bearer jdh_..., or Basic on /nic/update | Everything for that hostname |
| NIP-98 signed event | Authorization: Nostr <base64 event> | Everything the account owns, and the account itself |
API key jdk_... | Authorization: Bearer jdk_... | What its scope says; never sensitive actions |
| Dashboard session | Cookie, on app.satsdns.com only | The account, except sensitive actions |
- Tokens and keys are 256-bit random values, shown once and stored only as a hash. We cannot show them again. Rotate a
token with
POST /v1/zones/{zone}/tokenorPOST /v1/ddns/{hostname}/token; the old one stops working at once. - A wrong token and an unknown zone get the same
401. - Sensitive actions need a fresh NIP-98 signature (a session or API key gets
401 step_up_required): creating or revoking API keys, setting a recovery key, transfers, recoveries, rotating or disabling a token, deleting a zone, hostname or the account, asking for a refund, changing secondary primaries, TSIG keys or outside secondaries. A zone's own token may also do the actions for that zone. A token acts for the account that owns its object: while that account is banned the token is read-only. - A token-bought zone can be claimed into an account:
POST /v1/account/claim {"zone": "example.org", "token": "jdz_..."}(NIP-98). The token keeps working unless the owner disables it (PATCH /v1/zones/{zone}/settings {"token_disabled": true}, NIP-98).
NIP-98 signed requests
An account is a Nostr public key. It is created when it first orders, claims or sets something; reading
GET /v1/account creates nothing. To sign a request, make a kind 27235 event with empty content
and these tags, sign it, base64-encode the JSON and send Authorization: Nostr <base64>:
["u", "https://api.satsdns.com/v1/account/zones"]: the exact URL you call, scheme, host, path and query byte for byte (no port, no fragment). An event signed for one host does not work on another.["method", "POST"]: the HTTP method in upper case.["payload", "<sha256 hex of the raw body>"]: required on every method except GET and HEAD. Hash the exact bytes you send; an empty body hashes toe3b0c442...b855.
created_at must be within 60 seconds of our clock, and each event id is accepted once. A failed check is
401 with a code such as auth_expired, auth_replayed, auth_url_mismatch,
auth_method_mismatch, auth_payload_mismatch or bad_signature, plus a
Date header and detail.server_time so you can correct your clock. In JavaScript, nostr-tools'
nip98.getToken(url, method, sign, true, payload) makes the header; send exactly the body string it hashed.
API keys
POST /v1/account/keys (NIP-98)
{"label": "ci", "scope": {"objects": ["zone:example.org", "host:myhost"], "access": "write"},
"expires_at": 1798761600}
objects:["*"]or a list ofzone:NAMEandhost:LABEL.access:readorwrite(records, dynamic-DNS updates).- Keys can order renewals and extra queries for objects in their scope, but never new zones or hostnames.
- At most 20 live keys per account; optional expiry up to 2 years ahead. The key is shown once. Revoke with
DELETE /v1/account/keys/{id}. - A key follows the current owner: a zone transferred to another account drops out of the old owner's keys.
Orders with an account
POST /v1/account/orders (NIP-98, API key for renewals, or the dashboard)
Idempotency-Key: 6f3c0f0e-... (optional; same key + same body within 24 h = same answer;
409 idempotency_in_progress while the first is still running)
{"product": "zone_create", "period": "1y", "zone": "example.org", "pay": "onchain"}
| Field | Values |
|---|---|
product | zone_create, zone_renew, zone_queries, secondary_create (with primaries), ddns_create, ddns_renew; for domains domain_register, domain_renew, domain_transfer (see domain registration) |
period | 30d or 1y (not for zone_queries) |
zone or hostname | The name |
pay | lightning (default), onchain (orders of at least 5,000 sats), both (either rail, for orders of at least 5,000 sats), or check (run the free checks only: 204 or the error, nothing created) |
The answer is 201 {"invoice": {...}} with lightning.bolt11 (valid 15 minutes) and/or
onchain.address and onchain.bip21 (valid 24 hours). Poll GET /v1/invoices/{id}:
when the state is fulfilled, result holds the zone or hostname status and, once, its token.
- On-chain: pay the exact amount to the new address. A payment seen in the mempool before the deadline still counts;
the order counts as paid, and runs, once the payment has 3 confirmations. The invoice shows each payment's
confirmations meanwhile (
onchain.outputs[].confirmations,onchain.min_confirmations). - Paid in time and in full: the order runs. Paid late (Lightning settled after expiry, or an on-chain payment first seen after the deadline) or short: the order does not run, and the money is refundable on request. Overpaid: the order runs and the extra is kept unless you ask for it back.
- Refunds:
POST /v1/account/refunds {"invoice_id": "inv_...", "rail": "lightning", "destination": "name@example.com"}(NIP-98; on-chain:"rail": "onchain"and abc1address). Each is reviewed and paid by hand, from 1,000 sats, with the network fee deducted; refunds of on-chain payments wait for 3 confirmations. Amounts below 1,000 sats (for example a late or short payment on a 200- or 500-sat order) cannot be refunded.GET /v1/account/refundsshows their state. GET /v1/account/invoiceslists your invoices;GET /v1/account/ledgerlists the payments, orders and refunds of the account.
Zones and records
| Route | What |
|---|---|
GET /v1/zones/{zone} | Status: status (active, grace, suspended), expires_at, grace_ends_at, days_left, nameservers, record count, limits, writes_today, query use |
GET /v1/zones/{zone}/records[?name=&type=] | All record sets; SOA and apex NS are marked managed; DNSSEC records are not listed |
POST /v1/zones/{zone}/records | One record set {"name", "type", "ttl", "records": [...]}; replaces that name and type |
PUT /v1/zones/{zone}/records/{name}/{type} | {"ttl", "records"}, replace |
DELETE /v1/zones/{zone}/records/{name}/{type} | Delete a record set |
PATCH /v1/zones/{zone}/records | {"rrsets": [{..., "changetype": "REPLACE" | "DELETE"}]}, up to 100, applied together or not at all |
GET /v1/zones/{zone}/dnssec | DS, CDS, CDNSKEY and DNSKEY data; see DNSSEC |
POST /v1/zones/{zone}/token | New token; the old one stops working |
DELETE /v1/zones/{zone} | Remove the zone now (no refund of the rest of the period). Remove its NS records at your registrar too; the name stays reserved for you (see delegation) |
- Types: A, AAAA, CNAME, TXT, MX, SRV, CAA, HTTPS, SVCB, TLSA, NS (below the apex only, to delegate a subdomain) and DS (only where NS is). SOA, the apex NS and DNSSEC records are managed for you.
- Names:
@or empty for the apex, a relative name (www) or a full name inside the zone.*only as the leftmost label. Underscores are allowed (_acme-challenge,_443._tcp). - Content is zone-file text. Names in content are absolute (
mail.example.orgmeansmail.example.org.). TXT may be unquoted; long values are split into 255-byte strings for you. - TTL from 60 to 86,400 seconds, default 3,600. At most 100 records per record set.
- Rules: a CNAME stands alone and never at the apex; DS needs NS at the same name; below a delegated subdomain only glue (A, AAAA).
- Every change is applied at once on both nameservers.
curl -X PATCH https://api.satsdns.com/v1/zones/example.org/records \
-H "Authorization: Bearer jdz_..." -H "Content-Type: application/json" -d '{"rrsets": [
{"name": "@", "type": "MX", "ttl": 3600, "records": ["10 mail.example.org"], "changetype": "REPLACE"},
{"name": "@", "type": "TXT", "ttl": 3600, "records": ["v=spf1 mx -all"], "changetype": "REPLACE"},
{"name": "old", "type": "A", "changetype": "DELETE"}]}'
DNSSEC
- Every hosted zone is signed from the moment it is created: one combined key (CSK), ECDSA P-256 (algorithm 13), online signing, NSEC3 per RFC 9276 (SHA-1, no opt-out, 0 extra iterations, no salt). CDS and CDNSKEY records are published.
- Denial of existence reveals hashes of the names in the zone: NSEC3 stops simple zone walking, but short or common names can be guessed from the hashes offline. Do not treat a name as secret.
GET /v1/zones/{zone}/dnssecreturnsds(SHA-256 DS records for your registrar),cds,cdnskey,dnskey,algorithm, a short how-to andparent_ds:missing,matchingormismatch, read from the parent zone.- To turn validation on, enter the DS record (or the DNSKEY, if your registrar asks for the key) at your registrar. Do not rely on the registry picking up CDS by itself.
- Before you move a zone away or delete it, remove the DS record at your registrar and wait for its TTL. A DS that points at keys no longer served makes validating resolvers reject your domain.
- Secondary zones are served as your primary signs them (pre-signed) or unsigned; you manage their keys and DS.
Dynamic DNS
A hostname myhost.dynkite.com carries A, AAAA, TXT and HTTPS records, and a TXT record at
_acme-challenge.myhost.dynkite.com for ACME DNS-01. All have a 60-second TTL.
Hostnames and their addresses are public: anyone who knows or guesses a hostname can look up its addresses, and dynkite.com's DNSSEC (NSEC3, no salt, no extra iterations) gives out a hash of every hostname, from which short or common ones can be guessed. Use a long random hostname if it should be hard to find.
Buy
curl -i -X POST https://api.satsdns.com/v1/ddns/create/30d \
-H "Content-Type: application/json" -d '{"hostname": "myhost"}'
# 402: pay 200 sats (L402 or Cashu) and repeat. The 201 answer holds "token": "jdh_..." (shown once).
Update with JSON
# Point A at the address this request comes from, and set an IPv6 address:
curl -X POST https://api.satsdns.com/v1/ddns/myhost/update \
-H "Authorization: Bearer jdh_..." -H "Content-Type: application/json" \
-d '{"a": "auto", "aaaa": "2001:db8::10"}'
# {"changed": true, "records": {...}, "hostname": "myhost.dynkite.com.", "ttl": 60}
- Fields:
a,aaaa,txt,https,acme_txt. A value or a list of up to 10 values replaces the set,nulldeletes it, and a field you leave out stays as it is. "auto"(foraoraaaa) means the address the request comes from. Call over IPv4 foraand over IPv6 foraaaa.- Only public (globally routed) addresses are accepted, also through
"auto". Private (10/8, 172.16/12, 192.168/16), loopback, link-local, shared CGNAT (100.64/10), IPv6 unique-local (fc00::/7), multicast, documentation and other reserved addresses, and IPv6 forms that carry such an IPv4 address, are refused with400 address_not_public(detail.reasonsays which kind); nothing is changed. A public name that points into a local network can be used against the visitors' own devices (DNS rebinding). - Unchanged values are not written and do not count against the 1,000 changes a day.
GET /v1/ddns/{hostname}shows the status, the end date and the current records.
ACME DNS-01
curl -X POST https://api.satsdns.com/v1/ddns/myhost/update \
-H "Authorization: Bearer jdh_..." -H "Content-Type: application/json" \
-d '{"acme_txt": "gfj9Xq...Rg85nM"}'
# ...let the CA validate, then remove it:
curl -X POST https://api.satsdns.com/v1/ddns/myhost/update \
-H "Authorization: Bearer jdh_..." -H "Content-Type: application/json" -d '{"acme_txt": null}'
Routers, ddclient and inadyn (dyndns2)
GET https://api.satsdns.com/nic/update?hostname=myhost&myip=203.0.113.7 with HTTP Basic
authentication: any user name, the hostname token as the password. Leave out myip to use the caller's address;
give IPv4 and IPv6 separated by a comma. Answers are plain text: good <ip>, nochg <ip>,
badauth, dnserr (not an IP address, or not a public one), abuse (suspended, over
the daily limit, or the owning account is banned) or 911.
curl -u "user:jdh_..." "https://api.satsdns.com/nic/update?hostname=myhost"
# good 203.0.113.7
# /etc/ddclient.conf
protocol=dyndns2
ssl=yes
server=api.satsdns.com
login=myhost
password='jdh_...'
myhost.dynkite.com
HTTPS only: the token travels in the request. Plain http gets badauth (401) and no redirect; a client
set to http has already sent its token in clear, so switch it to https and rotate the token
(POST /v1/ddns/{hostname}/token). Updates are limited to 12 a minute per address.
Secondary DNS
You run the primary. Our ns1 transfers the zone from it with a TSIG key; ns2 transfers from ns1. Delegate the domain to the zone's own pair of nameservers first, as for a hosted zone (delegation; your own servers may be listed too).
Create
POST /v1/secondary/create/30d (paid route, or an order with "product": "secondary_create")
{"zone": "example.net", "primaries": ["198.51.100.7"]}
primaries: 1 to 4 IPv4 addresses (not host names), port 53, public unicast only. Private, reserved and documentation addresses and our own addresses are refused (400 bad_primarywithdetail.reason). IPv6 primaries are not offered yet (400 ipv6_primary_unsupported). At most 20 zones per primary address.- The answer holds, once each: the zone
tokenandtsig{"name", "algorithm": "hmac-sha256", "secret"}. We do not keep the secret. It also liststransfer_source_ips,notify_toand ready-madeconfig_examplesfor BIND, Knot, NSD and PowerDNS.
Set up your primary
- Install the TSIG key and allow zone transfers (AXFR and IXFR) to that key from the
transfer_source_ips. - Send NOTIFY to the
notify_toaddress, from the primary address you registered. NOTIFY from any other address is refused, and at most 10 a minute per zone are accepted. - The first transfer starts within about a minute. Until then the status shows
health: pending.
# BIND (named.conf), with the values from the answer
key "jd-123-abcdef" { algorithm hmac-sha256; secret "<secret>"; };
zone "example.net" {
type primary;
file "example.net.zone";
notify explicit;
also-notify { <notify_to>; };
allow-transfer { key "jd-123-abcdef"; };
};
Run it
GET /v1/zones/{zone}/secondary: primaries,health(pending,ok,failing,refused_primary,too_large,stale,suspended), serial, last transfer, last error, record count.PUT /v1/zones/{zone}/secondary {"primaries": [...]}changes the primaries (and brings back a zone suspended as stale once your primary works again).POST /v1/zones/{zone}/secondary/tsigmakes a new key and returns its secret once. Transfers fail until your primary has the new key.- Records come from your primary only: the record routes answer
409 zone_is_secondary. - Owners get a Nostr message after 24 hours of failed transfers, and when a signed zone's signatures are less than 3
days from expiry. After 7 days of failures, or once the data is older than the zone's SOA expire, the zone is suspended
as
staleso we do not serve data you abandoned. - Zones above 20,000 records or 16 MiB per transfer are not served (
too_large). - Renew with
/v1/zones/renew/..., an order (zone_renew) or the zone's Lightning address. The query quota and grace period are the same as for hosted zones.
Outside secondaries for hosted zones
A hosted zone may also be served by up to 2 other providers' secondaries, listed as extra nameservers at your registrar:
PUT /v1/zones/{zone}/xfr-peers
{"peers": [{"address": "192.0.2.25", "tsig": true}, {"address": "198.51.100.40", "tsig": false}]}
With "tsig": true a key is made for that peer and shown once; with false the peer may
transfer the zone from its address without a key (for providers that cannot use TSIG). The peers get NOTIFY on every
change. The same address rules as for primaries apply. GET on the same route lists them.
LNURL renewals
POST /v1/zones/{zone}/lnurl (token, owner or write key; also /v1/ddns/{hostname}/lnurl)
# {"pay_id": "abcdefghijkm", "lnurl": "LNURL1...", "lightning_address": "abcdefghijkm@pay.satsdns.com",
# "rate": {"sat_per_30_days": 500, "min_sat": 100, "max_sat": 100000}, ...}
- Any Lightning wallet can pay the address or the LNURL. Each payment from 100 to 100,000 sats extends the zone pro rata: 500 sats = 30 days for zones, 200 sats = 30 days for hostnames, counted from the current end date.
- The address stops offering amounts beyond the 1,125-day limit, and refuses payments while the zone is suspended or past grace.
- It works for zones bought without an account, so anyone you give the address to can keep the zone running.
GETshows the current address;POSTagain replaces it (the old one stops working). - Renewal reminders to accounts include this address.
Expiry, grace and reminders
- Every status answer shows
expires_at,days_leftandgrace_ends_at. - After
expires_atthe status isgracefor 7 days: the zone keeps answering, can still be edited and renewed, and every status answer carries awarning. - After the grace period the zone (or the hostname's records) is removed, and the name can be bought again by anyone.
- Nothing renews by itself. Accounts get Nostr direct messages (NIP-17; NIP-04 as well if you turn on
notify_nip04withPATCH /v1/account) 14, 7 and 1 days before the end, when grace starts, 2 days before removal and at removal. Zones without an account get no messages: readdays_left.
Account features
| Route | What |
|---|---|
GET /v1/account | State, tier, limits, usage, recovery key, message settings |
GET /v1/account/zones, /v1/account/hostnames | What you own, with status |
POST /v1/account/claim | Claim a token-bought zone or hostname ({"zone" | "hostname", "token"}) |
PUT /v1/account/recovery {"recovery_npub": "npub1..."} | A second key that can recover the whole account. It takes effect 72 hours after it is set. |
POST /v1/account/recover | Signed by the recovery key: moves a lost account's zones to a new key after 72 hours. The lost account is told and can cancel. |
POST /v1/zones/{zone}/transfer {"to_npub": "npub1..."} | Offer a zone (or /v1/ddns/{hostname}/transfer) to another account; it accepts with POST /v1/account/transfers/{id}/accept within 72 hours. The token is retired on transfer (and on recovery); the new owner issues a new one with
POST .../token (re-enabling the old one gives 409 token_reissue_required). The recipient gets
one DM per object per offer period and at most 5 offer DMs a day; an account can make 10 offers a day. |
DELETE /v1/account | Delete the account (only when it owns nothing live; registered domains
or a domain order in progress give 409 account_has_domains) |
Recovery by token: if someone else's account owns a zone and you hold its token, a claim starts a 72-hour recovery; the owner is told and can cancel it (and should then rotate or disable the token). No key, no token and no recovery key: the zone keeps running until it expires; we cannot hand it over.
Domain registration
.com, .net and .org names, registered with our sponsoring registrar, Global
Domain Group LLC (IANA ID 3956), under the registration agreement. Domains are
ordered only with an account, in the dashboard (Register domain) or with
POST /v1/account/orders; they are not sold on the L402/Cashu routes. The dashboard's Domains screens also
cover renewal, nameservers, DNSSEC, lock, transfer codes and registrant changes. WHOIS
privacy is always on. Prices: pricing.
Check and quote (free)
GET /v1/domains/check?domain=example.com&years=1
Answers available, premium, prices_sat (register, or transfer when the name is
taken, and renew) and hosting_addon_sat. The answer is cached for 60 seconds. 6 checks a minute per
address.
Order
POST /v1/account/orders (NIP-98 or an API key)
{"product": "domain_register", "period": "1y", "domain": "example.com",
"registrant": {"name": "Ada Lovelace", "email": "ada@example.net", "phone": "+44.2071234567",
"address1": "1 Example Street", "city": "London", "postal_code": "N1 1AA", "country": "GB"},
"attestation": true, "nameservers": ["ns1.example.net", "ns2.example.net"], "host_zone": false,
"pay": "lightning"}
| Field | Values |
|---|---|
product | domain_register, domain_renew, domain_transfer |
period | 1y to 10y (default 1y); domain_transfer only 1y |
domain | A second-level name under .com, .net or .org, ASCII only |
registrant | Registrations and transfers. Required: name (real full name; for an organization, a person authorized to act for it), email (must accept mail), phone (+CC.NUMBER, for example +1.6045551234), address1, city, postal_code, country (ISO 3166 two letters). Optional: organization, address2, state. Plain Latin letters only; no placeholders |
attestation | true for registrations and transfers: "I am authorised by the named registrant to register this domain for them, and the registrant accepts the registration agreement." |
nameservers | "ours" (the default for a registration) or 2 to 13 host names. A transfer needs this or host_zone |
host_zone | true adds a hosted zone here for a year (the hosting add-on); its DS record is set at the registry for you |
auth_code | domain_transfer only: the transfer code from the current registrar; never shown back |
pay | lightning, onchain, both, or check (the free checks: 200 with the quote, no invoice) |
- Before any invoice the order is checked for free: the name, our blocked and protected names, the registrant data, the attestation, the daily limits (one new registration or transfer a day per account and per registrant email address), availability at the registrar and the price.
- Pay the invoice as for every account order. When it is
fulfilled,resultholds the domain status and, once, the domain token (jdd_...). The registration may still be completing at the registrar:result.order_stateandGET /v1/domains/{domain}showpendinguntil it is done. - If our registrar balance is short, the paid order waits for up to 24 hours (
waiting_for_funding); after that it fails and is refundable. Other failures before registration are refundable too; see the registration agreement. - A new registration starts on our nameservers. About 3 hours later (
hold_until), after a phishing-list check, your nameservers (nameservers_pending) are set or the hosted zone goes live. - The registrar emails the registrant a link to confirm the email address. If it is not used within 15 days, the
registrar suspends the domain until it is;
registrant_verificationshows the state and deadline.
Manage
Management routes accept the domain token (Authorization: Bearer jdd_...), NIP-98 by the owning account,
an API key with domain: scope, or a dashboard session. Actions marked "fresh signature" need a new NIP-98
signature (401 step_up_required) when you use an account. The domain token is as strong as the owner's key:
whoever holds it can change nameservers and DNSSEC, unlock the domain, change the registrant and replace the token, so
keep it as safe as your key. The one exception: when an account owns the domain, only the owner's NIP-98 signature gets
the transfer code.
| Route | What |
|---|---|
GET /v1/domains/{domain} | Status: status, expires_at, days_left, renew_until, nameservers, hold, hosted zone, DNSSEC, lock, registrant (read from the registrar), verification, transfer_lock_until, pending orders |
GET /v1/account/domains | The account's domains |
PUT /v1/domains/{domain}/nameservers | {"nameservers": "ours"} or 2 to 13 host names; queued during the hold |
PUT /v1/domains/{domain}/dnssec, DELETE | Set the DS record at the registry (your own nameservers only); remove every DS (fresh signature). POST /v1/domains/{domain}/dnssec/sync pushes the DS of a zone hosted here again |
PUT /v1/domains/{domain}/lock | Registrar lock on or off (off: fresh signature) |
POST /v1/domains/{domain}/auth-code | Transfer code, at once; unlocks the domain (fresh signature; the domain token is enough only when no account owns the domain). Not within 60 days of a registration or transfer in; 5 a day |
PUT /v1/domains/{domain}/registrant | Change the registrant ({"registrant": {...}, "attestation": true}, fresh signature); free; the registrar may ask for email confirmation |
PUT /v1/domains/{domain}/transfer | Correct the transfer code of a transfer in progress (fresh signature) |
POST /v1/domains/{domain}/token | Replace the domain token (fresh signature) |
PUT /v1/domains/{domain}/glue/{host} | Glue addresses for nameservers under the domain itself |
Renewal and expiry
- Nothing renews automatically. Renew with
domain_renew(1 to 10 years, at most 10 years ahead in total). The owning account gets Nostr direct messages 30, 7 and 1 days beforeexpires_atand at expiry. - At expiry the registrar puts the domain on hold and it stops resolving. Status
expired: still renewable through the API untilrenew_until(25 days after expiry). - Status
redemption: pastrenew_until. A restore is done by hand: ask through the contact page. See renewal, expiry and restore.
Domain errors
| Status | Codes |
|---|---|
| 400 | invalid_domain, tld_not_offered, idn_not_offered, invalid_period, invalid_registrant (with detail.field), attestation_required, invalid_nameservers, invalid_auth_code, invalid_ds, invalid_host |
| 403 | registrant_refused (refused country), name_blocked, domain_suspended |
| 404 | not_offered (registration not open), not_found |
| 409 | domain_taken, domain_exists, price_not_offered (premium or too expensive), limit_reached, domain_order_in_progress, domain_not_active, not_renewable, renew_window_closed, too_far_ahead, transfer_locked_60d, abuse_hold, change_pending, dnssec_managed, dnssec_not_managed, registrar_refused |
| 503 | registration_unavailable, price_unavailable, price_over_cap (prices paused for a check), email_check_unavailable, waiting_for_funding, with Retry-After where known |
Abuse reports
POST /v1/abuse/reports (no account) with {"domain", "category", "details", "contact"}
reports abuse of a domain, zone or hostname served or sold here now; any other name (also one removed, transferred
away or deleted) gets 404 not_ours. See the abuse page.
Limits and quotas
| Limit | Value |
|---|---|
| Records per zone | 1,000 (records, not record sets; SOA, apex NS and DNSSEC records not counted) |
| Records per record set; record sets per PATCH | 100; 100 |
| Record writes per zone | 1,000 per UTC day (each record written and each record set deleted counts 1; reads are free). Over it: 429 write_quota_exceeded until 00:00 UTC |
| TTL | 60 to 86,400 seconds (dynamic DNS: 60) |
| Queries per zone | 1,000,000 per calendar month (UTC), counted on both nameservers, plus any extra bought for that month |
| Over the query quota | The zone is flagged and its owner told at once; its records keep answering. Only answers with data count (not NXDOMAIN or empty answers, not traffic our rate limits drop or truncate). When use passes 120% of the quota, or 3 days after the flag, the operator reviews the zone and decides; records are never withdrawn automatically because of query volume. If the operator withdraws them, buying extra queries, or the next month, brings them back. |
| Dynamic DNS | Up to 10 values per record type; 1,000 changes per hostname per UTC day (429 update_quota_exceeded) |
| Paid ahead | At most 1,125 days (3 years and 30 days) from now |
| Grace after expiry | 7 days |
| Secondary | 4 primaries per zone, 20 zones per primary address, 20,000 records, 16 MiB per transfer |
| Outside secondaries of a hosted zone | 2 |
| Accounts: new zones and hostnames per UTC day | 3 (established accounts: 20) |
| Accounts: live zones and hostnames | 10 (established: 100) |
| Accounts: open Lightning invoices / open on-chain orders | 5 / 2 (established: 10 / 5) |
| Accounts: API keys / dashboard sessions / open transfer offers / new transfer offers a day | 20 / 10 / 3 / 10 |
| Accounts: requests | 120 a minute, of which 60 writes; 10 invoices a minute (429 account_rate_limited) |
| Accounts: new accounts per network | 30 a day per IPv4 /24 or IPv6 /48 (429 rate_limited); 500 a day in all (503 busy_try_later) |
| Invoices per network | 100 an hour per IPv4 /24 or IPv6 /48 (429 rate_limited), 600 an hour in all (429 busy_try_later); Lightning-address invoices: 30 an hour per Lightning address, 200 per network, 600 in all |
Domain checks (GET /v1/domains/check and orders with "pay": "check") | Answers cached for 60 seconds; a name not in the cache counts against 6 a minute per address, 10 per network and 20 in all |
An account becomes established by itself after 30 days and 10,000 sats paid in total. If you need more, ask through the contact page.
Rate limits
Per IPv4 address or IPv6 /64. Over the limit: 429 rate_limited with
Retry-After.
| Requests | Rate |
|---|---|
| Paid routes without a payment proof (including HEAD/GET there) | 20 a minute |
| Paid routes with a payment proof | 120 a minute |
| Management and account reads (GET) | 120 a minute, burst 60 |
| Management and account writes | 60 a minute, burst 30 |
Invoice status polls (GET /v1/invoices/{id}) | 120 a minute |
Dynamic-DNS updates (/v1/ddns/{hostname}/update, /nic/update) | 12 a minute, burst 10 |
Dashboard login (/v1/auth/) | 30 a minute |
| LNURL routes | 20 a minute |
/v1/info, /healthz, discovery files | 60 a minute |
Service-wide limits also apply. Limits per address and per network are checked first, and a refused request counts against none of the others. Do not spread requests over many addresses to get around these.
Errors
| Status | Common codes |
|---|---|
| 400 | invalid_body, missing_parameter, unknown_parameter, invalid_zone_name, invalid_hostname, invalid_rrset, invalid_record, invalid_ttl, record_type_not_allowed, cname_conflict, cname_at_apex, ds_without_ns, occluded_name, bad_primary, invalid_address, address_not_public, onchain_minimum, amount_out_of_range, invalid_scope |
| 401 | missing_token, auth_required, invalid_token, invalid_auth, auth_expired, auth_replayed, auth_url_mismatch, auth_method_mismatch, auth_payload_required, auth_payload_mismatch, bad_signature, step_up_required, session_expired |
| 402 | Payment required (paid routes) |
| 403 | reserved_name, name_blocked, hostname_refused, hostname_suspended, forbidden_scope, account_banned, account_deleted |
| 404 | not_found, invoice_not_found |
| 405 | method_not_allowed, with Allow |
| 409 | zone_exists, zone_clash, hostname_taken, delegation_missing, delegation_mismatch, ns_ticket_invalid, ns_offers_exhausted, zone_reserved, domain_owner_only, too_far_ahead, zone_is_secondary, limit_reached, idempotency_conflict, idempotency_in_progress, transfer_pending, recovery_pending, token_reissue_required, account_has_domains, account_has_objects |
| 413 / 415 | Body too large / not JSON |
| 422 | Refused after payment (see failures after payment); invalid_request for a query parameter of the wrong type (for example years=abc) |
| 429 | rate_limited, account_rate_limited, busy_try_later, write_quota_exceeded, update_quota_exceeded, ns_offer_limit, all with Retry-After |
| 500 | internal_error (an unexpected fault on our side) |
| 503 | dns_backend_unavailable, lightning_unavailable, onchain_unavailable, busy_try_later, delegation_check_unavailable, delegation_check_busy, with Retry-After where known |
LNURL routes answer in the LNURL format ({"status": "ERROR", "reason": "..."} with HTTP 200), as wallets
expect.
Status and discovery
- GET /healthz:
{"ok": true, ...}when the API, its database and the DNS servers are up;503otherwise. - GET /v1/info: nameservers, the dynamic-DNS domain, prices, periods, grace, limits, record types, DNSSEC settings, payment options and the secondary transfer addresses.
- GET /.well-known/l402-services: the paid routes, prices and accepted Cashu mints with their fees.