Skip to content

Change DNS records on many domains at once

5 steps · Checked against the API code 3 November 2026

A guide does one task from start to finish. Every endpoint, with all its fields and cases, is in the API reference.

One call adds or deletes the same DNS records in many zones. The API answers with one job, which works through the domains one at a time. When it ends, its result says what happened on each domain.

  • The requests use your key from $HOSTUP_API_KEY (Quickstart). The key needs the scope write:dns. It covers read:dns for the lists of zones and records. Keys and scopes
  • Domains registered with Hostup in this account, each with its DNS zone at Hostup. A domain registered elsewhere that only uses Hostup’s DNS is refused. Change its records one at a time, as in Point a domain at your server.
  • jq for the commands that filter the answers.

The bulk call takes domain names, not IDs. List your DNS zones to see which names it accepts.

GET /api/v2/dns-zones
curl "https://cloud.hostup.se/api/v2/dns-zones" \
-H "Authorization: Bearer $HOSTUP_API_KEY"
Response 200
{
"data": [
{
"id": "zone_01hxa3b4c5d6e7f8g9h0j1k2m3",
"name": "example.com",
"kind": "linked",
"…": "…"
},
{
"id": "zone_01hxa3b4c5d6e7f8g9h0j1k2m4",
"name": "example.net",
"kind": "dns_only",
"…": "…"
},
{
"id": "zone_01hxa3b4c5d6e7f8g9h0j1k2m5",
"name": "example.nu",
"kind": "linked",
"…": "…"
},
{
"id": "zone_01hxa3b4c5d6e7f8g9h0j1k2m6",
"name": "example.se",
"kind": "linked",
"…": "…"
}
],
"hasMore": false,
"nextCursor": null
}

Use the zones whose kind is linked. The bulk call refuses dns_only zones. Keep the list: step 5 uses the zone IDs.

Up to 1000 zones come back at a time. When hasMore is true, ask again with ?cursor= and the nextCursor value. Pagination

The names as a JSON list, ready for the request body:

GET /api/v2/dns-zones
curl -s "https://cloud.hostup.se/api/v2/dns-zones" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
| jq -c '[.data[] | select(.kind == "linked") | .name]'

add never replaces a record. A domain that already has a DMARC record gets a second one. The job still reports success. With two _dmarc records, receivers apply no DMARC policy at all. An SPF record and a CNAME also belong once per name.

Check which zones already have a record at the name first. This prints each linked zone that has a record at _dmarc, with its values:

GET /api/v2/dns-zones
curl -s "https://cloud.hostup.se/api/v2/dns-zones" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
| jq -r '.data[] | select(.kind == "linked") | .id' \
| while read -r ID; do
curl -s "https://cloud.hostup.se/api/v2/dns-zones/$ID/records?type=TXT&name=_dmarc" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
| jq -c 'select(.records | length > 0)
| {zone: .zone.name, values: [.records[].value]}'
done

For SPF, ask for type=TXT&name=@ and look for values that start with v=spf1. For a CNAME, ask for the name without a type, such as name=www.

Leave the printed domains out of the add. Change their record one zone at a time with Update a DNS record.

Send one of the two requests below. Every domain in domainNames gets the same change.

The whole batch is checked before anything is queued. If one domain is not in the account or one record does not validate, the call is refused as a whole. Nothing changes then.

Here three domains get a DMARC record. The check in step 1 printed none of them.

POST /api/v2/domains/bulk/dns
curl -X POST "https://cloud.hostup.se/api/v2/domains/bulk/dns" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "add",
"domainNames": ["example.com", "example.se", "example.nu"],
"records": [
{"type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=none"}
]
}'
Response 202
{
"operation": {
"status": "pending",
"jobId": "dbj_01hxa3b4c5d6e7f8g9h0j1k2m3",
"pollUrl": "/api/jobs/dbj_01hxa3b4c5d6e7f8g9h0j1k2m3"
},
"action": "add",
"domainsQueued": 3
}
export JOB_ID="dbj_…" # operation.jobId above

