hostinger-dns-api
Hostinger's Developer API (developers.hostinger.com) exposes domain portfolio + DNS zone management with a Bearer token. The shape of the API has two non-obvious gotchas — the GET response shape does NOT match the PUT request shape, and CNAME values must include a trailing dot — that cost a full diagnostic loop the first time you hit them.
When to use
- You need to add/change/remove a DNS record on a Hostinger-registered domain
- The user has provided a Hostinger API token, OR you've extracted one from Bitwarden
- A domain's RDAP lookup shows registrar = "HOSTINGER operations, UAB" (NOT Namecheap, NOT Cloudflare)
- Custom-domain provisioning on Railway/Vercel/Netlify needs a CNAME update at the registrar
- You want to avoid the hpanel.hostinger.com web UI for any reason (automation, agent loop, scripting)
Sanity check first — confirm registrar
Don't assume. If dig +short NS domain.tld returns ns1.dns-parking.com / ns2.dns-parking.com, the domain is parked on Hostinger DNS but the registrar still might not be Hostinger. Verify via RDAP:
curl -sS -H "Accept: application/json" "https://rdap.centralnic.com/xyz/domain/example.xyz" \
| jq -r '.entities[] | select(.roles[]=="registrar") | .vcardArray[1][] | select(.[0]=="fn") | .[3]'
For .com / .net / .org / etc., swap the rdap host. The IANA bootstrap registry at https://data.iana.org/rdap/dns.json lists the right RDAP endpoint per TLD. Only proceed with this skill if the registrar is Hostinger.
API authentication
Token comes from hpanel → Account → API or Bitwarden. Use Authorization: Bearer <token> for every request. The token gives access to ALL domains in the user's portfolio, not a single one — handle accordingly.
TOK='Hostinger-API-Token'
# List domains visible to this token
curl -sS -H "Authorization: Bearer $TOK" -H "Accept: application/json" \
"https://developers.hostinger.com/api/domains/v1/portfolio" \
| jq -r '.[] | "\(.domain)\t\(.status)\t\(.type)"'
Read DNS zone
curl -sS -H "Authorization: Bearer $TOK" -H "Accept: application/json" \
"https://developers.hostinger.com/api/dns/v1/zones/agentpact.xyz" \
| jq .
Returns a bare JSON array of records, each shaped:
{
"name": "www",
"type": "CNAME",
"ttl": 300,
"records": [
{ "content": "agentpactweb-production.up.railway.app.", "is_disabled": false }
]
}
Key fields:
nameis the host label (@for apex,www,mcp, etc.) — NOT a fully qualified nametypeis the DNS record type (A, AAAA, CNAME, ALIAS, MX, TXT, NS, etc.) — Hostinger supportsALIASat apex (CNAME-flattening)recordsis a list of values; CNAME has one entry, MX/TXT can have severalcontentfor CNAME/ALIAS MUST end with a literal dot (...up.railway.app.) — without it, the API may accept the write but DNS resolution will misbehaveis_disabled: false— set totrueto soft-disable a record without deleting
Write DNS zone — the gotcha
PUT shape is NOT the same as GET shape. PUT requires the records array wrapped under a zone key:
# Pull current zone
curl -sS -H "Authorization: Bearer $TOK" -H "Accept: application/json" \
"https://developers.hostinger.com/api/dns/v1/zones/agentpact.xyz" > /tmp/zone.json
# Edit /tmp/zone.json in Python — find the record, change record content, write the modified zone
python3 - <<'PY'
import json
zone = json.load(open('/tmp/zone.json'))
for entry in zone:
if entry['name'] == 'www' and entry['type'] == 'CNAME':
entry['records'] = [{'content': 'ypb6kcbs.up.railway.app.', 'is_disabled': False}]
entry['ttl'] = 300
# PUT requires {"zone": [...]} wrapper, NOT a bare array
json.dump({"zone": zone}, open('/tmp/zone-payload.json', 'w'))
PY
# Send PUT
curl -sS -X PUT -H "Authorization: Bearer $TOK" \
-H "Content-Type: application/json" -H "Accept: application/json" \
--data @/tmp/zone-payload.json \
"https://developers.hostinger.com/api/dns/v1/zones/agentpact.xyz"
# Expected: {"message":"Request accepted"}
If you forget the wrapper and PUT a bare array, the response is {"message":"The zone field is required.","errors":{"zone":["The zone field is required."]}} — that's the giveaway.
Verify the change took
# Re-pull and confirm
curl -sS -H "Authorization: Bearer $TOK" -H "Accept: application/json" \
"https://developers.hostinger.com/api/dns/v1/zones/agentpact.xyz" \
| jq -r '.[] | "\(.type)\t\(.name)\t\(.records[0].content)"'
Then verify external DNS propagation against a public resolver (TTL-respecting):
dig +short www.agentpact.xyz @1.1.1.1
dig +short www.agentpact.xyz @8.8.8.8
Hostinger publishes within seconds. Resolver propagation depends on TTL — keep TTL low (300s) when planning further iteration.
Pitfalls
GET ≠ PUT shape. GET returns
[<record>, ...]. PUT requires{"zone": [<record>, ...]}. Always wrap.Trailing dot required on CNAME/ALIAS values.
2x68toyo.up.railway.app(no dot) and2x68toyo.up.railway.app.(with dot) are different to Hostinger's parser. The.form always works; the bare form sometimes doesn't, and the failure mode is silent — the record looks set in the API but DNS resolution acts as if it's missing.@is the apex. Usename: "@"for the root domain, notagentpact.xyzand not empty string.PUT replaces the entire zone, not just one record. Always pull, mutate, push back. If you push only the changed record, every other record disappears. (Discovered the hard way is a permanent education — don't.)
is_disabledis a soft-delete, not a hard remove. To remove a record permanently, drop the entry from the zone array entirely before PUT.ALIAS records survive at apex. Hostinger supports
ALIAS(CNAME-flattening) on@, which most other registrars don't. If you have anALIAS @ -> something.up.railway.app.already configured, leave it — CNAMEs at apex would violate RFC 1034 and Hostinger's API will reject them. UseALIASfor apex,CNAMEfor sub-labels.Mass changes need rate-limit awareness. The API is generous but not unlimited. If iterating across many domains, sleep 1-2s between PUTs and respect any 429 responses.
Companion skills
railway-serverless-diagnose— when the reason for a Hostinger DNS edit is a Railway custom-domain CNAME target change. Read pitfall #4 there: delete+recreate on Railway issues a NEW per-domain CNAME each time, requiring a fresh DNS update.cloudflare-tunnel-wiring— when the domain WAS at Cloudflare and you're moving it OFF, or when comparing CF DNS workflow to Hostinger.bitwarden-key-extraction— when the Hostinger token is in BW under a confusingly-named item. As of 2026-04-24, no dedicated Hostinger BW item existed for Adam — the token was passed in-chat. Recommend creating one.
Verification — definition of done
- Verified registrar is actually Hostinger (RDAP, not just nameservers)
- Pulled current zone, confirmed expected records exist with expected values
- PUT-ed the modified zone with
{"zone": [...]}wrapper and trailing-dot CNAME content - Got
{"message":"Request accepted"}response - Re-pulled and saw the change
- Confirmed
dig +shortagainst a public resolver returns the new value (allow up to 5 min for propagation, but typically 30-60s)