Create Artifact
Create a new artifact.
Returns 202 Accepted with the artifact ID and current status. The artifact starts in queued, moves through processing, and finishes in one of completed, failed, or cancelled. Poll GET /artifacts/:id or subscribe to the artifact.completed / artifact.failed webhooks to observe the transition.
See the Artifacts overview for the lifecycle, manifest structure, and webhook payloads.
Scope model (current limitations)
An artifact must be scoped by exactly one filter — there is no "whole-org" export, and arbitrary/compound filters are not currently supported. The request must contain a single
filtersentry, usingcondition: "eq"and a single value, with one of these attributes:
Attribute Value Scopes to assets.task_id(or aliasasset_task_id)task UUID One ingestion/upload (the assetTaskIdreturned by the ingestion endpoints).facility_idfacility id (text) One facility and everything beneath it. location_idfacility / shed / plot public id One location and everything beneath it. supplier_idsupplier UUID A supplier's locations and everything beneath them. namelocation normalised name Locations whose normalised name matches, and everything beneath them. Not currently supported (returns
400): more than one filter,filterOverrides, conditions other thaneq, list/array values (in), and any attribute not in the table above. These were intentionally removed while the export pipeline was reworked to run indexed, scope-resolved queries; broader filtering is a planned follow-up.
▶Scope to a single upload (recommended default)
The most common artifact is one scoped to a single ingestion/upload — the same shape the upload fast-path generates automatically. Filter on assets.task_id, passing the ingestion task id (the assetTaskId returned by the ingestion endpoints):
{
"type": "relational_full",
"format": "geoparquet",
"filters": [
{ "attribute": "assets.task_id", "condition": "eq", "value": "<ingestion-task-uuid>" }
]
}
A task scope is anchored on the upload itself, so the artifact contains exactly that upload's facility / shed / plots, its single asset row, and the latest metric and per-plot/per-shed assessment for each entity. A staged location shared with another upload does not drag in that other upload's rows.
▶Scope to a facility, location, supplier, or name
The other scopes resolve to a seed location set and then expand down the hierarchy (facility → shed → plot), so the artifact includes the facility plus all of its sheds and their plots:
{ "type": "relational_full", "format": "geoparquet",
"filters": [ { "attribute": "facility_id", "condition": "eq", "value": "<facility-id>" } ] }
{ "type": "relational_full", "format": "geoparquet",
"filters": [ { "attribute": "supplier_id", "condition": "eq", "value": "<supplier-uuid>" } ] }
{ "type": "relational_full", "format": "geoparquet",
"filters": [ { "attribute": "name", "condition": "eq", "value": "Acme Cocoa Coop" } ] }
Unlike the task scope, these are entity-scoped, not upload-scoped: a location uploaded several times contributes every distinct asset row across those uploads. The name match is on the location's normalised name (case- and whitespace-insensitive).
▶What the artifact contains
- locations / facilities / sheds / plots — every location in scope (after hierarchy expansion).
- metrics — the latest validation per
(location, assessment_type). - plot_/shed_ assessments (
plot_for,shed_for, …) — the latest assessment per plot/shed (1:1 with the plots/sheds in scope). - plot_agg_* — facility-level rollups for in-scope facilities.
▶Request body
| Field | Type | Required | Description |
|---|---|---|---|
type | enum | no | Bundle shape. Only "relational_full" is accepted today. Defaults to "relational_full". |
format | enum | no | Output format. Only "geoparquet" is accepted today. Defaults to "geoparquet". |
filters | array | yes | Exactly one scope filter (see the scope table above). |
partitioning | object | null | no | Output partitioning. null (default) emits a single bundle. Hive partitioning is reserved for a future release. |
idempotencyKey | string | no | 1–255 chars. Same key + same request body → the existing artifact is returned with idempotent: true. Same key + different body → 409. |
filterOverrides is not accepted — supplying it returns 400 filter_overrides_unsupported.
▶filters[] item (exactly one)
filters[] item (exactly one)| Field | Type | Description |
|---|---|---|
attribute | string | One of assets.task_id, asset_task_id, facility_id, location_id, supplier_id, name. |
condition | enum | Must be "eq". |
value | string | A single value. assets.task_id/asset_task_id/supplier_id must be a UUID; the others are free-text ids/names. Arrays are not accepted. |
▶Headers
Content-Type: application/json— required.Idempotency-Key— optional. Recommended primary mechanism for idempotency, matching Stripe / GitHub convention. The body fieldidempotencyKeyis a fallback for clients that cannot set headers.
▶Idempotency-Key precedence
When both forms are supplied, the resolution rule is:
- If
Idempotency-Keyheader is present, non-empty (after trim), and ≤ 255 chars → use the header. - Otherwise → use the body field
idempotencyKeyif present and non-empty. - Otherwise → no idempotency key; treat as a fresh create.
An over-length header value (> 255 chars) falls back to the body field rather than failing the request, so a malformed header on a retry path does not silently downgrade to a new artifact create.
▶Idempotency
Idempotency is scoped per organization — a parent caller does not share a namespace with their child orgs. After an artifact terminates in failed or cancelled, the same key is free for reuse and a retry produces a brand-new artifact.
| Outcome | Status | Behaviour |
|---|---|---|
| New key | 202 | New artifact created, attributes.idempotent: false. |
| Same key, same body | 202 | Existing artifact returned, attributes.idempotent: true. The artifact's current status is reported (it may already be processing, completed, etc.). |
| Same key, different body | 409 | idempotency_key_conflict. Pick a new key or wait for the in-flight artifact to terminate. |
Match is based on type, format, filters, and partitioning. The idempotencyKey itself and whitespace / key-ordering differences in the JSON body do not affect the match.
▶Response schema (data)
data)| Field | Type | Description |
|---|---|---|
type | "create_artifact" | Always "create_artifact". |
id | uuid | Artifact ID. Also exposed as attributes.jobId for clients that key off job IDs. |
▶attributes
attributes| Field | Type | Description |
|---|---|---|
jobId | uuid | Mirrors id. Use either to poll status. |
status | enum | Current artifact status. Almost always "queued" on a fresh create; on an idempotency replay it reflects the existing artifact's current state. |
retryAfterSeconds | int > 0 | Suggested poll interval. Currently 30. |
idempotent | bool | true when this response was served from an idempotency replay; false for a fresh create. |
▶Location header
Location headerEvery 202 carries a Location: /v1/artifacts/:id header so clients can resolve the artifact without parsing the body.
▶Status codes
202— Accepted. The artifact has been created (or this is an idempotency replay of an earlier accept).400— Validation failure. Common scope errors:scope_filter_required— zero or more than one filter supplied.unsupported_scope_filter— attribute not in the scope table, a non-eqcondition, a non-UUID value for a UUID scope, or a list value.filter_overrides_unsupported—filterOverrideswas supplied.empty_result_set— the scope matched no locations in scope.
401— Missing or invalid API key.403— Caller lacks theartifacts:writescope.409—idempotency_key_conflict.502—task_dispatch_failed. Retry the request.
See error-codes.md for the error envelope shape.
Required scope:
artifacts:write