domainsQueued is the number of domains in the job.

Two things differ from creating a single record:

  • type is in capitals, such as TXT or MX. Lower case is refused.
  • name is the short name inside each zone, such as _dmarc or www. @ is the domain itself. The same name is used in every zone. Never send a full name: www.example.com becomes www.example.com.example.se in the zone example.se.

Change DNS records in several zones describes the other record fields.

Here three domains lose every record that points at a server you no longer use. Send "action": "delete" with "deleteType": "by_value" and the old address in recordValue:

POST /api/v2/domains/bulk/dns
curl -X POST "https://cloud.hostup.se/api/v2/domains/bulk/dns" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "delete",
"domainNames": ["example.com", "example.se", "example.nu"],
"deleteType": "by_value",
"recordValue": "198.51.100.7"
}'
Response 202
{
"operation": {
"status": "pending",
"jobId": "dbj_01hxa3b4c5d6e7f8g9h0j1k2m4",
"pollUrl": "/api/jobs/dbj_01hxa3b4c5d6e7f8g9h0j1k2m4"
},
"action": "delete",
"domainsQueued": 3
}
export JOB_ID="dbj_…" # operation.jobId above

To point the domains at a new address instead, add the new records first. Move records to a new address shows the order.

deleteType decides which records are deleted. Send only the field that goes with it:

  • by_value with recordValue: every record whose value is exactly that, whatever its name and type. It suits A and AAAA records, whose value is the address.
  • by_type with recordType, in capitals: every record of that type. Add "recordName": "@" to limit it to the domain itself.
  • by_name with "recordName": "@": every record at the domain itself, mail and website records included.
  • all: every record in each zone, mail and website records included.

Across many domains, only "recordName": "@" works. A short name such as www matches nothing. A full name matches one zone only.

Poll the job until data.status is completed or failed. One poll looks like this:

GET /api/jobs/{jobId}
curl "https://cloud.hostup.se/api/jobs/$JOB_ID" \
-H "Authorization: Bearer $HOSTUP_API_KEY"
Response 200
{
"data": {
"jobId": "dbj_01hxa3b4c5d6e7f8g9h0j1k2m3",
"type": "bulk_dns_add",
"status": "running",
"progressDetails": {
"current": 2,
"total": 3,
"message": "Adding DNS records to example.se"
},
"…": "…"
},
"…": "…"
}

progressDetails.current and total count the domains. data.type is bulk_dns_add, bulk_dns_update or bulk_dns_delete.

To wait until it ends, run the polling script from Jobs. It prints data.result when the job has finished.

./wait-for-job.sh "/api/jobs/$JOB_ID"

Once data.status is completed, the same request returns the result.

GET /api/jobs/{jobId}
curl "https://cloud.hostup.se/api/jobs/$JOB_ID" \
-H "Authorization: Bearer $HOSTUP_API_KEY"
Response 200
{
"data": {
"jobId": "dbj_01hxa3b4c5d6e7f8g9h0j1k2m3",
"type": "bulk_dns_add",
"status": "completed",
"progress": 100,
"result": {
"message": "Bulk DNS add completed",
"total": 3,
"success": 2,
"failed": 1,
"results": [
{
"domainName": "example.com",
"success": true,
"recordsAdded": 1,
"totalRecords": 1,
"records": [{ "name": "_dmarc", "type": "TXT", "success": true }]
},
{
"domainName": "example.se",
"success": true,
"recordsAdded": 1,
"totalRecords": 1,
"records": [{ "name": "_dmarc", "type": "TXT", "success": true }]
},
{
"domainName": "example.nu",
"success": false,
"recordsAdded": 0,
"totalRecords": 1,
"records": [
{
"name": "_dmarc",
"type": "TXT",
"success": false,
"error": "Record already exists or could not be added"
}
]
}
]
},
"error": null,
"…": "…"
},
"…": "…"
}

