Skip to content

Idempotency and rate limits

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.

Terminal window
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.

  • 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.
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 POST that 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 and Idempotent-Replay: true; repeating it with a different body returns 409 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.

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.

X-RateLimit-Limit: 600
X-RateLimit-Remaining: 412
X-RateLimit-Reset: 1758200400 # unix seconds when the window frees up
X-RateLimit-Pool: standard

They 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.

{
"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.

429 rate_limited and 429 parcel_quota_exceeded share a status code and nothing else:

  • rate_limited is a speed limit. Wait and retry.
  • parcel_quota_exceeded is a volume limit: the plan’s parcel quota (or the 10 own parcels of the sandbox) is used up. detail carries plan, quota, active and requested. 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.

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
}
  • status is queued, running, completed, failed or cancelled.
  • eta_seconds is the remaining estimate, not the number you received with the 202: it counts down while the job runs, includes the back-off while a failed attempt waits to be retried, and is null once 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 failed and the job.failed webhook fire. attempts counts the attempts made so far. Each attempt is bounded by a per-type limit (damage 10 min, bulk_import 60 min, portfolio_refresh 2 h); an attempt that exceeds it is recorded as an error of type timeout and retried like any other failure.
  • error is null until an attempt fails, then {"type", "message"} (plus detail where the type carries one). Branch on type, as with HTTP errors: it is either a type from the error catalog - for example insufficient_data with the same message you would get synchronously - or timeout. An internal_error carries a generic message only; the cause is in our logs under the job_id, so quote it when you contact support.
  • A job that belongs to another client is a 404 not_found, never a 403.

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.

Lists are cursor based. Pass the returned next_cursor back as ?cursor= until has_more is false:

Terminal window
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.