Asset Upload

There are two transports for POST /ingestions/assets. The JSON / signed-URL flow is the recommended path for all new integrations; the multipart/form-data flow still works but is deprecated and will be removed in a future release.


Suited for MCP / LLM clients, server-to-server callers, and anything that does not want to proxy file bytes through the API. The file is PUT directly to object storage; the ingestion task starts when storage finalises the upload.

Quickstart

1. Request an upload URL. Send a JSON body describing the asset. filename is optional but recommended — when supplied, its extension routes the file directly to the correct parser. When omitted, the task worker sniffs the first few KB of the uploaded bytes and infers the format.

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

{
  "location_type": "facility",
  "commodity_type": "palm",
  "metrics": ["deforestation"],
  "tags": ["batch-q1"],
  "end_date": "2099-01-01",
  "filename": "q1-facilities.csv",
  "name": "Q1 facilities — APAC",
  "description": "First-quarter facility refresh for the APAC region."
}

commodity_type and commodity_column are both optional:

  • commodity_column (≤ 255 chars) names the column holding each row's commodity, matched ignoring case and surrounding spaces. It outranks a column named commodity_type and the automatic column scan. For a shapefile, give the truncated DBF name (at most 10 characters). If the file has no such column, the task fails and its error lists the file's columns.
  • commodity_type is the file's commodity. It fills blank cells and is the fallback for unsupported ones, but it never overrides a cell. With no commodity column in use, every row takes it.
  • Omit both and the commodity is read from a column named commodity_type, or from the column the profiler judges most likely.

Every row is stored, recorded and checked on the same commodity. See The commodity column for the full rules, the commodity_invalid flag and commodity_column_report.

The canonical values are cattle, cocoa, coffee, palm, rubber, soy, timber, and shrimp. Common aliases are accepted and normalised to the canonical value on ingest — on both transports — so palm oil and oil palm are stored as palm, wood / paper / pulp / lumber as timber, beef / leather as cattle, soya / soybean / soja as soy, and cacao as cocoa. Because the stored value is canonical, GET /locations?filter[commodity]=eq:palm returns rows uploaded as palm oil.

name and description are optional human-friendly labels for the task, returned on every task read so you can recognise an upload without inspecting its filename or tags:

  • name (≤ 255 chars) — a display name for the task. When you omit it, the API derives one from your tags (joined with , ), falling back to the filename. Supply it explicitly to override that derivation.
  • description (≤ 2000 chars) — free-form notes. Never derived; absent unless you set it.

filename is sanitised before use: path components are stripped (/etc/passwd.csv → passwd.csv), the character set is limited to letters/digits/._-, and only the following extensions are accepted: .csv, .tsv, .xlsx, .xls, .json, .geojson, .gpkg, .fgb, .zip, .kml, .kmz. Anything else returns 400 VALIDATION_ERROR. Omit the field entirely if you want the worker to auto-detect.

Attaching a supplier

There are two ways to associate the upload's locations with a supplier (see Suppliers):

  • Per-row supplier columns in the file itself (supplier_name, supplierid, supplier_address, supplier_country, supplier_statement) — each row can carry its own supplier.
  • An upload-wide supplier object on the request body — applied to every location in the file as the fallback supplier (per-row columns win where present). This is the recommended way when one file represents a single supplier, and the only reliable way for shapefiles, whose DBF attribute names are truncated to 10 characters so the longer supplier_* columns can't be matched.
{
  "location_type": "plot",
  "commodity_type": "coffee",
  "metrics": ["deforestation"],
  "supplier": {
    "name": "Café Verde Cooperativa",   // required when the object is present
    "external_id": "CV-001",             // your caller-side identifier
    "address_line1": "Rua do Café 100",
    "city": "Patrocínio",
    "region": "MG",
    "postcode": "38740-000",
    "country": "BR",                     // ISO 3166-1 alpha-2 (validated)
    "statement": "100% smallholder, deforestation-free."
  }
}