completed means the job went through the whole list. It does not mean every domain worked. data.result.success and data.result.failed count the domains. data.result.results has one entry per domain.

What each entry holds depends on the action:

ActionFields per domainThe domain counts as failed when
addrecordsAdded, totalRecords, recordsany of its records failed
deleterecordsDeleted, totalRecords (the records that matched)nothing was deleted. "totalRecords": 0 means nothing matched. Nothing is wrong with that zone.

A delete where recordsDeleted is lower than totalRecords left some matches in place. That entry still has "success": true.

A failed record has "success": false and the reason in error. A domain the job could not work on at all has only domainName, "success": false and error. One cause is a zone that was removed after the call.

The domains to look at, with the records that failed on each:

GET /api/jobs/{jobId}
curl -s "https://cloud.hostup.se/api/jobs/$JOB_ID" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
| jq '.data.result.results[]
| select(.success == false
or (.recordsDeleted != null
and .recordsDeleted < .totalRecords))
| {domainName, error,
failed: [.records[]? | select(.success == false)]}'

The error Record already exists or could not be added is also given when the write returned no reason. The record may be in the zone already. Check the zone in step 5 before you send anything again.

The job stopped as a whole. data.result is null and data.error says Job failed. Some zones may have changed before it stopped. Look at their records before you send the change again.

Look at the zone of a domain that failed. Here it is example.nu. When every domain worked, check any one zone the same way.

export ZONE_ID="zone_…" # the domain's zone ID from step 1

Ask for the records by type and name.

GET /api/v2/dns-zones/{id}/records
curl "https://cloud.hostup.se/api/v2/dns-zones/$ZONE_ID/records?type=TXT&name=_dmarc" \
-H "Authorization: Bearer $HOSTUP_API_KEY"
Response 200
{
"zone": {
"id": "zone_01hxa3b4c5d6e7f8g9h0j1k2m5",
"name": "example.nu",
"status": "active",
"recordCount": 1,
"…": "…"
},
"records": [
{
"id": "drr_01hxa3b4c5d6e7f8g9h0j1k2m3",
"type": "TXT",
"name": "_dmarc.example.nu",
"value": "v=DMARC1; p=none",
"ttl": 3600
}
]
}

Names come back in full. One record with the value you sent means the record is in place. That domain needs nothing more, even though step 4 listed it as failed.

Send a new request with only the domains whose zone lacks the record. For add, send only the records that failed on them. The records that went through are in place already. Follow the new job as in step 3.

Two records with different values mean an older record was there already. Delete the one you don’t want with Delete a DNS record. Use its id from this list.

Here the domains point at 198.51.100.7 and should point at 192.0.2.10. Add the records with the new address first. Wait until that job has finished. Then delete the old records on the domains where the add worked.

Bulk jobs can run at the same time. A delete sent before the add has finished can remove the old address from a domain that does not have the new one yet. That domain is then left without an address.

1. Find the names that use the old address

Section titled “1. Find the names that use the old address”

This prints each linked zone with the names whose value is the old address:

GET /api/v2/dns-zones
curl -s "https://cloud.hostup.se/api/v2/dns-zones" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
| jq -r '.data[] | select(.kind == "linked") | .id' \
| while read -r ID; do
curl -s "https://cloud.hostup.se/api/v2/dns-zones/$ID/records" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
| jq -c '{zone: .zone.name,
names: [.records[] | select(.value == "198.51.100.7") | .name]}'
done

The delete in step 4 removes all of these names. Here example.com prints example.com and www.example.com. In the add, they are @ and www.

One add gives every domain the same names. Put the domains that use the same names in one request.

