1 Upload Assets

Step 1: Upload Assets via Signed URL

Create an asset ingestion task and upload the file in a two-step flow:

  1. Request a signed URL. POST /ingestions/assets with a JSON body describing the asset returns a short-lived (15 min) signed upload URL, a task ID in pending, and an upload action descriptor under meta.client_actions[0] that tells SDKs/UIs how to send the bytes.
  2. 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 of pending. 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_URL is meta.client_actions[0].href from 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 leaves pending — 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/assets with multipart/form-data, the response now carries Deprecation: true and a Link: …; rel="successor-version" header.

Request

POST /ingestions/assets with Content-Type: application/json

FieldTypeRequiredDescription
location_typestringYesfacility, plot, or shed
commodity_typestringYescattle, cocoa, coffee, palm, rubber, soy, timber, shrimp. Aliases palm oil and oil palm are accepted.
metricsstring[]NoSubset of deforestation, emissions, biodiversity, water
tagsstring[]No≤ 50 tags, each ≤ 100 chars
end_datestringNoYYYY-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.id as the task ID for subsequent steps
  • Read meta.client_actions[0] and call xhr.open(action.method, action.href)
  • Drive a progress bar from xhr.upload.onprogress
  • On 2xx, poll action.poll.href every action.poll.interval_ms until data.attributes[action.poll.attribute] is in action.poll.terminal_states

Errors

StatusCodeCause
400VALIDATION_ERRORMissing/malformed JSON field; unknown or duplicate metrics
401MISSING_API_KEY / INVALID_API_KEYAuth missing or invalid
403FORBIDDENAPI key lacks ingestions:write
415UNSUPPORTED_MEDIA_TYPESigned-URL flow not enabled in this environment

Next Step

→ Step 2: Check Validation Status (GET /ingestions/:id/summary)