1 Upload Assets
▶Step 1: Upload Assets via Signed URL
Create an asset ingestion task and upload the file in a two-step flow:
- Request a signed URL.
POST /ingestions/assetswith a JSON body describing the asset returns a short-lived (15 min) signed upload URL, a task ID inpending, and anuploadaction descriptor undermeta.client_actions[0]that tells SDKs/UIs how to send the bytes. - Upload the bytes. PUT the file to the signed URL with
Content-Type: application/octet-stream. Once GCS finalises the object, an internal webhook dispatches the ingestion task and the status moves out ofpending. No further client call is needed.
This step covers the JSON POST. Step 1b below is the explicit byte
PUT — a server-to-server integrator issues it directly; no docs-UI step
is required.
▶Step 1b: PUT the file bytes to the signed URL
A plain HTTP PUT straight to GCS, with no x-api-key. Take the
descriptor from meta.client_actions[0] (mirrored on
data.attributes.upload_url) and send the raw file bytes with the headers
the descriptor lists:
# The header values below are illustrative. Copy them verbatim from your
# own response's `client_actions[0].headers` — a minted descriptor may
# differ, and GCS 403s if any header does not match what it signed.
curl -X PUT "$UPLOAD_URL" \
-H "content-type: application/octet-stream" \
-H "x-goog-content-length-range: 0,104857600" \
-H "x-goog-if-generation-match: 0" \
--data-binary @assets.csv
$UPLOAD_URLismeta.client_actions[0].hreffrom the 201 response.- Send exactly the headers in
client_actions[0].headers— GCS rejects the PUT (403 SignatureDoesNotMatch) if the signed headers do not match. The values shown above are only the descriptor's defaults for this example. - The URL is single-use and expires at
expires_at. Re-POST step 1 to mint a fresh one if it lapses. - On a
2xx, GCS finalises the object, an internal webhook dispatches the ingestion task, and the status leavespending— which is what Step 2 polls for.
▶What this step does
It registers a new asset ingestion task and hands back the credentials
to deliver the file: a task id (in pending), a short-lived signed GCS
URL, and an upload action descriptor under meta.client_actions[0].
The JSON body describes what you're uploading — the location type, the
commodity, any tags, and (here) the include_core assessment — but not
the bytes. The bytes go up in the second call, direct to storage.
▶Why it's needed
This is the entry point of the whole workflow: every later step addresses
the task id this call mints, captured here into {{asset_task_id}} and
{{eudr_task_id}}. Without it there is no task to validate, process, or
read results from. The two-step signed-URL shape exists so the file never
transits the API itself — the client PUTs straight to GCS, and only once
the object finalises does an internal webhook dispatch the ingestion,
moving the task out of pending. That keeps large uploads off the API's
request path and lets validation run asynchronously.
▶Why the signed-URL flow (vs multipart)
The signed-URL flow is the supported path and is what every field here is
built around. The older multipart/form-data variant still responds, but
it now carries a Deprecation: true header and a successor-version
link — prefer the JSON POST + PUT sequence for anything new, since it
scales to large files and is what the SDKs/UIs drive off
meta.client_actions[0].
New in 2026-Q2. This replaces the deprecated multipart variant — if you call
POST /ingestions/assetswithmultipart/form-data, the response now carriesDeprecation: trueand aLink: …; rel="successor-version"header.
▶Request
POST /ingestions/assets with Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
location_type | string | Yes | facility, plot, or shed |
commodity_type | string | Yes | cattle, cocoa, coffee, palm, rubber, soy, timber, shrimp. Aliases palm oil and oil palm are accepted. |
metrics | string[] | No | Subset of deforestation, emissions, biodiversity, water |
tags | string[] | No | ≤ 50 tags, each ≤ 100 chars |
end_date | string | No | YYYY-MM-DD; Dec 31 of a prior year, or any date in the current year up to today (UTC) |
Note: filename and content_type are not sent. They are resolved
from the uploaded object's metadata when GCS finalises.
▶Response (201)
{
"meta": {
"client_actions": [
{
"type": "upload",
"method": "PUT",
"href": "https://storage.googleapis.com/.../X-Goog-Signature=…",
"expires_at": "…",
"headers": {
"content-type": "application/octet-stream",
"x-goog-content-length-range": "0,104857600",
"x-goog-if-generation-match": "0"
},
"poll": {
"href": "/ingestions/assets/<task-id>",
"attribute": "overall_status",
"terminal_states": ["completed", "failed"],
"interval_ms": 2000
}
}
]
},
"links": { "self": "/ingestions/assets", "status": "/ingestions/assets/<task-id>" },
"data": {
"type": "asset_task",
"id": "<task-id>",
"attributes": {
"overall_status": "pending",
"source_file": "upload",
"upload_url": "https://storage.googleapis.com/.../X-Goog-Signature=…",
"expires_at": "…",
"created_at": "…"
},
"links": { "self": "/ingestions/assets/<task-id>", … }
}
}
Clients should drive the upload off meta.client_actions[0] rather
than attributes.upload_url — the action descriptor also tells them
the HTTP method, required headers, and how to poll for completion.
▶Frontend Usage
- Store
data.idas the task ID for subsequent steps - Read
meta.client_actions[0]and callxhr.open(action.method, action.href) - Drive a progress bar from
xhr.upload.onprogress - On 2xx, poll
action.poll.hrefeveryaction.poll.interval_msuntildata.attributes[action.poll.attribute]is inaction.poll.terminal_states
▶Errors
| Status | Code | Cause |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing/malformed JSON field; unknown or duplicate metrics |
| 401 | MISSING_API_KEY / INVALID_API_KEY | Auth missing or invalid |
| 403 | FORBIDDEN | API key lacks ingestions:write |
| 415 | UNSUPPORTED_MEDIA_TYPE | Signed-URL flow not enabled in this environment |
▶Next Step
→ Step 2: Check Validation Status (GET /ingestions/:id/summary)