Ingest Assets
Create an asset ingestion task and receive a short-lived signed upload URL the client uses to PUT the file directly to object storage.
Recommended for new integrations. The legacy multipart upload variant is documented at Ingest Assets Multipart and continues to work, but emits
Deprecation: trueand aLink: …; rel="successor-version"response header.
▶How it works
- Request the URL —
POST /ingestions/assetswith a JSON body describing the asset. No bytes yet, and nofilename/content_type— those are resolved from the uploaded object once storage finalises. The response carries anupload_url(valid for 15 minutes) and a taskidinpending. - Upload the bytes —
PUTthe raw file toupload_urlwithContent-Type: application/octet-stream. The signed URL is single-object, single-shot, and scoped to that exact path. - Storage finalises — when GCS confirms the upload, an
OBJECT_FINALIZEevent triggers an internal webhook that dispatches the ingestion task and records the real filename / MIME type on the task. No further client call is needed. - Poll —
GET /ingestions/{id}reports task progress:pending→processing→staged→completed.
▶Request body
| Field | Type | Required | Notes |
|---|---|---|---|
location_type | enum | Yes | facility, plot, or shed. Promoted to a first-class task attribute and returned on every read. After processing it is reconciled to the type actually detected in the file, so the value read back may differ from the one requested. |
commodity_type | string | No | cattle, cocoa, coffee, palm, rubber, soy, timber, shrimp. Common aliases (e.g. palm oil / oil palm → palm) are accepted and normalised to the canonical value on ingest. When omitted, the commodity is auto-detected per location during validation and surfaced as commodity on the results (locations + assessments). |
name | string | No | Human-friendly task name, ≤ 255 chars. Returned on every task read. When omitted it is derived from tags (joined with , ), falling back to the filename. Supply it to override the derivation. |
description | string | No | Free-form notes, ≤ 2000 chars. Never derived — null unless you set it. |
metrics | string[] | No | Subset of deforestation, emissions, biodiversity, water. Unique values only. |
tags | string[] | No | Tags for the upload. Each ≤ 100 chars. A tag with no placeholder applies to every location in the file; a tag embedding a {Column Header} placeholder is expanded per row from that column's cell value. See Per-row tags from columns below. |
end_date | string | No | YYYY-MM-DD. Must be Dec 31 of a prior year, or any date in the current year up to today (UTC). |
force | boolean | No | Bypass duplicate-upload rejection. Defaults to false. See Duplicate uploads below. |
assessments | object | No | Per-group toggles for the assessments persisted on this upload. See Enabling assessments below. Omit to run the default — core only. |
create_artifact | boolean | No | Fast path. When true, the ingestion worker auto-generates a GeoParquet artifact scoped to exactly this upload once it finishes processing — no second call. See Fast path below. Requires metrics to include at least one metric. |
signed_url_ttl_seconds | number | No | Lifetime (seconds, 1–86400) for the artifact dashboard signed URL surfaced on the single-task read. Only valid alongside create_artifact: true (otherwise 400). See Fast path below. |
create_audit_trail | boolean | No | Fast path. When true, the ingestion worker auto-creates an EUDR audit-trail record from this upload once it finishes processing — no second call. See Fast path below. Requires metrics to include deforestation. |
audit_trail_operator | object | No | Operator block stamped onto the auto-created audit-trail record. Same shape as the operator block on POST /regulations/eudr/audit-trail. Only valid when create_audit_trail: true (otherwise 400). When omitted, the record falls back to the org's default operator. |
audit_trail_commodity | object | No | Commodity block stamped onto the auto-created audit-trail record. Same shape as the commodity block on POST /regulations/eudr/audit-trail; hs_code may be omitted; it is never defaulted and a blank one blocks submission. Only valid when create_audit_trail: true (otherwise 400). When omitted, the record stores a NULL commodity block (set later via PATCH). |
▶Per-row tags from columns
A plain tag applies the same label to every location in the upload. A tag
that embeds a {Column Header} placeholder instead becomes a per-row
tag: the placeholder is replaced with that row's cell value for the named
column. This is the way to carry a per-location identifier (part number,
internal site id, sales region, …) through ingestion so you can later filter
or group locations by it.
Write the tag as namespace:{Column Header} — the text before the first :
is the namespace (exactly as for a static namespaced tag), and the
brace-delimited column header supplies the value:
"tags": ["part_number:{Part Number}", "region:{Sales Region}"]
Given a row where the Part Number cell is 12345 and Sales Region is
EMEA, that row's location is tagged part_number:12345 and region:EMEA.
Behaviour:
- Comma-split into multiple values — a placeholder cell is split on
commas, so one row can carry several values under the same namespace: a
Part Numbercell of1,2,3withpart_number:{Part Number}tags that rowpart_number:1,part_number:2, andpart_number:3. Each part is trimmed; blank parts (e.g. from1,,2) are skipped. - Missing column / blank cell is skipped — a row with no cell for the referenced column (or a blank one) gets no tag from that template; other rows and tags are unaffected.
- Namespace — as with any tag, the namespace is the text before the first
:and is normalised to lowercasesnake_casewhen stored for grouping; the substituted value keeps its original case. - Column-name matching is space-insensitive — a
{Part Number}placeholder matches aPart Numberheader in the file. - The per-row tags a template produces count against the normal tag limits.
The resulting tags behave like any other tag — they propagate to the row's
location, are returned in the asset's tags array, and can be renamed in
bulk with PATCH /tags/namespaces/:namespace.
▶Fast path: auto-create an audit trail
Set create_audit_trail: true to chain ingestion → deforestation →
EUDR audit-trail record in a single upload. Once the task reaches a
successful terminal (completed / completed_with_errors), the worker
dispatches the same create task as
POST /regulations/eudr/audit-trail,
scoped to this upload's plots (filter: assets.task_id == <this task>).
metricsmust includedeforestation(validated — a request without it returns400), so the created record snapshots the deforestation signal rather than being empty.- Operator + commodity: supply
audit_trail_operatorand/oraudit_trail_commodityto stamp them onto the created record. Both are optional:audit_trail_operatoromitted → the record uses the org's default operator (resolved live at read time).audit_trail_commodityomitted → the record stores a NULL commodity block; set it viaPATCH /regulations/eudr/audit-trail/:recordIdbefore submitting (thecommodity_missingblocker enforces this). When supplied, itshs_codemay be omitted; it is never defaulted, and a blank one blocks submission.- Both are only accepted alongside
create_audit_trail: true; sending either without it returns400.
- Auto-split still applies: one record per
(country_code, commodity). A suppliedaudit_trail_commodityblock is applied only when the upload resolves to a single(country_code, commodity)group; multi-group uploads store a NULL commodity on every record (populate each viaPATCH). - Poll this task; once it carries
attributes.audit_trail_task_id, poll that audit-trail task for the created record id. A failed ingestion creates nothing. - Split flow: the same opt-in is available on Process Ingestion Assets (PUT), so you can upload first, review, then create the audit trail when you kick off deforestation via the reprocess. See that endpoint's Fast path section.
▶Fast path: auto-create an artifact
Set create_artifact: true to chain ingestion → metric processing →
a GeoParquet artifact of just this upload in a single request — no
second POST /artifacts call, and no filter to author. Once the task
reaches a non-failed terminal state (completed /
completed_with_errors), the worker auto-dispatches a generateArtifact
task scoped to this upload (filter: assets.task_id == <this task>), so
the bundle contains precisely the locations from this upload and nothing
else. See the Artifacts endpoint docs for
what the bundle contains and how to consume it.
metricsmust include at least one metric (validated — a request without any returns400), so the exported artifact carries metric/assessment slices rather than being empty.- It fires once. The dispatch is guarded, so a retried or re-run upload does not mint a second artifact — if generation is retried, the same artifact is reused.
- Follow it via the task. Poll this ingestion task; once it carries
attributes.artifact_id, fetch the artifact at/v1/artifacts/:id(or list by scope —GET /artifacts?filter[assets.task_id]=<this task>). A failed ingestion creates nothing. - Downloads are not pre-minted. The fast path creates and generates
the artifact; it does not pre-mint download URLs. Once the artifact is
completed, callPOST /artifacts/:id/signed-urls(or open the dashboard link) exactly as for an on-demand artifact. - Optional dashboard link. Set
signed_url_ttl_seconds(1–86400) alongsidecreate_artifact: trueto have the single-taskGET /ingestions/{id}surface a freshly-mintedattributes.artifact_dashboard_url+attributes.artifact_signed_url_expires_atonce the artifact succeeds. The URL is minted on read, never stored. Sendingsigned_url_ttl_secondswithoutcreate_artifact: truereturns400.
POST /ingestions/assets
Content-Type: application/json
{
"location_type": "facility",
"commodity_type": "palm",
"metrics": ["deforestation"],
"create_artifact": true,
"signed_url_ttl_seconds": 3600
}
▶Enabling assessments
Ingestion is for ingestion-scope assessment groups: core, hazard, and infrastructure. EUDR-scope assessments (art9, art10) belong to a DDS submission, not an upload, and are driven on the DDS create path — see Assessments.
Each include_* flag is independent — set the ones you want and leave the rest off:
| Flag | Default | Persists |
|---|---|---|
include_core | false | The core validation row — confidence score + level + plot- and facility-validation flags. |
include_hazard | false | Natural-hazard risk score / level row. |
include_infrastructure | false | Infrastructure nearest-feature enrichments — nearest_water_km / nearest_port_km / nearest_road_km plus port metadata (subject to the access checks below). |
Normalisation always runs regardless of these flags — that is what stages the location itself (parsed coordinates, geocoding, commodity detection). The flags only govern which validation results are persisted alongside it.
Migration note: The
include_eudr/include_art9/include_art10flags previously accepted here are no longer wired up on ingestion. Stale callers can keep sending them — the API silently strips the keys rather than returning400— but no EUDR-scope row will be persisted. Move EUDR-scope opt-in to the DDS create path.
See docs/assessment-fields.md for the field-by-field reference of every key each group can carry, and Assessments for the readiness check / fill-missing endpoints used during the DDS workflow.
▶Default (omit the block)
Omit assessments to run with the defaults: no validation results are persisted. Normalisation still runs and stages the location. This is the right shape for tagging or metric processing flows that don't need risk scoring or core confidence flags.
POST /ingestions/assets
Content-Type: application/json
{
"filename": "supply_chain_data.geojson",
"content_type": "application/geo+json",
"location_type": "facility",
"commodity_type": "palm"
}
After the upload finalises, the resulting locations exist but carry no validation results. The core, hazard, and infrastructure slots will be null in the response. EUDR-scope assessments (art9, art10) are not surfaced on this response shape at all — they are exposed through the audit-trail record (GET /regulations/eudr/audit-trail/:recordId/plots). Set include_core: true to opt in to the core confidence/flags assessment.
▶Top-level flags
| Field | Type | Default | Description |
|---|---|---|---|
include_core | boolean | false | Persist the core validation row (confidence + flags) and pivot it to your org. Set true to opt in. Honoured at upload time on POST /ingestions/assets and post-hoc on PUT /ingestions/assets (filter-scoped). Both paths share the same cache-aware runner — a covering core row short-circuits to a pivot insert. |
include_hazard | boolean | false | Run natural-hazard risk scoring. Persists the hazard validation row pivoted to your org. |
include_infrastructure | boolean | false | Run infrastructure nearest-feature enrichments — adds nearest_water_km / nearest_port_km / nearest_road_km plus port metadata (port_road_km, nearest_port_name / nearest_port_address, GeoJSON nearest_port_point / nearest_water_point), subject to the access checks below. Persists the infrastructure validation row pivoted to your org. |
▶Access checks (check_*)
check_*)The check_* flags are cross-cutting toggles that govern the water / port / road access checks. They are recorded on both core and infrastructure rows, because the same assessment call produces:
core— boolean access flags:water_access,port_proximity,road_access.infrastructure— numeric distances in km:nearest_water_km,nearest_port_km,nearest_road_km.
Because they are shared, the request flags live at the top level of the assessments block — not nested under any group.
| Field | Type | Default | Drives (in core) | Drives (in infrastructure) |
|---|---|---|---|---|
check_water_access | boolean | false | water_access boolean | nearest_water_km |
check_port_proximity | boolean | false | port_proximity boolean | nearest_port_km |
check_road_access | boolean | false | road_access boolean | nearest_road_km |
Each row records the check_* snapshot on its sub_options column, so a later request that asks for an access check not covered by the cached row is treated as a cache miss and refreshes both groups.
▶Opting in to core
coreinclude_core: true writes the core assessment, scoped to your org — confidence score, level, and plot-validation flags persisted. Normalisation runs either way (the location is staged), and the other ingestion-scope add-ons are independent. Use this when:
- You want confidence scoring or geometric validation flags surfaced on the asset response.
- You need the
corerow available for later DDS readiness / fill-missing flows. - You're on a tier or contract that includes core scoring.
POST /ingestions/assets
Content-Type: application/json
{
"filename": "supply_chain_data.geojson",
"content_type": "application/geo+json",
"location_type": "facility",
"commodity_type": "palm",
"assessments": {
"include_core": true,
"include_hazard": true,
"include_infrastructure": true
}
}
Above: the core, hazard, and infrastructure rows are persisted. EUDR-scope rows are written by the DDS create path and are not surfaced on this response (see GET /regulations/eudr/audit-trail/:recordId/plots).
▶Disabling assessments
There is no separate run flag. To disable any group, leave its include_* flag absent (the default for every group, including core) or set it to false. Sub-options on a group whose include_* is false are ignored.
▶Resolution rules
| Body shape | Effective groups persisted |
|---|---|
No assessments block | None — location staged only |
"assessments": {} | None — location staged only |
"assessments": { "include_core": true } | core only |
"assessments": { "include_hazard": true } | hazard only |
"assessments": { "include_infrastructure": true, "check_water_access": true } | infrastructure group with nearest_water_km populated |
"assessments": { "include_core": true, "include_hazard": true, "include_infrastructure": true } | core + hazard + infrastructure |
"assessments": { "include_eudr": true } | None — EUDR-scope keys are silently stripped on ingestion; use the DDS create path |
▶Adding assessments later
If you uploaded without an assessments block (or with a smaller subset) and want to fill in missing add-ons later, use Process Ingestion Assets (PUT /ingestions/assets) with the same assessments block. The runner is cache-aware — assessments any org has already produced for the same location are reused, granting your org visibility instead of making a fresh assessment call.
▶Duplicate uploads
To avoid re-running the same work, an upload is rejected as a duplicate when its file content and its processing parameters match a task that has already completed for your organization (or a child org).
- What counts as identical: the file bytes (content hash) plus
location_type,commodity_type,metrics, andassessments/ metric options. Labelling fields —name,description,tags, supplier info — are not part of the match, so re-uploading the same file just to change a tag is still treated as a duplicate. end_date: matched separately. When you set anend_date, only a prior completed task with the sameend_datecounts as a duplicate. When you omit it, the upload is considered a duplicate only of another open-ended task completed in the last 14 days — after that window a re-upload is allowed so it re-runs against fresher data.- Only completed tasks block: a task still
pending/processing/stageddoes not trigger rejection.
On the signed-URL flow the duplicate is detected once the bytes finalise,
so the task is marked failed with duplicate_of_asset_task_id pointing
at the original — poll GET /ingestions/{id} to see it. (The multipart
variant rejects synchronously with 409.)
▶Overriding with force
forceSet "force": true to bypass the check and process the upload anyway —
e.g. to deliberately re-run the same file, or re-ingest the same locations
under a different tag:
POST /ingestions/assets
Content-Type: application/json
{
"filename": "supply_chain_data.geojson",
"location_type": "facility",
"commodity_type": "palm",
"metrics": ["deforestation"],
"force": true
}
▶Why this flow
- One client request to the API; the file bytes never pass through it.
- The signed URL is scoped to one object path with a 15-minute TTL — safe to hand to less-trusted clients (MCP, browser).
- A failed or expired upload leaves a
pendingtask that times out cleanly, no half-ingested file.
▶Supported file formats
CSV, GeoJSON / NDJSON, Parquet, GeoPackage, Shapefile (.zip), Excel
(.xlsx/.xls). Up to 100 MB.
▶Errors
| Status | Code | Cause |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing/malformed JSON field; unknown or duplicate metrics; create_artifact: true without at least one metrics entry; signed_url_ttl_seconds set without create_artifact: true. |
| 401 | MISSING_API_KEY / INVALID_API_KEY | Auth missing or invalid. |
| 403 | FORBIDDEN | API key lacks ingestions:write. |
| 409 | CONFLICT | Duplicate upload — identical file + parameters already processed. The original task id is in data.details.duplicate_of_asset_task_id. Multipart only; the signed-URL flow surfaces this asynchronously as a failed task. Pass force: true to override. See Duplicate uploads. |
| 415 | UNSUPPORTED_MEDIA_TYPE | Signed-URL flow not enabled in this environment. |
Required scope:
ingestions:write