Bulk operations with Jobs
A change across many domains never becomes many tool calls. You supply a selector and one shared template; the server resolves the matching domains and submits a single Jobs batch, where the loop, the retries and the error handling all run server-side.
Three consequences worth knowing:
- The model never transcribes a domain name, so it cannot mistype one.
- One approval covers the whole batch, bound to the exact resolved set.
- Progress and retries live in the Jobs service, so a dropped conversation does not orphan a half-finished change.
Template types
| Family | templateType |
Instances addressed by |
|---|---|---|
| Domains | domain_update_bulk |
domain_id |
| DNS zones | dns_zone_create_bulk, dns_zone_update_bulk |
name |
| DNS RRsets | dns_zone_patch_rrsets_bulk |
zone_name |
| Domain (URL) forwards | domain_forward_create_bulk, domain_forward_update_bulk, domain_forward_enable_bulk, domain_forward_disable_bulk, domain_forward_delete_bulk |
hostname |
| Email forwards | email_forward_create_bulk, email_forward_update_bulk, email_forward_enable_bulk, email_forward_disable_bulk, email_forward_delete_bulk |
hostname |
The always-current list, including the Jobs commands that are deliberately not
reachable this way and why, is on Bulk
templates. A worked template body for each
family is on Recipes.
Choosing a template body
The template is the shared mutation, in the shape the Jobs command expects —
renewal_mode, nameservers, status_changes or contacts for a domain
update; rrsets and dnssec_status for a zone; http/https redirects for a
URL forward; aliases for an email forward.
The enable, disable and delete commands carry no template of
their own — there is nothing to configure, only a set to act on. The tool still
requires the argument, so pass an empty object:
"template": {}.
dns_zone_patch_rrsets_bulk is the other shape: its command has no shared
template either, because the RRset operations belong to each zone. You still
pass one template — put the ops in it — and the server writes them into
every resolved instance alongside its zone_name. bulk_preview shows the
result under sampleInstances.
The selector
The selector is the GET /v1/domains filter set — the same
object portfolio_query uses.
Resolution pages the domain list server-side and sorts the result, so the same
selector always produces the same batch and therefore the same approval.
An empty selector targets every domain in your account. The approval prompt says so in as many words — "matching the entire portfolio (no filters)". Read the prompt, not the intent.
hostnamePrefix, for forward templates
Forward instances are addressed by hostname, built as hostnamePrefix plus the
resolved domain. Pass "*." to target wildcard subdomain forwards
(*.acme-labs.com), or leave it out for the apex (acme-labs.com).
The two are separate forwards, so covering both means running the operation
twice. For a selector resolving to acme-labs.com and acme-shop.com:
hostnamePrefix |
Resolved instances |
|---|---|
"" (default) |
{"hostname": "acme-labs.com"}, {"hostname": "acme-shop.com"} |
"*." |
{"hostname": "*.acme-labs.com"}, {"hostname": "*.acme-shop.com"} |
bulk_preview shows exactly this under sampleInstances, which is the cheapest
way to be sure you are about to change the one you meant.
Supplying it for a template that is not hostname-addressed is rejected:
hostnamePrefix is only valid for hostname-addressed templates (*_forward_*_bulk); the "domain_update_bulk" template keys on "domain_id"
Step 1 — preview
bulk_preview with:
{
"templateType": "domain_update_bulk",
"template": { "status_changes": { "add": ["clientTransferProhibited"] } },
"selector": { "tld": ["com"], "expires_in_30_days": true },
"sampleSize": 3
}
returns:
{
"status": "dry_run",
"templateType": "domain_update_bulk",
"command": "domain_update_bulk",
"matchedDomains": 412,
"template": { "status_changes": { "add": ["clientTransferProhibited"] } },
"sampleDomains": [
{ "name": "acme-labs.com", "domain_id": "domain_01h45ytscbebyvny4gc8cr8ma2" },
{ "name": "acme-labs.net", "domain_id": "domain_01h45yv2m8fbcwnz9pq3kr7txe" },
{ "name": "acme-shop.com", "domain_id": "domain_01h45yw7q2gdexpa4bt6mn8zvc" }
],
"sampleInstances": [
{ "domain_id": "domain_01h45ytscbebyvny4gc8cr8ma2" },
{ "domain_id": "domain_01h45yv2m8fbcwnz9pq3kr7txe" },
{ "domain_id": "domain_01h45yw7q2gdexpa4bt6mn8zvc" }
],
"note": "Nothing was submitted. Call bulk_submit with the same templateType, template and selector to execute. You will be asked to approve the action first."
}
matchedDomains is the full count; sampleDomains and sampleInstances are
only the first few, sampleSize of them (10 by default).
bulk_preview submits nothing and needs no approval. Always preview
first — the preview is what tells you the selector matched 412 domains rather
than 4,127.
Step 2 — submit
bulk_submit takes the same arguments, plus an optional label. The first call
returns an approval challenge whose prompt names the command and the resolved
set:
Approve: domain_update_bulk on 412 domain(s) matching expires_in_30_days=true, tld=["com"] (POST /v1/jobs)? Risk: writes data.
After you approve:
{
"status": "ok",
"operationId": "create_batch_v1_jobs_post",
"httpStatus": 201,
"data": {
"batch_id": "batch_01k3n0m5xrf9pab6t2wqzhkvr3",
"total_commands": 1,
"jobs_created": 412,
"jobs_duplicated": 0,
"jobs_failed": 0,
"status_url": "/v1/jobs/batch_01k3n0m5xrf9pab6t2wqzhkvr3"
},
"truncated": false
}
total_commands is 1: a batch built from a selector is one bulk command
carrying every instance. jobs_created is the per-domain count.
What the server posts
{
"label": "Lock .com renewals expiring in 30 days",
"commands": [
{
"command": "domain_update_bulk",
"payload": {
"template": { "status_changes": { "add": ["clientTransferProhibited"] } },
"instances": [
{ "domain_id": "domain_01h45ytscbebyvny4gc8cr8ma2" },
{ "domain_id": "domain_01h45yv2m8fbcwnz9pq3kr7txe" }
]
}
}
]
}
Limits
A batch is one command, and the Jobs API allows 1,000 instances per command, so a selector may resolve to at most 1,000 domains. To change more, narrow the selector and submit more than one batch — one TLD at a time, for example.
The errors you will meet:
| Message | Meaning |
|---|---|
selector matched no domains; nothing to submit… |
Filters too narrow, or tag_ids was given a label instead of an ID |
selector matched 1204 domains, exceeding the limit of 1000… |
Narrow the selector and submit in parts |
selector matched more than the limit of 1000 domains; narrow the selector… |
Same, detected while paging |
Changing more than 1,000 domains
Split along a filter that partitions cleanly. One TLD at a time is the least surprising, because each half is easy to describe out loud and easy to check in the preview:
-
bulk_previewwith{"tld": ["com"], …}→ 812 matched → submit, approve, keep thebatch_id. -
bulk_previewwith{"tld": ["de"], …}→ 392 matched → submit, approve, keep the secondbatch_id.
Each half is its own approval and its own batch. Keep both ids: that is how you check and steer them afterwards.
Step 3 — watch it
job_batch_status with { "batchId": "batch_01k3n0m5xrf9pab6t2wqzhkvr3" }
returns the batch's progress percentage and its job counts by state — queued,
running, succeeded, failed, dead_letter and the rest. The lifecycle those
states belong to is documented in Jobs
overview and Managing
batches.
Step 4 — steer it
job_batch_control takes an action:
action |
Effect | Approval |
|---|---|---|
pause |
Pause the eligible jobs | Yes |
resume |
Resume a paused batch | Yes |
retry |
Retry failed jobs, optionally only one errorClass |
Yes |
cancel |
Cancel the jobs still queued | Yes |
errorClass is what makes retry useful: retry only the jobs that failed for
one reason — say insufficient funds, after topping up — and leave failures with a
different cause alone. It is a list, and omitting it retries every failed job
in the batch:
{
"batchId": "batch_01k3n0m5xrf9pab6t2wqzhkvr3",
"action": "retry",
"errorClass": ["BillingInsufficientFundsError"]
}
{
"status": "ok",
"operationId": "retry_batch_v1_jobs__batch_id__retry_post",
"httpStatus": 200,
"data": {
"batch_id": "batch_01k3n0m5xrf9pab6t2wqzhkvr3",
"retried_count": 5,
"queued_count": 5,
"blocked_count": 0
},
"truncated": false
}
See Managing batches.
The approval prompt for a pause reads:
Approve: pause job batch batch_01k3n0m5xrf9pab6t2wqzhkvr3 (POST /v1/jobs/{batch_id}/pause)? Risk: writes data.
Pause, resume and cancel answer 204 No Content, so the tool result
carries "httpStatus": 204 and an empty data. That is
success, not a truncated response. Retry is the exception: it answers
200 with batch_id, retried_count,
queued_count and blocked_count, so you can see how many
jobs it actually picked up.
A batch outlives the conversation. It runs inside OpusDNS Jobs, so closing your
client neither pauses nor cancels it — reconnect later and ask for its status by
batch_id.
Related
- Recipes — a worked template body per family
- Bulk templates
- Jobs overview, Domain commands, Managing batches
- User tags — resolving a tag label to its ID
- Approvals and confirmations

