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:
| Endpoint | What it does |
|---|---|
POST /monitor/bulk | Create many monitors. |
POST /monitor/bulk-update | Patch, or reset the statistics of, many monitors - by ids or by a filter. |
POST /monitor/bulk-delete | Delete a filter-matched set (phase two - see below). |
POST /monitor/{monitorId}/reset-stats | Discard one monitor's accumulated statistics. |
POST /contact/bulk | Create, update and delete many contacts. |
POST /contact/bulk-delete | Delete a filter-matched set of contacts. |
POST /monitor/report | Generate 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:
{ "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
curl 'https://api2.host-tracker.com/job/be65c73e-4e1a-49d7-8ef9-ba9b11c8681c' \
-H 'Authorization: Bearer YOUR_TOKEN'
{
"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 whosestatesaysfailed; 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 samenextCursor/hasMorepair as any collection.expiresAtis when the job stops being readable. Jobs are kept for 7 days, then answer 404. Read what you need before then.errorappears 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.)
| State | Terminal? | Meaning |
|---|---|---|
queued | no | Accepted, not started. |
running | no | In progress. progress.done is advancing. |
succeeded | yes | Every item succeeded. An empty job succeeds. |
partial | yes | Some items succeeded, some failed. |
failed | yes | Every item failed. |
cancelled | yes | Cancellation was accepted and took effect. |
interrupted | no | The 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
| Member | Meaning |
|---|---|
index | The 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. |
itemRef | A human-recognisable handle for the item (for a monitor, its URL). Convenience, not an identifier. |
status | pending · 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. |
entityId | The affected resource's id, once there is one. |
result | The resulting representation, rendered fresh at poll time. |
error | For a failed item: a full problem document, the same shape a synchronous failure would have had. See Errors. |
processedAt | When 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:
{
"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_failedimmediately rather than producing a job whose callback silently never fires. - It delivers the
job.completedevent, 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'seventsarray, and a webhook does not need to list it to receive one. - Add
"on": "progress"to also receivejob.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.
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.
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
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"] } }'
{
"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
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.
| Property | Value |
|---|---|
filter | Required, never optional. An absent filter would match the whole account, so it is refused rather than defaulted. |
expectedCount | Required. 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 expire | There 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 everywhere | POST /contact/bulk-delete takes the identical pair against POST /contact/bulk-delete-validate. |
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:
| Endpoint | Why it refuses |
|---|---|
POST /monitor/bulkPOST /monitor/bulk-updatePOST /monitor/bulk-deletePOST /monitor/{monitorId}/reset-statsPOST /contact/bulkPOST /contact/bulk-deletePOST /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}/incidentPOST /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. |
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:
{
"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" } ]
}
Replay and conflict
| Second call with the same key | Answer |
|---|---|
| Same request body | The stored response, byte for byte - same status, same body, same Location - plus Idempotency-Replayed: true. |
| Different request body | 409 idempotency_key_conflict, reason: "different_body". |
| While the first is still running | 409 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:
HTTP/2 201
location: /monitor/56e7c0ba-19c0-4daa-8fbe-c54e2a0db52a
idempotency-replayed: true
{
"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.
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.