Skip to main content

No matching sections.

Jobs & idempotency

Bulk operations run asynchronously and answer 202 with a job to poll. Destructive bulk operations require a preview first. And a handful of endpoints refuse to run at all without an Idempotency-Key, because a retry there costs real money.

The 202 + job contract

Seven endpoints hand work to a background runner instead of doing it inline. Every one of them requires an Idempotency-Key - a door that works AFTER it answers cannot tell you whether a timed-out request landed:

EndpointWhat it does
POST /monitor/bulkCreate many monitors.
POST /monitor/bulk-updatePatch, or reset the statistics of, many monitors - by ids or by a filter.
POST /monitor/bulk-deleteDelete a filter-matched set (phase two - see below).
POST /monitor/{monitorId}/reset-statsDiscard one monitor's accumulated statistics.
POST /contact/bulkCreate, update and delete many contacts.
POST /contact/bulk-deleteDelete a filter-matched set of contacts.
POST /monitor/reportGenerate a report over a set of monitors and a time range.

Two more operations answer 202 against a job that already exists - POST /job/{id}/cancel and POST /job/{id}/resume - and each has a validate-only twin that answers synchronously and writes nothing (…/bulk-validate, …/bulk-update-validate, …/bulk-delete-validate).

The acceptance is small and tells you two things - the job id, and how many items were taken:

202 Accepted  ·  Location: /job/be65c73e-…  ·  Retry-After: 3
{ "jobId": "be65c73e-4e1a-49d7-8ef9-ba9b11c8681c", "accepted": 2 }

Every 202 carries a Location header pointing at the job - follow it rather than assembling the URL yourself - and a Retry-After in seconds, sized on how much work was accepted. That is how long to wait before the FIRST poll, so a client does not have to invent a cadence.

Keep the job id anyway: GET /job lists this account's recent jobs, newest first, filterable by kind and state, and a token sees only the jobs whose scope it holds - so a lost id is recoverable, but reading a list is slower than remembering one.

Polling a job

request
curl 'https://api2.host-tracker.com/job/be65c73e-4e1a-49d7-8ef9-ba9b11c8681c' \
  -H 'Authorization: Bearer YOUR_TOKEN'
200 OK  ·  a real job, mid-flight
{
  "id": "be65c73e-4e1a-49d7-8ef9-ba9b11c8681c",
  "kind": "monitor.bulkCreate",
  "scope": "monitor:write",
  "state": "running",
  "progress": { "done": 1, "total": 2 },
  "summary": { "created": 1, "updated": 0, "skipped": 0, "failed": 0, "deleted": 0 },
  "cancelRequested": false,
  "created": 1785712640,
  "startedAt": 1785712653,
  "expiresAt": 1786317440,
  "results": [
    {
      "index": 0,
      "itemRef": "https://bulk-a.example.com",
      "status": "created",
      "entityId": "22fa345f-6b06-4686-a83b-71543d3fe52c",
      "result": { /* the full monitor representation */ },
      "processedAt": 1785712654
    },
    { "index": 1, "itemRef": "https://bulk-b.example.com", "status": "pending" }
  ],
  "nextCursor": null,
  "hasMore": false
}
  • The poll is always 200, whatever the job's outcome. A failed job is a 200 whose state says failed; the only non-200 answers are refusals of the poll itself (404 unknown/expired, 403 wrong scope, 422 bad paging).
  • results[] is paginated inside the job for jobs over 500 items, with the same nextCursor/hasMore pair as any collection.
  • expiresAt is when the job stops being readable. Jobs are kept for 7 days, then answer 404. Read what you need before then.
  • error appears only if the job itself faulted - a whole problem document, not a message. Individual item failures never populate it.
  • Every non-terminal poll carries a fresh Retry-After, in seconds, sized on what is LEFT of the job. A terminal poll carries none - the absence of that header is the poll loop's exit condition, and it needs no body parsing to detect.

State vocabulary

A job's place in its lifecycle is state - the same word a monitor, a check result, an incident, a maintenance window and an instant check use. The per-item receipts below keep status, because "what did the job do with this row" is a different question. (The envelope was spelled status until 2026-08-17; there is no alias - the old member is simply absent.)

