Jobs, delivery and errors
How the partner API works underneath — credentials, idempotency, the job lifecycle, downloads, and the full table of status codes.
The three products —lightning activity, housing and damage assessment— share the same shape. This guide explains that shape once; each product guide covers only what differs.
The two environments
Your organization gets two keys, and they are not interchangeable:
| Key | Environment | What it returns | Quota |
|---|---|---|---|
uco_sbox_… | https://sbox.ucotron.com | Deterministic fixtures | Not consumed |
uco_live_… | https://api.ucotron.com | Real satellite data | Consumed |
Build your integration against the sandbox and change one variable to go live: routes and bodies are identical, only the host and the key change.
Presenting a key against the wrong environment returns 401 with code
environment_mismatch. It has a reason of its own on purpose: an unknown key
sends you to check whether it was copied correctly, a wrong-environment key
sends you to check which variable was deployed. Those are different
investigations.
curl -s -X POST "$BASE/v1/lightning/evaluations" \
-H "Authorization: Bearer $UCOTRON_API_KEY" \
-H "content-type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"lat":-2.76,"lng":-60.52,"eventAt":"2026-06-26T17:22:00Z","radiusKm":20,"windowMin":15}'The secret is shown once, when minted. We store its hash, not the secret, so "we will not show it again" is literal: if it is lost, revoke it and mint another.
The sandbox keeps no state
sbox.ucotron.com has no database, and that is deliberate: the environment
declares databaseMutation: false. Everything it answers is a fixture, so each
response is a pure function of its request and the identifier carries the request
inside it. Any instance reconstructs it identically without storing anything.
That has two visible consequences. They are not bugs in your integration:
| What you see | Why |
|---|---|
GET /v1/jobs and GET /v1/reports return data: [] | Listing asks "what did I do?", and that is exactly the state the sandbox does not keep. Returning a made-up catalogue would show you jobs you never enqueued |
Jobs are born succeeded | There is no queue or worker to wait for. Your polling loop still works —the response carries pollUrl and Retry-After— but it closes on the first pass |
And a third one about the PDF: the file is real and opens, with its satellite
map, its logo and its signature block, but its content does not reflect your
request's parameters. Asking for a 5 km or an 80 km radius returns the same
document. The sha256 the API reports is the one of the bytes you download, so
verifying the download does work.
To exercise the full asynchronous cycle, the listing, and a PDF that reflects your query, use production.
Deterministic scenarios
Because the sandbox keeps no state, the negative paths are requested by
convention of the request itself. They exist so you can write and test the
else branch of your integration without going live: an environment that only
knows how to say yes is no use for that.
| What you want to exercise | How to ask for it | What it answers |
|---|---|---|
| Lightning: no activity | integer lat and lng (e.g. -31, -68) | detected: false with coverage: "ok" |
| Lightning: out of coverage | eventAt before 2018 | coverage: "out_of_coverage", no provenance |
| Housing: empty plot | a ring of 5 hectares or more | hasBuilding: false |
| Damage: unknown plot | aoi_ref outside the demo catalogue (demo:lote-1, -2, -3) | job failed with aoi_not_found |
| Idempotency conflict | Idempotency-Key: 00000000-0000-0000-0000-000000000409 | 409 idempotency_conflict, always |
| Malformed ring | a ring whose last point does not repeat the first | 422 invalid_ring |
| Impossible date | eventAt in the future | 422 event_in_future |
| Unknown report | a made-up reportId | 404 not_found |
The conflict key is reserved because the sandbox cannot remember that a key was already used: without it, the conflict would be the only code in the contract you could not rehearse before going live.
Idempotency
Every operation that consumes quota requires the Idempotency-Key header.
Without it the answer is 428, not 400: a precondition is missing, not a
malformed body.
Same key and same body: the second call returns the stored result without
recomputing or charging again. Same key, different body: 409 idempotency_conflict. That is almost certainly a bug on your side, and answering
the new question under the old key would hand you the wrong answer without anyone
noticing.
This holds for jobs and for evaluations alike. If your integration was
written against an earlier version of this API, it is worth revisiting:
evaluations used to require the key and never read it, so a retry with a changed
body answered 200 instead of 409.
A job's identifier comes from the hash of its content. Asking twice for
exactly the same analysis —even with different idempotency keys— returns the job
that already exists, with its result untouched. That also means re-issuing the
same report does not produce a new document: it produces the same one, with the
same sha256.
The job lifecycle
Evaluations answer immediately. PDF issuance is asynchronous, because rendering a document takes seconds and keeping you on an open HTTP response would be a promise we cannot always keep.
sequenceDiagram
participant C as Your system
participant A as API
C->>A: POST /v1/lightning/reports { evaluationId }
A-->>C: 202 · { id, status: "queued", pollUrl }
loop every Retry-After seconds
C->>A: GET /v1/jobs/{id}
A-->>C: 200 · { status: "running" }
end
C->>A: GET /v1/jobs/{id}
A-->>C: 200 · { status: "succeeded", resultUrl }
C->>A: GET /v1/reports/{id}/download
A-->>C: 302 · to the PDF
States: queued → running → succeeded | failed | cancelled. The last
three are terminal: a job that got there never changes again.
Honour the Retry-After returned with the 202. Polling faster speeds nothing
up and moves you closer to a 429.
GET /v1/jobs lists your jobs, filterable by status and operation.
Pagination is cursor-based: pass the previous page's nextCursor. Do not use
offsets — the list grows from the top, and a job enqueued between two pages
would make a row repeat or be skipped.
Downloads
GET /v1/reports/{id}/download returns a 302 to the PDF. That address is
safe to store: it does not expire. What expires is the URL it redirects to,
minted at click time and valid for minutes.
This is deliberate, and better than handing out a signed link directly: access
is cut the moment you revoke the key, which an already-delivered S3 link cannot
do. Follow the redirect with curl -L.
GET /v1/reports lists what you have issued, with each document's sha256 and
signature — which lets you verify later that the file you hold is the one we
issued.
Status codes, and what to do with each
Every error is RFC 7807 and
carries a retryable field: you do not need to maintain your own table of
what is worth retrying.
| Code | When | retryable | What to do |
|---|---|---|---|
400 | Body is not JSON | no | Fix the request |
401 | Key missing, invalid, revoked, expired, or from another environment | no | Check the credential. code tells the cases apart |
402 | The organization's budget is exhausted | no | Contact us: a commercial cap, not a technical error |
403 | The key lacks the permission the operation needs | no | The body carries requiredPermission |
404 | Route does not exist or the resource belongs to another organization | no | See below |
409 | Same Idempotency-Key, different body. Or result requested too early | no | Use a new key, or wait for succeeded |
422 | Parameters are not shaped as expected | no | The body lists the fields under issues |
422 invalid_ring | The plot ring is not closed | no | Repeat the first point at the end. We do not close it for you: that would assess a different surface |
422 event_in_future | The event date is after today | no | Fix the date. There is no satellite image of tomorrow |
428 | Missing Idempotency-Key on a quota-consuming operation | no | Add the header |
429 | Too many requests | yes | Wait out the Retry-After |
503 engine_not_configured | That product's satellite engine is unavailable in the environment | no | Write to us. We would rather say so than issue a signed document without the data behind it |
5xx | Something on our side | yes | Retry with backoff |
Why someone else's resource returns 404 and not 403. A 403 would confirm
that the identifier exists somewhere. The 404 does not distinguish "does not
exist" from "not yours" —neither in the status nor in the body, which is
identical in both cases— and so this endpoint cannot be used to find out which
ids are real.
What a report asserts
Every PDF carries the analysis hash and an Ed25519 signature in its footer. That lets you verify the file was not altered and that the analysis can be reproduced: the same query over the same scenes yields the same result.
The signature is verified with a public key, not a shared secret. That
distinction matters once the document reaches a case file: whoever verifies it
cannot forge another. The key is published, with no credentials, at
https://app.ucotron.com/v1/verify/keys, and the PDF itself states which key it
used and over which bytes it signed. See
Signed reports.
What it does not do is replace a field inspection. Each report states its own limitations in a dedicated section, before the provenance block, so that whoever reaches the end already knows what the document can and cannot support.
Damage assessment
How much of the plot was damaged after a hail, flood or drought event — with the preliminary step that avoids spending quota on a calculation that cannot be made yet.
Partner API
Three satellite-data products with a signed PDF report: lightning activity, housing detection and damage assessment.