The supplier object drives the same supplier registry as the per-row columns — it creates (or reuses, by dedup) one supplier and links every staged location in the upload to it. See Suppliers for the field reference and dedup rules.

ZIP-wrapped formats (.zip shapefile bundles, .kmz, .gpkg.zip) must be uploaded with an explicit filename — they all share the same magic bytes as .xlsx, so auto-detection will route them to the XLSX parser and the task will fail.

Response (201):

{
  "data": {
    "type": "asset_task",
    "id": "8a1b…",
    "attributes": {
      "overall_status": "pending",
      "source_file": "q1-facilities.csv",
      "name": "Q1 facilities — APAC",
      "description": "First-quarter facility refresh for the APAC region.",
      "location_type": "facility",
      "upload_url": "https://storage.googleapis.com/.../X-Goog-Signature=…",
      "expires_at": "2026-04-29T21:30:00.000Z",
      "created_at": "2026-04-29T21:15:00.000Z"
    },
    "links": { "self": "/ingestions/assets/8a1b…" }
  }
}

source_file is the filename you supplied, or the literal string upload when omitted. name, description, and location_type are echoed back here and on every subsequent task read — see Reading the task back. The signed URL expires in 15 minutes. The task is in pending and has not been dispatched — uploading the file is what triggers it.

2. PUT the file bytes to upload_url. The required headers are advertised on the response's meta.client_actions[0].headers — mirror them verbatim, since they are bound into the v4 signature and GCS rejects the PUT if any value differs:

PUT https://storage.googleapis.com/...
Content-Type: application/octet-stream
x-goog-content-length-range: 0,104857600
x-goog-if-generation-match: 0

<binary file bytes>

x-goog-if-generation-match: 0 makes the signed URL effectively single-use: a second PUT to the same URL gets 412 Precondition Failed from GCS, since the object now exists at that path. Mint a fresh URL if you need to retry with different bytes.

Storage acknowledges with 200 OK. No API call is needed after this — a finalize event fires server-side and dispatches the ingestion task.

3. Poll task status the same way as before:

GET /ingestions/{id}

Statuses progress: pending → processing → staged → completed.

An upload that requests no metrics stops at staged, and staged is its final state. The file is validated, profiled and staged, and nothing else runs until you ask for it. To run metrics on the same upload later, send PUT /ingestions/assets with the task id and metrics: the task moves from staged to processing, then to completed. The task's run_kind is screening until metrics are requested, and full after.

Reading the task back

GET /ingestions/{id} returns the task with the labels you set at creation:

{
  "data": {
    "type": "asset_task",
    "id": "8a1b…",
    "attributes": {
      "organization_id": "…",
      "type": "asset_upload",
      "name": "Q1 facilities — APAC",
      "description": "First-quarter facility refresh for the APAC region.",
      "location_type": "facility",
      "overall_status": "processing",
      "source_file": "q1-facilities.csv",
      "created_at": "2026-04-29T21:15:00.000Z",
      "updated_at": "2026-04-29T21:16:10.000Z",
      "tags": ["batch-q1"]
    }
  }
}

The same name, description, and location_type attributes appear on every item of the task list (GET /ingestions). location_type is also a filter:

GET /ingestions?filter[location_type]=facility

Allowed values are facility, plot, and shed.

The location_type returned here is reconciled to the type actually detected in the uploaded file once the task reaches staged, so it may differ from the value you requested if the file's contents were classified differently.

Once the task reaches staged, GET /ingestions/{id}/summary also carries supplier_partition: how many suppliers the upload describes, on what basis, and the per-cluster plot counts, as validation partitioned the file. It is the same block POST /ingestions/profile quoted from (see File profiling), recomputed on the same bytes at ingest, and every staged row carries the supplier_unit it was put in. A file that names its suppliers in a recognised column is linked on that column regardless; the partition is the identity a row has when the file gives it none.