POST /api/v2/domains/bulk/dns
curl -X POST "https://cloud.hostup.se/api/v2/domains/bulk/dns" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "add",
"domainNames": ["example.com", "example.se", "example.nu"],
"records": [
{"type": "A", "name": "@", "value": "192.0.2.10"},
{"type": "A", "name": "www", "value": "192.0.2.10"}
]
}'
Response 202
{
"operation": {
"status": "pending",
"jobId": "dbj_01hxa3b4c5d6e7f8g9h0j1k2m5",
"pollUrl": "/api/jobs/dbj_01hxa3b4c5d6e7f8g9h0j1k2m5"
},
"action": "add",
"domainsQueued": 3
}
export JOB_ID="dbj_…" # operation.jobId above

add leaves the records with the old address in place.

Wait for the job as in Follow the job:

./wait-for-job.sh "/api/jobs/$JOB_ID"

Then list the domains where the add worked:

GET /api/jobs/{jobId}
curl -s "https://cloud.hostup.se/api/jobs/$JOB_ID" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
| jq -c '[.data.result.results[]
| select(.success == true) | .domainName]'

Here it prints ["example.com","example.se"]. The add failed on example.nu. Read what happened on each domain shows how to find out why. A domain whose add failed keeps its old records. Leave it out of the delete until its new records are in place.

Send the delete with the domains from step 3 and the old address in recordValue. This delete cannot be undone through the API either.

POST /api/v2/domains/bulk/dns
curl -X POST "https://cloud.hostup.se/api/v2/domains/bulk/dns" \
-H "Authorization: Bearer $HOSTUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "delete",
"domainNames": ["example.com", "example.se"],
"deleteType": "by_value",
"recordValue": "198.51.100.7"
}'
Response 202
{
"operation": {
"status": "pending",
"jobId": "dbj_01hxa3b4c5d6e7f8g9h0j1k2m6",
"pollUrl": "/api/jobs/dbj_01hxa3b4c5d6e7f8g9h0j1k2m6"
},
"action": "delete",
"domainsQueued": 2
}
export JOB_ID="dbj_…" # operation.jobId above

Follow this job too. Each entry’s recordsDeleted is the number of old records removed on that domain.

The body also takes "action": "update". Don’t use it to replace records. In each zone it looks for a record of the same type whose stored name equals name. Zones store full names. A short name such as _dmarc or @ finds nothing there. The record is then added next to the old one. update also reports a record as written without checking that the write went through.

To replace an A or AAAA record on many domains, add the new one and delete the old one by its value, as in Move records to a new address. Replace other records zone by zone: find the record’s id as in step 5. Then send the new record to Update a DNS record.

A domain’s records change when the job reaches it. Resolvers that looked a name up before keep the old answer until its TTL runs out.

Nothing is billed and no e-mail goes out. The snapshot taken before the job is listed at List DNS snapshots, as the caution in step 2 says.

A finished job is kept for a limited time. Read the result while you poll. Jobs

Next: Change a domain’s nameservers, for a domain whose records should answer from Hostup’s nameservers.

  • 403 forbidden with errors[]: a name in domainNames is not a domain in this account. Each entry points at one, for example /domainNames/2. A dom_ ID gets the same answer: send names.
  • 403 forbidden with the detail Access denied. and no list: a domain is registered elsewhere. The same detail comes when the key is tied to certain DNS zones or records:
    • Every zone in the batch must be on the key’s list.
    • A key limited to certain records can add and update only those records.
    • A bulk delete needs a grant for each whole zone.
  • 403 forbidden with requiredScopes: the key lacks write:dns. When a key is refused
  • 400 invalid_request with errors[0].pointer: the body does not fit the action. It has a field the action does not take, an empty list or a delete without its filter field. The pointer names the field.
  • 400 invalid_request with the detail The bulk DNS operation request is invalid. and no pointer: a record did not validate or a domain has no DNS zone at Hostup. Check that each record type is in capitals. Then check the TTL, the priority on MX and SRV records, the value format and the zones from step 1.
  • 500 upstream_failed: the job could not be queued. Nothing changed. Send the same request again a little later.

Nothing is queued after a 403 or a 400.