Idempotency and rate limits
Idempotency-Key on every POST
Section titled “Idempotency-Key on every POST”Every POST must carry an Idempotency-Key header - the metered ones, the
ones that start jobs, and the ones that are reads in disguise
(POST /v1/indices/batch, POST /v1/cap/subsidy/calculate,
POST /v1/cap/eco-schemes/optimize). Without it the API answers
400 idempotency_key_required before anything is executed, reserved or
billed. The check runs after authentication and after the request body has
been parsed and validated, so a request that is both malformed and missing
the header gets 400 validation_error first - fix the body, add the key, and
the same request goes through.
curl -s -X POST "$BASE/damage/assess" -H "Authorization: Bearer $KEY" \ -H "Idempotency-Key: 6f1c2f7e-1f0e-4a0a-9a8e-1b2c3d4e5f60" \ -H "Content-Type: application/json" \ -d '{"parcel_id":"prc_...","event_date":"2026-07-15","peril":"hail","claim_ref":"CLM-2026-8812"}'The rule exists because several POSTs are metered (a damage assessment is EUR 35) or start long jobs. A network timeout must never turn into a second charge or a second job.
What to send
Section titled “What to send”- One UUID v4 per logical operation. “Assess claim CLM-2026-8812 for the 15 July hail” is one operation, however many times your code retries it.
- Reuse the same key on every retry of that operation, including retries from a different process after a crash. Persist the key together with the intent, not only in memory.
- A new key for a new operation, even if the body happens to be identical.
What comes back
Section titled “What comes back”| Situation | Response |
|---|---|
| First call | Normal result (201, 202, 200), executed and billed once. |
| Same key, same body, within 24 h | The stored original result, with Idempotent-Replay: true and HTTP 200 (also for a POST that first answered 201 or 202 - the OpenAPI document declares both). Not executed and not billed again. |
| Same key, different body | 409 idempotency_conflict with detail.idempotency_key. |
| Same key while the first call is still running | 409 idempotency_conflict with detail.state: "in_progress" and a Retry-After header (at most 120 s - the reservation is that short so that a crashed first request frees the key in minutes, not a day). Wait and retry with the same key: you get the stored result, or the key is free again if the original never finished. |
Keys are stored for 24 hours. After that the same key starts a new operation - keep your retry windows well inside that. Two exceptions make the binding longer, not shorter:
- Jobs. The key of a
POSTthat created a job (/damage/assess,/parcels/bulk,/portfolios/{id}/risk-score/refresh) stays bound to that job for as long as the job is retained - 180 days. Repeating it with the same body returns the same job with its current status andIdempotent-Replay: true; repeating it with a different body returns409 idempotency_conflict. Use a new key for a new claim, import or refresh. - Publishing an index definition twice returns the same document with
Idempotent-Replay: true, and the annual fee is charged once - the definition itself is the record.
A replay is cheap for us but it is still a request: it counts against the
rate-limit pool of the endpoint exactly like a first call (a replayed
POST /damage/assess consumes one of the sandbox’s two expensive calls per
minute). Retry after X-RateLimit-Reset, not in a tight loop.
Rate limits
Section titled “Rate limits”Limits are per key, per minute, in three separate pools, so a batch of expensive jobs can never starve your ordinary reads:
| Pool | Applies to | Sandbox | Starter | Growth | Scale | Enterprise |
|---|---|---|---|---|---|---|
standard |
every GET (and HEAD / OPTIONS) |
60 | 600 | 2 000 | 6 000 | 20 000 |
write |
every other method that is not expensive: POST /parcels, /parcels/bulk, POST /indices/batch, the CAP calculations, index-product definitions, webhook management, every DELETE |
10 | 60 | 200 | 600 | 2 000 |
expensive |
POST /damage/assess, index-product backtest, POST /portfolios/{id}/risk-score/refresh |
2 | 5 | 20 | 60 | 200 |
Each pool also has a short per-second burst allowance (burst in
GET /v1/account, 10 in the sandbox). Enterprise numbers are starting values;
the contract can raise them.
The pool is chosen by method: a GET is standard, anything else is write
unless the endpoint is explicitly marked expensive. So POST /indices/batch
counts against write - one call, up to 500 parcels - not against standard.
Headers on every response
Section titled “Headers on every response”X-RateLimit-Limit: 600X-RateLimit-Remaining: 412X-RateLimit-Reset: 1758200400 # unix seconds when the window frees upX-RateLimit-Pool: standardThey are on every response, including 429, and always describe the minute
window. Read X-RateLimit-Remaining before deciding to fan out; read
X-RateLimit-Reset when you hit the limit.
A 429 caused by the per-second burst limit looks slightly different: it adds
X-RateLimit-Burst-Limit and X-RateLimit-Burst-Reset and carries
Retry-After: 1, while X-RateLimit-Remaining still shows the minute budget
you have left - a rejected burst call does not consume it. When you see the
burst headers, spread the calls out; when you do not, wait for the window.
When you exceed a pool
Section titled “When you exceed a pool”{ "error": { "type": "rate_limited", "message": "Rate limit of 2 requests per minute exceeded for the 'expensive' pool", "detail": { "pool": "expensive", "limit": 2, "window_seconds": 60 }, "request_id": "req_5D2F9A1BCKMN", "doc_url": "https://docs.agrovisio.ai/errors#rate_limited" }}The response also carries Retry-After in seconds. Back off until
X-RateLimit-Reset, then retry: the request itself was fine. If you are
hitting standard while iterating over parcels, switch to the batch endpoint
(POST /v1/indices/batch, up to 500 parcels per call, counted against write:
10 per minute in the sandbox, so 5 000 parcel-series a minute instead of 60) or
to webhooks instead of polling.
Speed limit versus volume limit
Section titled “Speed limit versus volume limit”429 rate_limited and 429 parcel_quota_exceeded share a status code and
nothing else:
rate_limitedis a speed limit. Wait and retry.parcel_quota_exceededis a volume limit: the plan’s parcel quota (or the 10 own parcels of the sandbox) is used up.detailcarriesplan,quota,activeandrequested. Delete parcels, upgrade, or - in the sandbox - use the shared demo parcels. Retrying does not help.
Billing is by the monthly peak of active parcels, so deleting a parcel frees the quota for new ones but does not lower the peak already recorded for the current period. See the billing guide.
Polling jobs
Section titled “Polling jobs”Every endpoint that answers 202 returns a job_id, a poll_url
(/v1/jobs/{job_id}) and an initial estimated_seconds. The job document
has a fixed shape:
{ "job_id": "job_QC3HBJ0EW2H89CW9M5VT", "type": "damage", "status": "completed", "progress": { "pct": 100, "step": "done" }, "created_at": "2026-10-03T11:27:30.118Z", "started_at": "2026-10-03T11:27:30.402Z", "finished_at": "2026-10-03T11:27:31.077Z", "eta_seconds": null, "attempts": 1, "result": { "assessment_id": "dmg_4TVZ34Y9SJEH8NNSCWJ2", "...": "..." }, "error": null}statusisqueued,running,completed,failedorcancelled.eta_secondsis the remaining estimate, not the number you received with the202: it counts down while the job runs, includes the back-off while a failed attempt waits to be retried, and isnullonce the job has finished.- A failing attempt is retried three times (after 30 s, 2 min and 10 min);
only when the fourth attempt fails does the job end as
failedand thejob.failedwebhook fire.attemptscounts the attempts made so far. Each attempt is bounded by a per-type limit (damage10 min,bulk_import60 min,portfolio_refresh2 h); an attempt that exceeds it is recorded as an error of typetimeoutand retried like any other failure. errorisnulluntil an attempt fails, then{"type", "message"}(plusdetailwhere the type carries one). Branch ontype, as with HTTP errors: it is either a type from the error catalog - for exampleinsufficient_datawith the same message you would get synchronously - ortimeout. Aninternal_errorcarries a generic message only; the cause is in our logs under thejob_id, so quote it when you contact support.- A job that belongs to another client is a
404 not_found, never a403.
Jobs are retained for 180 days; the Idempotency-Key that created one stays
bound to it for the same time (see above). In the sandbox a damage job
finishes within a few seconds; the SDKs’ jobs.wait helper polls with
back-off so you do not have to.
Pagination
Section titled “Pagination”Lists are cursor based. Pass the returned next_cursor back as ?cursor=
until has_more is false:
curl -s "$BASE/parcels?limit=100" -H "Authorization: Bearer $KEY"curl -s "$BASE/parcels?limit=100&cursor=eyJpZCI6..." -H "Authorization: Bearer $KEY"(eyJpZCI6... stands for a real next_cursor; the cursor is an opaque token
and anything else - including that placeholder - answers
400 validation_error with detail.fields[0].field: "cursor".)
Offsets are not supported on purpose: they are unstable while parcels are
being added, and a bulk import that lands halfway through your scan would make
you skip or duplicate rows. ?offset=, like any query parameter the endpoint
does not declare or a filter value it does not know (land_use=swamp,
country=Lithuania), is a 400 validation_error listing the supported
values - never a silently unfiltered list.
