Ucotron Cortex
Guides by flow

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:

KeyEnvironmentWhat it returnsQuota
uco_sbox_…https://sbox.ucotron.comDeterministic fixturesNot consumed
uco_live_…https://api.ucotron.comReal satellite dataConsumed

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.

bash
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 seeWhy
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 succeededThere 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 exerciseHow to ask for itWhat it answers
Lightning: no activityinteger lat and lng (e.g. -31, -68)detected: false with coverage: "ok"
Lightning: out of coverageeventAt before 2018coverage: "out_of_coverage", no provenance
Housing: empty plota ring of 5 hectares or morehasBuilding: false
Damage: unknown plotaoi_ref outside the demo catalogue (demo:lote-1, -2, -3)job failed with aoi_not_found
Idempotency conflictIdempotency-Key: 00000000-0000-0000-0000-000000000409409 idempotency_conflict, always
Malformed ringa ring whose last point does not repeat the first422 invalid_ring
Impossible dateeventAt in the future422 event_in_future
Unknown reporta made-up reportId404 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.

CodeWhenretryableWhat to do
400Body is not JSONnoFix the request
401Key missing, invalid, revoked, expired, or from another environmentnoCheck the credential. code tells the cases apart
402The organization's budget is exhaustednoContact us: a commercial cap, not a technical error
403The key lacks the permission the operation needsnoThe body carries requiredPermission
404Route does not exist or the resource belongs to another organizationnoSee below
409Same Idempotency-Key, different body. Or result requested too earlynoUse a new key, or wait for succeeded
422Parameters are not shaped as expectednoThe body lists the fields under issues
422 invalid_ringThe plot ring is not closednoRepeat the first point at the end. We do not close it for you: that would assess a different surface
422 event_in_futureThe event date is after todaynoFix the date. There is no satellite image of tomorrow
428Missing Idempotency-Key on a quota-consuming operationnoAdd the header
429Too many requestsyesWait out the Retry-After
503 engine_not_configuredThat product's satellite engine is unavailable in the environmentnoWrite to us. We would rather say so than issue a signed document without the data behind it
5xxSomething on our sideyesRetry 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.

On this page