StateTerminal?Meaning
queuednoAccepted, not started.
runningnoIn progress. progress.done is advancing.
succeededyesEvery item succeeded. An empty job succeeds.
partialyesSome items succeeded, some failed.
failedyesEvery item failed.
cancelledyesCancellation was accepted and took effect.
interruptednoThe server running it stopped. Everything applied stands; continue it with POST /job/{id}/resume.
partial is a success, not an error. 290 monitors created and 10 rejected is a job that did what it could and reported per-row detail - not a 500 to retry wholesale. Branch on state and then walk results[] for the failures; retry only those items. Treating partial as failure and resubmitting the whole batch is the mistake this vocabulary exists to prevent.

Per-item results

MemberMeaning
indexThe position in the items[] array you sent - how you correlate a result back to your input. Same spelling here and in the pushed job.completed payload.
itemRefA human-recognisable handle for the item (for a monitor, its URL). Convenience, not an identifier.
statuspending · created · updated · skipped · deleted · failed · cancelled · createdDisabled. Deliberately status, not state: this says what the job did with the row, which is not a lifecycle.
entityIdThe affected resource's id, once there is one.
resultThe resulting representation, rendered fresh at poll time.
errorFor a failed item: a full problem document, the same shape a synchronous failure would have had. See Errors.
processedAtWhen the item was handled, Unix seconds.

Because each failed item carries a complete problem document, the error-handling you already wrote for synchronous calls works unchanged on a job's results - the same code, the same errors[].pointer, the same remediation members.

Callbacks: don't poll if you don't want to

Any job-creating request may name a webhook to notify when the job reaches a terminal state:

request body fragment
{
  "items": [ /* … */ ],
  "callback": { "webhookId": "0c3c7b07-cecb-43dd-9b76-8516d3b9c771" }
}
  • The webhook must be yours and enabled. It is validated when the job is submitted, so a bad id answers 422 validation_failed immediately rather than producing a job whose callback silently never fires.
  • It delivers the job.completed event, carrying the terminal job document and its first page of results. That type is callback-only - it cannot be subscribed to by putting it in a webhook's events array, and a webhook does not need to list it to receive one.
  • Add "on": "progress" to also receive job.progress - a throttled interim report carrying the job's counts and no per-item results. It is callback-only in the same way.
  • A callback is a convenience, not a guarantee: keep polling as your fallback. The job itself always completes and GET /job/{id} always answers.

Cancelling and resuming

POST /job/{id}/cancel requests cancellation and answers 202 with the job's current representation, cancelRequested: true and a Retry-After. Cancelling an already-cancelling job is another 202 - it is idempotent. A job that has already reached a terminal state answers 409 job_not_cancellable naming its state.

It was DELETE /job/{id} until 2026-08-17, and that spelling is now 405 with allowed[], never a silent accept. Nothing is deleted by a cancel - the job stays readable for its whole retention - and a 202-with-a-body is not the shape a client's HTTP layer expects from a DELETE.
Cancellation stops future items; it does not undo finished ones. Monitors already created stay created. Read results[] to see exactly where it stopped.

POST /job/{id}/resume continues an interrupted job - one that stopped because the server running it did. It skips the items that already concluded and answers 202 + Retry-After. Any other state is 409 carrying state and reason, as are the few job kinds whose original request is not stored (resubmit those). A keyed retry replays the first 202 rather than meeting a 409 for its own successful call.

Two-phase destructive bulk

An operation that deletes a filter-matched set will not act on a filter whose consequences you have not seen. The flow is validate, then submit the count you were shown - two endpoints, and the count is the handshake.

Phase 1 - validate

request
curl -X POST 'https://api2.host-tracker.com/monitor/bulk-delete-validate' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "filter": { "tags": ["staging"] } }'
200 OK
{
  "matched": 37,          // every monitor the filter selects, counted server-side
  "sample": [ /* the first few, so a human can recognise the selection */ ],
  "truncated": false,  // true when the selection is larger than one submission carries
  "max": 500            // the largest selection one submission accepts
}

Nothing has changed. Show matched and sample to whoever is about to approve the deletion.

Phase 2 - submit

request
curl -X POST 'https://api2.host-tracker.com/monitor/bulk-delete' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 5f2c1a90-…' \
  -d '{ "filter": { "tags": ["staging"] }, "expectedCount": 37 }'

Answers 202 with a job and a Retry-After, like any other bulk operation.