A geographic group is split further by commodity and by country of production, because one screening batch and one Due Diligence Statement each carry one of each. partition says which cut produced the units. It is travel_time when the file was refined before ingestion. The file you upload is rarely byte-identical to the file you profiled, because you drop units and stamp fields onto the rows you keep. Validation therefore finds that partition by the parcels' geometries rather than only by the content fingerprint of the bytes, so the refined quote and the ingestion name the same units. Every parcel must come from one profiled pass: a parcel that pass never saw makes the whole upload fall back to the straight-line cut, and partition is then straight_line. A file never refined is committed on the straight-line cut too.

"supplier_partition": {
  "basis": "geometry_clusters",
  "partition": "travel_time",
  "window_minutes": 120,
  "split_by": ["commodity", "country"],
  "supplier_count": 2,
  "site_supplier_count": 0,
  "cluster_supplier_count": 2,
  "cut_km": 50.0,
  "clusters": [
    {"unit": "8f2c1a04e7b3", "plot_count": 178, "centroid": [-74.6977, -11.2533],
     "extent_km": 73.0, "commodity": "cocoa", "country": "Peru", "travel_unit": 1},
    {"unit": "b91d40c6aa17", "plot_count": 17, "centroid": [-73.6979, -11.2798],
     "extent_km": 2.6, "commodity": "coffee", "country": "Peru", "travel_unit": 1}
  ],
  "source": "validation"
}

A metrics run screens each of these units in its own batch, so a failed batch fails only that unit's rows. See One unit, one batch.

Why this flow

  • One client request to the API; the bytes never pass through it.
  • The signed URL is scoped to one object path with a short TTL, so it is safe to hand to less-trusted clients.
  • Failures during upload (network drops, expired URL) leave a pending task that times out cleanly — no half-ingested file.

Supported formats

CSV, GeoJSON / NDJSON, Parquet, GeoPackage, Shapefile (ZIP), .xlsx, .xls. Up to 100 MB.

Required scopes

ingestions:write on the API key, identical to the multipart flow.


Multipart upload (deprecated)

Deprecated. Existing integrations continue to work. Responses now carry Deprecation: true and a Link: …; rel="successor-version" header pointing at this same endpoint. Plan to migrate to the JSON / signed-URL flow above.

Stream the file in a single request as multipart/form-data:

POST /ingestions/assets
Content-Type: multipart/form-data; boundary=…

file=<binary>
location_type=facility
commodity_type=palm
metrics=deforestation
tags=batch-q1
end_date=2099-01-01
name=Q1 facilities — APAC
description=First-quarter facility refresh for the APAC region.
supplier={"name":"Café Verde Cooperativa","external_id":"CV-001","country":"BR"}

On the multipart transport commodity_column is a plain text form field, sent at most once (a duplicate or empty value is 400 INVALID_COMMODITY_COLUMN). The supplier field (and assessments / metric_options) is a JSON-stringified object — the same shape as the JSON flow, encoded as a single form field. name and description are plain text form fields with the same limits and derivation behaviour as the JSON flow.

tags accepts plain labels (batch-q1) and per-row column templates — a tag with a {Column Header} placeholder is expanded against each row's cell, e.g. tags=partno:{Part Number} tags every row partno:<its Part Number>. See the Tags guide for namespaces, grouping, and filtering.

The response shape matches the legacy contract — same asset_task resource (including name, description, and location_type), but without the upload_url / expires_at attributes since the bytes are already received.


Errors

StatusCodeCause
400VALIDATION_ERRORMissing or malformed JSON body field on the signed-URL flow.
411MISSING_CONTENT_LENGTHMultipart only — Content-Length header required.
413PAYLOAD_TOO_LARGEFile exceeds 100 MB.
415UNSUPPORTED_MEDIA_TYPEMultipart: file type not in the supported list. JSON: signed-URL flow not enabled in this environment.