The dns.lgbt API
Last updated · OpenAPI document
With the API, programs can manage the records and DNSSEC of your domains: get certificates from Let’s Encrypt with DNS challenges, keep a record pointed at a changing IP address, or keep your zone in a repository. Domains themselves are added and deleted in the web app only.
Getting started
- Open your account and create a token under “API tokens”. Copy it right away: we only store a fingerprint of it, so we can’t show it again.
- Send it with every request, in the
Authorizationheader. Every address starts withhttps://dns.lgbt/api/v1.
curl https://dns.lgbt/api/v1/me -H "Authorization: Bearer dnslgbt_pat_…"Bodies are JSON (Content-Type: application/json), and so are the answers. Changes go live on every nameserver within seconds once the domain is delegated to dns.lgbt: the domain’s publishPending says whether some are still waiting, and its serial grows with every published version.
The API is for people who agreed to our terms of service: until you have agreed to them once in the app, the API answers terms_not_accepted.
Tokens and what they allow
A token belongs to your account and acts for you. Give each program its own token with only what it needs, so you can delete it on its own if it leaks. You can have up to 20.
| Scope | Allows |
|---|---|
domains:read | See your domains |
records:read | See records |
records:write | Change records |
dnssec:read | See DNSSEC settings |
dnssec:write | Turn DNSSEC on and off |
ip_sources:read | See IP sources |
ip_sources:write | Set IP sources |
- Domains: a token can be limited to some of your domains. The others answer
not_found, as if they didn’t exist. Without a limit it works for every domain, including the ones you add later. - Records: a token can be limited to records by name and type, with up to 20 rules. A name ending in
.*also covers every name that starts with it:_acme-challenge.*covers_acme-challengeand_acme-challenge.www. Such a token only sees those records, can’t change others, and can’t import zone files. - Expiry: tokens last 30, 90, 180 or 365 days (90 unless you choose). We let you know when a token is created and 7 days before it expires, and delete it 30 days after.
Records
A record looks like this:
{
"id": "cmvk…",
"name": "@",
"type": "MX",
"ttl": 3600,
"data": { "priority": 10, "exchange": "mail.example.com" },
"value": "10 mail.example.com",
"etag": "\"mvkmdoup\"",
"createdAt": "2026-10-23T06:59:36.337Z",
"updatedAt": "2026-10-23T06:59:36.337Z"
}nameis relative to the domain:@for the domain itself,www,_dmarc, or a wildcard like*.dev.dataholds the fields of the record’s type (see below). Host names may end with a dot or not; we store them without.ttlis 1 to 86,400 seconds, 3600 if you leave it out.- The same rules apply as in the web app: a CNAME stands alone at its name, a DS record needs NS records at its name, no record is there twice, and a domain has at most 1,000 records (unless your account has other limits, see your account page), at most 100 per name and type. The SOA, the domain’s own NS records and DNSSEC records are managed by dns.lgbt.
- A and AAAA records carry
proxied: whether their name is served through dns.lgbt’s proxies, with them as the origins. Proxying is switched in the web app; changing the address of a proxied record through the API keeps the name proxied, but an address that can’t be an origin (private, loopback, …) is refused. - Every record carries
managedBy: the dns.lgbt feature that keeps it, ornull. Today that is"wkd", theopenpgpkeyCNAME of a domain whose key directory (WKD) is on. Such a record can’t be changed or deleted (409 conflict) until WKD is switched off in the domain’s Settings, so leave it out when you sync a whole zone.
Changing records safely
- ETags: every record and record set has an ETag. Send it back as
If-Matchwhen you change or delete it, and the request fails withprecondition_failedif someone changed it in the meantime, in the web app or with another token.If-None-Match: *on a record set only creates it if it doesn’t exist yet. - Record sets:
PUT …/rrsets/www/Amakes the records with that name and type exactly the ones you send. Records that stay keep their id. This is the simplest way to set a value. - Batches:
POST …/records:batchmakes up to 100 changes all or nothing. The rules are checked against the result, so a batch can, for example, delete an A record and create a CNAME at the same name.
IP sources (dynamic DNS)
An IP source is a named address that changes, for instance the public address of your home connection. Instead of a fixed ipAddress, an A or AAAA record can follow a source: give it data.ipSourceId. When the source’s address changes, every record that follows it changes with it, in every domain. A source is either IPv4 (for A records) or IPv6 (for AAAA records). While it has no address, its records are held back: they aren’t served, and the record answers heldBack: true. You can have up to 50 sources.
Create sources on the IP sources page or with POST /ip-sources (scope ip_sources:write). On the page, “Generate key” gives a source a token of its own that can do nothing but read that source and set its address: give it to your router or a script, not a token of your whole account. Such a key can’t rename, create or delete sources (ip_source_key), and GET /ip-sources only shows it its own source. A token limited to some domains or records can’t have the IP source scopes at all, since a source’s records may be in every domain. On a record, giving data.ipSourceId clears ipAddress and the other way round.
#!/bin/sh
# Sets the IP source to this machine's public IPv4 address. KEY is the
# source's key from the IP sources page, ID its id (shown there, too).
IP=$(curl -sf https://api.ipify.org)
curl -sf -X PATCH "https://dns.lgbt/api/v1/ip-sources/$ID" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "{\"address\":\"$IP\"}"Sending the same address again is fine (routers do it every few minutes): it only updates lastSetAt. A source with records can’t be deleted (conflict); change or delete the records first. Records that follow a source can be at proxied names and onion services too: the proxies and onion nodes forward to the source’s current address, and while a name has no origin with an address, it isn’t in effect.
Limits
| Reads | 300 a minute per account |
|---|---|
| Changes | 60 a minute per account (unless your account has other limits, see your account page), together with the web app. A batch counts once per started 10 changes. |
| Zone file imports | 5 a minute per account |
| Requests with a wrong token | 20 a minute per IP address |
Answers carry RateLimit-Policy and RateLimit headers with what is left (r) and when the window moves on (t, seconds). Past the limit, you get rate_limited with a Retry-After header. Many changes to one domain in a row are published in bursts of a few, then at most every 10 seconds.
Errors
Errors are problem details (application/problem+json) with a stable code to switch on and a detail to show to people. field names the input it is about, and in a batch index names the change.
{
"type": "https://dns.lgbt/docs/api#errors-conflict",
"title": "Conflicts with the zone",
"status": 409,
"detail": "www already has other records, so it can’t have a CNAME record",
"code": "conflict",
"index": 1
}| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | Missing or invalid token |
insufficient_scope | 403 | The token doesn't allow this |
terms_not_accepted | 403 | Agree to the terms of service first |
record_not_allowed | 403 | The token can't change records with this name and type |
filtered_token | 403 | Tokens limited to some records can't do this |
ip_source_key | 403 | The key of an IP source can only read it and set its address |
not_found | 404 | Not found |
method_not_allowed | 405 | Method not allowed |
conflict | 409 | Conflicts with the zone, or the record is managed by dns.lgbt (managedBy) |
precondition_failed | 412 | Changed in the meantime |
payload_too_large | 413 | The request body is too large |
unsupported_media_type | 415 | Send JSON (Content-Type: application/json) |
validation_failed | 422 | Invalid input |
rate_limited | 429 | Too many requests |
Endpoints
Account
The token you are using.
GET/api/v1/me
The token and its user. Who the token belongs to and what it may do. Works with every token, whatever its scopes.
- Any token
- 200: The token
Domains
Your domains and whether they are served. Domains are added and deleted in the web app only.
GET/api/v1/domains
List domains. The domains of the account the token may see. Domains are added and deleted in the web app only.
- Scope:
domains:read - 200: The domains, by name
GET/api/v1/domains/{domain}
Get a domain. Whether the domain is served, its SOA serial and whether changes are still waiting to be published.
- Scope:
domains:read - 200: The domain
Records
Single records, and batches of changes.
GET/api/v1/domains/{domain}/records
List records. The records of the domain, oldest first. A token limited to some records only sees those.
- Scope:
records:read ?name=Only records with this name, e.g. www or @?type=Only records of this type, e.g. TXT- 200: The records
POST/api/v1/domains/{domain}/records
Create a record. Adds a record. The same rules apply as in the web app: a CNAME stands alone, a DS record needs NS records at its name, no duplicates, at most 100 records per name and type and 1000 per domain.
- Scope:
records:write - 201: The new record
GET/api/v1/domains/{domain}/records/{id}
Get a record.
- Scope:
records:read - 200: The record
PATCH/api/v1/domains/{domain}/records/{id}
Change a record. Changes the TTL and fields given; the others keep their values, except that giving ipAddress clears ipSourceId and the other way round (an A or AAAA record holds one of the two). Name and type can't change: delete the record and create a new one instead. Send the ETag you read as If-Match to make sure nobody changed the record in the meantime.
- Scope:
records:write If-Matchheader, optional- 200: The changed record
DELETE/api/v1/domains/{domain}/records/{id}
Delete a record.
- Scope:
records:write If-Matchheader, optional- 204: Deleted
POST/api/v1/domains/{domain}/records:batch
Change several records at once. Creates, changes and deletes up to 100 records in one go, all or nothing: if one change fails, none is made, and the error names it by index. The zone rules are checked against the result, so e.g. a CNAME can replace other records in the same batch. Counts as one write per started 10 changes.
- Scope:
records:write - 200: One result per change, in order
Record sets
All records with one name and type (an RRset) at once: the easiest way to set a value, e.g. for dynamic DNS.
GET/api/v1/domains/{domain}/rrsets/{name}/{type}
Get the records with one name and type. The record set (RRset), with an ETag for replacing it safely. A name without such records gives an empty list.
- Scope:
records:read - 200: The record set
PUT/api/v1/domains/{domain}/rrsets/{name}/{type}
Replace the records with one name and type. Makes the record set consist of exactly these records, all with the same TTL. Records whose data stays keep their id. Send If-Match with the ETag you read, or If-None-Match: * to only create a set that doesn't exist yet.
- Scope:
records:write If-Matchheader, optionalIf-None-Matchheader, optional- 200: The record set afterwards
DELETE/api/v1/domains/{domain}/rrsets/{name}/{type}
Delete the records with one name and type.
- Scope:
records:write If-Matchheader, optional- 204: Deleted (or there were none)
DNSSEC
Signing the zone, and the DS record for your registrar.
GET/api/v1/domains/{domain}/dnssec
Get the DNSSEC status. The status, and once every nameserver serves the signed zone, the DS record to add at your registrar.
- Scope:
dnssec:read - 200: The DNSSEC status
PUT/api/v1/domains/{domain}/dnssec
Turn DNSSEC on or off. Turning it on signs the zone; add the DS record at your registrar once the status is WAITING_FOR_DS. Turning it off keeps the zone signed until the registry has no DS record any more, so the domain keeps resolving.
- Scope:
dnssec:write - 200: The DNSSEC status afterwards
Zone files
Your records in the master file format.
GET/api/v1/domains/{domain}/zone
Export the zone file. The domain's records as a zone file, without the SOA and the apex NS records, which dns.lgbt manages. A token limited to some records only exports those.
- Scope:
records:read - 200: The zone file
POST/api/v1/domains/{domain}/zone:import
Import a zone file. Adds the records of a zone file, like the import in the web app: records dns.lgbt manages and duplicates are skipped, invalid ones and conflicts are reported and left out. With dryRun, nothing is imported. Not for tokens limited to some records. At most 5 imports per minute.
- Scope:
records:write - 200: What was (or would be) imported, entry by entry
IP sources
Named addresses that change (dynamic DNS): a router or script sets the address of a source, and every A or AAAA record that follows the source (data.ipSourceId instead of data.ipAddress) changes with it. A source without an address holds its records back.
GET/api/v1/ip-sources
List IP sources. Your IP sources, by name. The key of one source only sees that one.
- Scope:
ip_sources:read - 200: The sources
POST/api/v1/ip-sources
Create an IP source. Adds a source, with or without an address. You can have up to 50. Then put its id into data.ipSourceId of the A (IPV4) or AAAA (IPV6) records that should follow it, instead of data.ipAddress.
- Scope:
ip_sources:write - 201: The new source
GET/api/v1/ip-sources/{id}
Get an IP source.
- Scope:
ip_sources:read - 200: The source
PATCH/api/v1/ip-sources/{id}
Set the address of an IP source, or rename it. Setting the address (a string, or null for none) changes every record that follows the source within seconds; the same address again changes nothing but lastSetAt, so a client may send its address every few minutes. The key of one source can set its address, but not rename it. Tokens limited to some domains or records can't have the IP source scopes, since a source's records may be in every domain.
- Scope:
ip_sources:write - 200: The source as it is now
DELETE/api/v1/ip-sources/{id}
Delete an IP source. Refused (409 conflict) while records follow the source: change or delete them first.
- Scope:
ip_sources:write - 204: Deleted
Record types and their fields
The keys of data for each type. Numbers may also be sent as strings.
A
IPv4 address.
ipAddress | IPv4 address (optional) |
|---|---|
ipSourceId | IP source (optional). One of your IP sources: the record follows its address. Held back while the source has none. |
AAAA
IPv6 address.
ipAddress | IPv6 address (optional) |
|---|---|
ipSourceId | IP source (optional). One of your IP sources: the record follows its address. Held back while the source has none. |
CNAME
Alias: this name is another name. Nothing else can use it.
cname | Target |
|---|
MX
Mail server for this name.
priority | Priority |
|---|---|
exchange | Mail server. . with priority 0 means this name receives no mail (null MX). |
NS
Delegates a subdomain to other nameservers.
nameServer | Nameserver |
|---|
PTR
Pointer to a hostname, for reverse DNS.
ptrName | Hostname |
|---|
SRV
Location of a service. The name is _service._protocol.
priority | Priority |
|---|---|
weight | Weight |
port | Port |
target | Target. . means the service is not available. |
TXT
Text, e.g. for SPF, DKIM or domain verification.
text | Text (optional) |
|---|
NAPTR
Rewrite rule, e.g. for ENUM or SIP.
naptrOrder | Order |
|---|---|
naptrPreference | Preference |
naptrFlags | Flags (optional). S (next: SRV), A (next: A/AAAA), U (result: URI), P (protocol specific), or empty to continue with the replacement. |
naptrServices | Services (optional) |
naptrRegexp | Regexp (optional) |
naptrReplacement | Replacement. . when a regexp is used. |
CAA
Which certificate authorities may issue certificates.
caaFlags | Flags: one of 0, 128 |
|---|---|
caaTag | Tag: one of issue, issuewild, iodef, issuemail, issuevmc, contactemail, contactphone |
caaValue | Value (optional) |
DS
DNSSEC for a delegated subdomain: needs NS records at the same name.
dsKeyTag | Key tag |
|---|---|
dsAlgorithm | Algorithm: one of 8, 10, 13, 14, 15, 16 |
dsDigestType | Digest type: one of 2, 4 |
dsDigest | Digest. Spaces and colons are ignored. |
TLSA
DANE: the certificate of a TLS service. The name is _port._protocol.
tlsaUsage | Usage: one of 0, 1, 2, 3 |
|---|---|
tlsaSelector | Selector: one of 0, 1 |
tlsaMatchingType | Matching type: one of 0, 1, 2 |
tlsaData | Certificate data. Spaces and colons are ignored. |
SSHFP
Fingerprint of an SSH host key (ssh-keygen -r prints them).
sshfpAlgorithm | Algorithm: one of 1, 2, 3, 4, 6 |
|---|---|
sshfpType | Fingerprint type: one of 1, 2 |
sshfpFingerprint | Fingerprint. Spaces and colons are ignored. |
SVCB
Service binding: where and how to connect (priority 0 = alias to another name).
svcPriority | Priority |
|---|---|
svcTarget | Target. . means this name itself. |
svcParams | Parameters (optional). key=value pairs separated by spaces: alpn, port, ipv4hint, ipv6hint, ech, mandatory, no-default-alpn, dohpath. |
HTTPS
Service binding: where and how to connect (priority 0 = alias to another name).
svcPriority | Priority |
|---|---|
svcTarget | Target. . means this name itself. |
svcParams | Parameters (optional). key=value pairs separated by spaces: alpn, port, ipv4hint, ipv6hint, ech, mandatory, no-default-alpn, dohpath. |
URI
URI of a service. The name is _service._protocol.
priority | Priority |
|---|---|
weight | Weight |
uri | URI |
OPENPGPKEY
OpenPGP key of an email address (DANE, RFC 7929). The name is computed from the address.
openpgpKey | Public key. The public key as gpg --export --armor you@example.com prints it, or base64. |
|---|
Examples
Certificates from Let’s Encrypt with Certbot
The easiest way is certbot’s dns-rfc2136 plugin with the key from your account page, see Certificates with Certbot. With the API instead, create a token with records:read and records:write, limited to _acme-challenge.* and TXT (the token form fills this in for you), and save these two hooks:
#!/bin/sh
# certbot --manual-auth-hook: adds the challenge as a TXT record.
# ZONE is your domain at dns.lgbt, TOKEN a token with records:write.
set -e
NAME="_acme-challenge"
[ "$CERTBOT_DOMAIN" != "$ZONE" ] && NAME="_acme-challenge.${CERTBOT_DOMAIN%.$ZONE}"
curl -sf -X POST "https://dns.lgbt/api/v1/domains/$ZONE/records" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"name\":\"$NAME\",\"type\":\"TXT\",\"ttl\":60,\"data\":{\"text\":\"$CERTBOT_VALIDATION\"}}"
sleep 30#!/bin/sh
# certbot --manual-cleanup-hook: deletes the challenge again (needs jq).
set -e
NAME="_acme-challenge"
[ "$CERTBOT_DOMAIN" != "$ZONE" ] && NAME="_acme-challenge.${CERTBOT_DOMAIN%.$ZONE}"
for ID in $(curl -sf "https://dns.lgbt/api/v1/domains/$ZONE/records?name=$NAME&type=TXT" \
-H "Authorization: Bearer $TOKEN" |
jq -r --arg v "$CERTBOT_VALIDATION" '.records[] | select(.data.text == $v) | .id'); do
curl -sf -X DELETE "https://dns.lgbt/api/v1/domains/$ZONE/records/$ID" \
-H "Authorization: Bearer $TOKEN"
doneZONE=example.com TOKEN=dnslgbt_pat_… certbot certonly --manual \
--preferred-challenges dns -d example.com -d '*.example.com' \
--manual-auth-hook ./auth.sh --manual-cleanup-hook ./cleanup.shDynamic DNS
The easiest way is an IP source that your records follow (see above). To set one record directly instead, with a token limited to home and A, run this every few minutes:
#!/bin/sh
# Points home.example.com at this machine's public IPv4 address.
IP=$(curl -sf https://api.ipify.org)
curl -sf -X PUT "https://dns.lgbt/api/v1/domains/example.com/rrsets/home/A" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"ttl\":300,\"records\":[{\"data\":{\"ipAddress\":\"$IP\"}}]}"Export and import
curl https://dns.lgbt/api/v1/domains/example.com/zone -H "Authorization: Bearer $TOKEN" > example.com.zonePOST …/zone:import with {"zone": "…", "dryRun": true} shows what an import would add without changing anything.
Over Tor
The API is also at our onion service, at http://lgbtdns5pwlmd3owons7dkabgrge7onmisghx42rjoddinndseks6tid.onion/api/v1, with the same tokens, endpoints and limits. Send the requests through a Tor client, e.g. the one of the tor package, which listens on port 9050:
ONION=http://lgbtdns5pwlmd3owons7dkabgrge7onmisghx42rjoddinndseks6tid.onion
curl --socks5-hostname 127.0.0.1:9050 $ONION/api/v1/me \
-H "Authorization: Bearer dnslgbt_pat_…"
# or
torsocks curl $ONION/api/v1/me -H "Authorization: Bearer dnslgbt_pat_…"--socks5-hostname lets Tor look up the address; plain --socks5 fails for onion addresses. The address is http on purpose: Tor encrypts the connection all the way to us, and the address itself proves you reached dns.lgbt.
How to check the address, sign up and use the app over Tor: dns.lgbt over Tor.