PropertyValue
filterRequired, never optional. An absent filter would match the whole account, so it is refused rather than defaulted.
expectedCountRequired. The number the matching validate call reported. The server re-resolves the filter at submit time and refuses if it has moved: 409 selection_mismatch carrying {expected, actual}, with nothing deleted. Validate again and decide.
Nothing to expireThere is no minted token and no lifetime to race - the count IS the handshake, and it is re-checked against live data at the moment of the write.
Same shape everywherePOST /contact/bulk-delete takes the identical pair against POST /contact/bulk-delete-validate.
If you integrated against an earlier draft: the signed selectionToken is gone. The preview used to mint a sel_… token with a 300-second lifetime that the apply call spent. It was replaced by this validate-then-submit pair - one fewer opaque value to carry, one fewer clock to race, and the same protection, because expectedCount is verified against live data rather than against a snapshot the token remembered.

The non-destructive twin follows the same two-endpoint shape without the count: POST /monitor/bulk-update-validate reports what POST /monitor/bulk-update would touch, and the update itself takes either an explicit ids list or a filter.

Idempotency-Key

Send Idempotency-Key: <an opaque string, max 255 characters> on any write, and a retry after a timeout is safe: the second call returns the first call's stored response instead of doing the work again.

  • Keys are scoped to your account, and remembered for 24 hours.
  • What makes a repeat "the same request" is the method, the route, and a canonicalized hash of the body - so a client library that reorders JSON keys does not accidentally lose idempotency.
  • Generate one key per logical operation (a UUID is ideal) and reuse it across that operation's retries. Reusing one key for two different operations is the one thing that fails.

Where it is required

Every other write merely accepts the header - and as of 2026-08-17 there is no write on this surface that accepts it and ignores it, so sending one is always safe and never a no-op. Nine operations refuse without it, in two groups:

EndpointWhy it refuses
POST /monitor/bulk
POST /monitor/bulk-update
POST /monitor/bulk-delete
POST /monitor/{monitorId}/reset-stats
POST /contact/bulk
POST /contact/bulk-delete
POST /monitor/report
The seven job doors. A door that does its work AFTER it answers cannot tell you whether a request that timed out landed - so the key is the only thing that makes the retry safe.
POST /statuspage/{id}/incident
POST /statuspage/{id}/incident/{incidentId}/timeline
Declaring an incident, or appending to its timeline, fans out to the page's subscribers. A keyless retry announces the incident twice.
Not required, but strongly advised, on anything that spends money or creates a row. POST /contact and an inline contacts[] on POST /monitor send confirmation codes; a keyless retry sends them again. The key is honoured there - use it.

Refusal is a 400 that names the operation, so the fix is unambiguous:

400 idempotency_key_required
{
  "type": "https://api2.host-tracker.com/problems/idempotency-key-required",
  "title": "This operation requires an Idempotency-Key header.",
  "status": 400,
  "code": "idempotency_key_required",
  "detail": "Send an Idempotency-Key header (a unique opaque string per logical operation, max 255 chars).",
  "errors": [ { "endpoint": "bulkCreateMonitors" } ]
}
The two conditional rows are conditional on the BODY, so you cannot tell from the URL alone. The safe habit is to send a key on every write - it is never rejected for being present, and it turns every timeout into a safe retry.

Replay and conflict

Second call with the same keyAnswer
Same request bodyThe stored response, byte for byte - same status, same body, same Location - plus Idempotency-Replayed: true.
Different request body409 idempotency_key_conflict, reason: "different_body".
While the first is still running409 idempotency_key_conflict, reason: "in_flight", with Retry-After. Wait and retry.

A real replay - note the header and that the id is the first call's, so no second monitor was created:

second POST, same key, same body
HTTP/2 201
location: /monitor/56e7c0ba-19c0-4daa-8fbe-c54e2a0db52a
idempotency-replayed: true
409 idempotency_key_conflict
{
  "code": "idempotency_key_conflict",
  "status": 409,
  "detail": "This Idempotency-Key was first used for a different request body.",
  "errors": [
    {
      "key": "docs-replay-1785712625",
      "firstSeenAt": 1785712626,
      "reason": "different_body"
    }
  ]
}

A first attempt that failed does not lock the key: retrying with the same key re-runs the operation, which is what you want when the failure was transient.

Two endpoints deliberately do not honour the header - POST /webhook/{id}/test and the redelivery endpoint. Both exist to send something now; replaying the first response instead of sending again would defeat the point.