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:

  1. bulk_preview with {"tld": ["com"], …} → 812 matched → submit, approve, keep the batch_id.
  2. bulk_preview with {"tld": ["de"], …} → 392 matched → submit, approve, keep the second batch_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.