Query Parameters
List endpoints in the API support pagination, filtering, sparse field selection, and relationship includes via query parameters. This page describes the shared conventions; each endpoint's documentation lists which specific filters, fields, and includes it accepts.
▶Pagination
List endpoints support two pagination modes: page-number (offset) and cursor (keyset). They share the same page_size knob and produce the same response envelope — the only difference is which parameter you pass to advance.
| Parameter | Type | Default | Description |
|---|---|---|---|
page[number] | integer | 1 | 1-based page index (page-number / offset mode). |
page[size] | integer | 100 | Items per page. Maximum varies by endpoint (typically 500). |
page[cursor] | string | — | Opaque cursor for keyset pagination. When set, page[number] is ignored. Pass the value you received in meta.next_cursor. |
The flat page / page_size keys are also accepted for backwards compatibility.
▶Choosing a mode
Default to cursor mode for anything that's not a small, paginated UI list. Cursor (keyset) mode is dramatically faster at scale:
| Endpoint shape | Page-number | Cursor | Speedup |
|---|---|---|---|
/metrics/<type>/<metric> over 1M plots | 60-100s | 50-200ms | ~500× |
/metrics/<type>/<metric>?filter[location_id]=… (drill) | 18-27s | 0.8-1.4s | ~20× |
/locations/{type} over 1M plots | 2-50s | 40-400ms | 10-1000× |
/locations/{type}?filter[tags]=… | 4-10s | 200-400ms | ~20× |
The numbers are from a 6M-plot stress benchmark — your mileage will vary, but the curve is the same: cursor is O(page_size); page-number scales with how deep you've paged (skipping to a deep page gets slower the further in you go).
- Page-number is fine for small result sets and known-shallow paging. The server computes a
totalso UIs can render "page X of Y". - Cursor (keyset) is the right choice for large result sets, deep pagination, full-dataset walks, and any list endpoint backing a non-UI integration. Each request is O(
page_size) regardless of how far you've paged — page 10 and page 10,000 take the same time. The server skips the total-count step entirely, someta.totalisnullon cursor responses — usemeta.has_next/meta.next_cursorto traverse.
You opt into cursor mode by passing ?page[cursor]= (empty value = first cursor page; subsequent calls echo back the value from meta.next_cursor). Page-number responses never include meta.next_cursor — switching modes mid-traversal requires re-issuing the request with ?page[cursor]= (the server doesn't auto-promote between modes).
▶Sort order
List endpoints return rows in lexicographic order by location id, ascending, on both pagination modes. This is stable and identical between page-number and cursor traversal — switching modes (or even refreshing the same page) returns results in the same order.
Applies to /locations/{facilities,sheds,plots} and /metrics/<type>/<metric> (the "latest per location" shape). Both use the same location-id ascending sort.
The untyped mixed /locations listing and the streaming endpoint still sort by creation time (newest first), then by location id, for backwards-compat with cursor-stream callers. If you need rows sorted by monitoring_end (newest data first), call the per-metric /history endpoint with filters or sort client-side after fetching a page.
▶Client-controlled sort — ?sort=
?sort=Some list endpoints accept a JSON:API-style sort parameter to override the default order:
?sort=-noncompliance_plot_count # descending
?sort=created_at # ascending
- A leading
-means descending; otherwise ascending. - NULLs are ordered by direction: DESC → NULLS LAST, ASC → NULLS FIRST — unless a key documents an override (e.g.
GET /ingestionssortsnameNULLS LAST in both directions, so unnamed tasks always sort last). - The order is always made total by appending the resource id as the final tie-break, so pagination (offset and cursor) is stable.
- The sort key is added to the cursor payload, so
?sort=…composes withpage[cursor]=— keyset pagination stays correct at any depth. - Only the keys listed below are accepted per endpoint. Any other key returns
400(the same shape as an unsupported filter), and a?sort=on an endpoint that doesn't list it is rejected too. - Omitting
sortkeeps each endpoint's default order (above) — fully backward compatible.
Allowed sort keys by endpoint
| Endpoint | Sortable keys | Notes |
|---|---|---|
GET /ingestions | created_at (default), name, source_file, overall_status, id | created_at is organization-composite-backed (both directions) and id is primary-key-backed — O(page_size) at any depth, including keyset cursor. name orders NULLs last (unnamed tasks sort last) in both directions; ascending ?sort=name is org-composite-backed, but descending ?sort=-name is not — the NULLS LAST placement flips to NULLS FIRST on a backward index scan, so the planner falls back to a sort. source_file, overall_status, and ?sort=-name are accepted but not org-index-backed, so they cost a sort over your org's task count (fine at typical volumes, slower for very large orgs). |
GET /ingestions/assets, GET /ingestions/assets/:id | created_at, status, source_file, legality_risk_level, article10_risk_level | Per-record (asset entity) lists. created_at/status live on the per-org pivot; source_file orders NULLs last. The two risk keys sort by the severity rank of the headline level promoted onto the assessments relationship (low < medium < high; see the risk-level filter section below) — unassessed records order last in both directions, and the rank is a per-row correlated subquery (not index-backed), so expect a sort over your org's record set. |
GET /locations/{facilities|sheds|plots} | id, created_at, legality_risk_level, article10_risk_level | Typed routes only. created_at is the per-org earliest-link timestamp (MIN over your org's pivot rows). ⚠️ It is not index-backed — sorting by it costs O(entities-in-org-of-that-type): fine for facilities/sheds, but on /locations/plots it scales with your org's plot count (seconds for very large orgs). id stays O(page_size). The two risk keys sort by headline-level severity rank exactly as on the assets endpoint (unassessed locations last in both directions; same non-index-backed cost profile as created_at). |
GET /locations, GET /locations/commodity/:commodity | id | Untyped listings: created_at and the risk keys are not offered here (returns 400) — they need a typed route. |
GET /metrics/facilities/deforestation (+ /history, + /:commodity) | noncompliance_plot_count, plot_count | Facility-aggregation compliance ranking ("most-deforested facilities first" = -noncompliance_plot_count). Other metric routes/families do not accept these keys (400). |
Sorting metric time fields (
monitoring_end_date, etc.) and other location/metric attributes is not yet supported — those keys return400until backing indexes (or denormalised sort columns) land. See the engineering follow-ups.
▶Response metadata
// Page-number mode — request: `?page[number]=1&page[size]=100`
{
"meta": {
"page": 1,
"page_size": 100,
"total": 1250,
"has_next": true,
"total_is_approx": false
},
"links": {
"self": "/locations/facilities?page[number]=1&page[size]=100",
"next": "/locations/facilities?page[number]=2&page[size]=100"
},
"data": [...]
}
// Cursor (keyset) mode — request: `?page[size]=100&page[cursor]=`
{
"meta": {
"page": 1,
"page_size": 100,
"total": null,
"has_next": true,
"next_cursor": "550e8400-e29b-41d4-a716-446655440000"
},
"links": {
"self": "/locations/facilities?page[size]=100&page[cursor]=",
"next": "/locations/facilities?page[cursor]=550e8400-e29b-41d4-a716-446655440000&page[size]=100"
},
"data": [...]
}
| Field | Type | Description |
|---|---|---|
meta.page | integer | Current page number (always 1 in cursor mode — the value is informational). |
meta.page_size | integer | Page size used for this request. |
meta.total | integer | null | Total matching items in page-number mode. In approx-count mode (default) this is capped — see meta[count]. Always null in cursor mode. |
meta.has_next | boolean | Whether another page exists. Authoritative across both modes — use this rather than deriving from total. |
meta.total_is_approx | boolean | Present and true only on page-number responses when total is a clamped lower bound, or when a deforestation filter or sort on is_compliant or a plot count reads verdicts still being refreshed. Such a page can come back short, or empty, with has_next still true. Not emitted in cursor mode (where total is null). |
meta.next_cursor | string | Opaque cursor for the next cursor-mode page. Present only on cursor-mode responses, omitted on the final page. Not emitted on page-number responses. |
links.self | string | URL for the current page — echoed back from the request URL verbatim. On the first cursor page this includes the empty sentinel (page[cursor]=); subsequent pages carry the cursor UUID. |
links.prev | string | URL for the previous page in page-number mode (omitted in cursor mode — keyset paging is forward-only). |
links.next | string | URL for the next page. Carries page[number] in page-number mode, page[cursor] in cursor mode. |
▶Count strategy
| Parameter | Values | Default | Description |
|---|---|---|---|
meta[count] | approx, exact | approx | approx caps meta.total at a threshold (typically 10,000) for cheap pagination. exact forces an unbounded count — slower at scale, only meaningful when you need a precise total. |
▶Cursor pagination — example
# First page (page-number mode — server also gives you a cursor to switch)
curl '/locations/plots?filter[assets.task_id]=eq:<task>&page[size]=50' \
-H 'x-api-key: ...'
# → meta.next_cursor: "0000389d-43b0-4b31-bf51-22bcaade16b7"
# → links.next: "/locations/plots?...&page[cursor]=0000389d-...&page[size]=50"
# Follow the cursor — same shape, no deep-paging cost
curl '/locations/plots?filter[assets.task_id]=eq:<task>&page[size]=50&page[cursor]=0000389d-...' \
-H 'x-api-key: ...'
Stop when meta.next_cursor is absent (or links.next is omitted). Each cursor page costs the same as the first, even at "page 10,000" equivalent depth.
/locations/*, /metrics/<location_type>/<metric>, and GET /ingestions (the asset-task list) all support cursor pagination on an identical contract. /ingestions defaults to created_at ASC, id ASC and folds any ?sort= key into the cursor, so keyset traversal stays correct at any depth — see its allowed sort keys. Resuming an /ingestions cursor requires an explicit ?sort= spec; a cursor without one returns 400.
▶NDJSON and NDGeoJSON modes
When using streaming response formats (application/x-ndjson or application/x-ndgeo+json), pagination parameters are ignored and all matching results are streamed in one response. See Response Formats for the streaming wire format and how to consume it.
▶Filters
Filter results by attribute values using the filter[attribute]=condition:value syntax.
▶Syntax
filter[<attribute>]=<condition>:<value>
If the condition is omitted, eq is assumed:
filter[name]=Fazenda # equivalent to filter[name]=eq:Fazenda
▶Conditions
| Condition | Description | Example |
|---|---|---|
eq | Exact match. | filter[country_code]=eq:BR |
ne | Everything eq would not match, including locations with no value. Only on the attributes that list it. | filter[city]=ne:Pekanbaru |
in | Matches any value in a comma-separated list. | filter[status]=in:pending,approved |
contains | Case-sensitive substring match. | filter[name]=contains:Farm |
startsWith | Value starts with the given prefix. | filter[name]=startsWith:Faz |
gt | Greater than (text/numeric comparison). | filter[score]=gt:80 |
gte | Greater than or equal to. | filter[score]=gte:80 |
lt | Less than. | filter[score]=lt:50 |
lte | Less than or equal to. | filter[score]=lte:50 |
▶Nested field filters
For endpoints with nested data fields (e.g. assets.data), use dot-notation to filter by nested fields:
filter[assets.data.CITY]=eq:London
filter[assets.data.address.city]=contains:New
filter[assets.data.score]=gte:80
The first segment identifies the data field; subsequent segments navigate into nested keys. Each endpoint documents which wildcard prefixes are supported (e.g. assets.data.*).
▶Relationship filters
Some endpoints support filtering by related resource attributes using dot-notation with the relationship name:
filter[location.location_type]=eq:facility
filter[location.commodity_type]=eq:palm
These are documented per-endpoint under the Query parameters section.
▶Supplier filters
Location (/locations, /locations/{facilities,sheds,plots}) and metric (/metrics/{plots,sheds,facilities}/<metric>) endpoints can be filtered by supplier:
filter[supplier_id]=eq:<supplier-uuid> # global supplier ID
filter[supplier_id]=in:<uuid-a>,<uuid-b>
filter[supplier_external_id]=eq:<your-supplier-id> # your org-local identifier
filter[supplier_external_id]=startsWith:ACME-
| Filter | Type | Conditions |
|---|---|---|
filter[supplier_id] | uuid | eq, in |
filter[supplier_external_id] | string | eq, in, contains, startsWith |
supplier_idis the global supplier UUID returned byGET /parties/suppliers.supplier_external_idis the identifier you supplied for the supplier in your own namespace (e.g. asupplieridcolumn at upload, orexternal_idon the supplier object). For best performance prefereq/in/startsWith—containscannot use the supplier index.- Drill-through-parents: on the typed
/locations/sheds,/locations/plotsand the corresponding/metrics/{sheds,plots}/…routes, a location also matches when an ancestor (its parent facility, or a plot's parent shed) is linked to the supplier — so a supplier attached at facility or shed grain still surfaces its descendant plots/sheds. On/locations/facilities, the bare/locationsroute, and/metrics/facilities/…, only the location's own supplier link is matched. - All supplier filtering is organization-scoped: you only ever match suppliers linked within the orgs you can access.
▶Deforestation assessment period
Deforestation metric endpoints accept filter[deforestation_period] to select which monitoring period to return. A plot uploaded with a start_date far from the EUDR cut-off produces two rows — a user-period row and an EUDR-period row (see Metric Methodology — How is_compliant is computed).
filter[deforestation_period]=eq:eudr # only EUDR-assessment rows (is_compliant meaningful)
filter[deforestation_period]=eq:user # only user-period rows (since your own start_date)
| Value | Meaning |
|---|---|
eudr | Rows whose monitoring_start is within ±14 days of the EUDR cut-off (2020-12-31). Use for EUDR compliance reporting/screening. |
user | Rows outside that window (anchored at the upload's own start_date). is_compliant is null here by design. |
Applies to /metrics/{plots,sheds,facilities}/deforestation (latest, /history, /:commodity, streaming). It is deforestation-only — silently ignored on other metric families — and facility aggregates are EUDR-anchored, so eq:user returns no rows there. Omit the filter for the default "latest" behaviour.
▶EUDR compliance verdict (ingestion assets)
GET /ingestions/assets (and GET /ingestions/assets/:id) accept filter[compliance] to narrow the list by each item's per-location EUDR deforestation status:
filter[compliance]=eq:non_compliant # items whose location has ≥1 non-compliant plot
filter[compliance]=eq:pending # no non-compliant plot, ≥1 awaiting review
filter[compliance]=eq:compliant # every non-excluded plot has a compliant result
filter[compliance]=eq:excluded # every plot excluded from the filing
filter[compliance]=eq:not_assessed # some plot has no result yet, or no EUDR plots
The statuses are checked in this order, and the first that applies wins, so the five values partition the set:
| Value | Meaning |
|---|---|
non_compliant | At least one plot is non-compliant. |
pending | No non-compliant plot, and at least one awaits adjudication review. |
not_assessed | Not a verdict: some plot has no deforestation result yet (not screened, still running, or failed), or the location has no EUDR plots. Records that resolved no location also match. |
compliant | Every plot that is not excluded has a compliant result, and at least one does. |
excluded | The operator excluded every plot from the filing. |
- Same vocabulary as
filter[compliance]onGET /ingestions(task grain); here it is per location (a facility/shed rolls up its catchment; a plot location rolls up the parts of its latest upload). - Evaluated against the same variant as the
?deforestation=view — pin it with?deforestation=ccdc/eudr, else the prioritised fallback applies. Facility/shed read the cached roll-up; plots evaluate live. meta.totalreflects the filter.eqonly.
▶Headline risk levels (legality / Article 10)
The two headline risk levels promoted onto the assessments relationship — legality_risk_level (Article 9 adverse-media / legality) and article10_risk_level (Article 10 country-risk) — are filterable on both the per-record and the location grain:
# Records whose location's headline legality risk is high
GET /ingestions/assets?filter[assessments.legality_risk_level]=eq:high
# Facilities at medium or high Article 10 country-risk
GET /locations/facilities?filter[article10_risk_level]=in:medium,high
- Values:
low,medium,high(enum;eqandin). - The filter matches the promoted level the row displays (canonical field first, sco2api alias fallback), so a filtered list can never disagree with the levels returned under
include=assessments/include=assessment. - Rows with no visible art9/art10 assessment never match any value — there is no "unassessed" filter value. Absence isn't filterable; it's just the
nulllevel on the row. - On
GET /ingestions/assets*the attributes areassessments.legality_risk_level/assessments.article10_risk_level(matching the confidence filters); on the typed/locations/*routes they are plainlegality_risk_level/article10_risk_level. - Both keys are also sortable on the same endpoints (severity-ranked; see the sort-key table above).
▶Validation
- Only attributes listed per-endpoint are accepted. Unsupported attributes return a 400 Bad Request with a
VALIDATION_ERRORcode. - The condition must be one of the values listed above.
- The value (operand) must be non-empty.
- Each attribute enforces type-specific validation on both the condition and the value. Using an unsupported condition or invalid value returns a 400 Bad Request with a descriptive error message.
▶Attribute types and validation rules
Each filterable attribute has a type that determines which conditions are allowed and how the value is validated.
| Type | Allowed conditions | Value validation |
|---|---|---|
| string | eq, in, contains, startsWith; ne on name, city, admin_region, country and supplier_unit | 1–255 characters (customizable per attribute). |
| enum | eq, in | Must be one of the documented allowed values. |
| numeric | eq, in, gt, gte, lt, lte | Must be a valid number. May have a min/max range. |
| uuid | eq, in | Must be a valid UUID v4 format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). |
| iso-country | eq, in | Must be a 2-letter ISO 3166-1 alpha-2 country code (e.g. BR, US). |
| wildcard | All conditions | No value validation. Used for nested field dot-notation paths (e.g. asset_data.*). |
| tags | eq, in, contains | Any non-empty string. Matches against organization-scoped tag records. eq/contains: exact name match. in: matches any in a comma-separated list. |
▶Tag filter performance
Tag filters (filter[tags]=…) are dramatically faster on the typed routes (/locations/{facilities,sheds,plots}, /metrics/<type>/<metric>) than on the untyped /locations mixed listing. The typed routes scan only the matching pivot + resource_tag combination; the untyped route has to UNION across all three pivot tables (facility, shed, plot) because the request can legitimately return any of those types.
At 6M-plot stress scale, an ?filter[tags]=in:high-priority query takes 200ms-1.4s on a typed route vs ~14s on the untyped /locations route. If you know the location type you want, use the typed route. If you're consuming a mixed listing, expect tag-filter responses to be 10-50× slower than the same filter on a typed route.
▶Error examples
Invalid condition for attribute type:
filter[country_code]=gt:BR
→ 400: "Filter condition 'gt' is not supported for attribute 'country_code'. Allowed conditions: eq, in"
Invalid enum value:
filter[overall_status]=eq:unknown
→ 400: "Invalid value 'unknown' for filter 'overall_status'. Allowed values: pending, processing, validating, completed, failed"
Numeric value out of range:
filter[validation.confidence_score]=gt:2
→ 400: "Value 2 for filter 'validation.confidence_score' exceeds maximum 1"
Invalid UUID format:
filter[id]=eq:not-a-uuid
→ 400: "Invalid UUID format for filter 'id': not-a-uuid"
String value too long:
filter[name]=eq:<256+ characters>
→ 400: "Value for filter 'name' exceeds maximum length of 255 characters"
▶Sparse Fields
Request only specific attributes per resource type using the fields[resource] parameter. This reduces payload size when you only need a subset of attributes.
▶Syntax
fields[<resource>]=<attr1>,<attr2>,...
▶Example
GET /locations/facilities?fields[facility]=name,country_code
Returns facility resources with only name and country_code in attributes. The top-level type, id, links, and relationships fields are always present regardless of field selection.
On the /metrics/... list routes, pass include_links=false as well to keep only links.self on each row and drop the per-row navigation links.
▶Validation
- Only resource types and attributes listed per-endpoint are accepted.
- At least one attribute must be specified.
- Unsupported resource types or attributes return a 400 Bad Request.
▶Geometry — include_geometries
include_geometriesGeometry is opt-in. By default the location, metric, and asset list/detail endpoints do not return geometry — the server doesn't even read it from the database, which keeps responses small and fast for callers that only need attributes. Geometry can be large (plot and supply-shed polygons especially), so you pay for it only when you ask.
▶Syntax
include_geometries=true
Accepted values are true and false (default false). Any other value is a
400 Bad Request.
▶Behaviour by response format
| Format | Geometry returned? |
|---|---|
application/json | Only when include_geometries=true. Otherwise the geo attribute is omitted from the resource. |
application/x-ndjson | Same as JSON — opt-in via the flag. |
application/geo+json | Always — each Feature's geometry is populated regardless of the flag. Geometry is the point of this format. |
application/x-ndgeo+json | Always — same as geo+json. |
So you never need the flag when requesting a GeoJSON media type; you only need it to add geometry to a plain JSON / NDJSON response.
▶Example
# Default — no geometry, `geo` absent from attributes
GET /locations/plots
# Opt in — `geo` present on every resource
GET /locations/plots?include_geometries=true
# geo+json always carries geometry, flag or not
GET /locations/plots
Accept: application/geo+json
▶Asset endpoints
On GET /ingestions/assets and GET /ingestions/assets/:id, geometry lives
inside the normalised_data relationship. It therefore requires both
include=normalised_data and include_geometries=true:
GET /ingestions/assets/{id}?include=normalised_data&include_geometries=true
Passing include_geometries=true without include=normalised_data is a no-op
there (the relationship that would carry the geometry isn't expanded).
Migration note. Prior to this change,
geowas always present in JSON responses. Clients that readattributes.geo(ornormalised_data'sgeometry) from a plain JSON/NDJSON response must now passinclude_geometries=true, or switch to ageo+jsonmedia type.
▶Include (Relationship Expansion)
Use the include parameter to expand relationships inline with full resource objects.
▶Syntax
include=<relationship1>,<relationship2>
▶Validation
- Only relationship names listed per-endpoint are accepted.
- Unsupported relationship names return a 400 Bad Request.
▶Per-endpoint includes
▶GET /ingestions/assets, GET /ingestions/assets/:id
| Relationship | Description |
|---|---|
normalised_data | Normalised location data (match method, location type, commodity, geometry, address fields) |
validation | Confidence score, confidence level, and validation flags |
eudr | EUDR compliance fields (HS code, product trade name, supplier, production country) |
deforestation | Per-location EUDR deforestation roll-up for each item's location_id: total_plots, compliant_plots, non_compliant_plots, unknown_plots, total_area_ha, compliant_area_ha, under attributes.deforestation. A facility/shed aggregates its whole catchment; a plot location rolls up the parts of its latest upload. Facility/shed roll-ups are read from a cache written at task completion (no live catchment scan); plot verdicts are computed live. |
Selecting the roll-up view — the roll-up exists in variants by monitoring window and change-detection algorithm. Use ?deforestation=<view> (which also enables the block, so you don't also need include=deforestation):
?deforestation= | View |
|---|---|
ccdc | CCDC (continuous change detection) metrics only |
eudr | EUDR monitoring-window metrics only |
true / user (or omitted) | Org default — prioritised fallback: prefer a variant that has an assessed metric, then CCDC, then the EUDR window (avoids showing all-unknown when a pinned variant has no data) |
▶GET /ingestions, GET /ingestions/:id, GET /ingestions/:id/summary
| Include | Routes | Description |
|---|---|---|
deforestation | all three | EUDR compliance roll-up over the task's plots: total_plots, compliant_plots, non_compliant_plots, unknown_plots, total_area_ha, compliant_area_ha. Surfaced under attributes.deforestation on each resource. The list endpoint batches the roll-up server-side so a page of N tasks costs one extra query, and supports the streaming application/x-ndjson accept header (rows are flushed in bounded chunks). |
payment | GET /ingestions/:id only | Whether this task's own run paid for its billable locations, under attributes.payment: status (unpaid, partially_paid, paid), locations (total, paid, unpaid), open_session and latest_session. Needs billing:read. A key without it gets the task with no payment key, not a 403. GET /ingestions and the summary return 400 for this value. The block reads every billable row of the task, so its cost grows with the task's location count. See Checkout Sessions. |
▶GET /metrics/plots/:metric
| Relationship | Description |
|---|---|
shed | The supply shed containing this plot |
plot | The plot location resource |
facility | The facility linked to the plot's shed |
▶GET /metrics/sheds/:metric
| Relationship | Description |
|---|---|
shed | The shed location resource |
facility | The facility linked to this shed |
▶Rollup — ?rollup= (ingestion assets)
?rollup= (ingestion assets)?rollup= takes four values:
| Value | Grain |
|---|---|
false (default) | One row per asset. |
true | One row per plot upload (task). Facility and shed rows stay one per location. |
task | The same as true. true is the legacy spelling; both return identical responses. |
unit | One row per supplier unit within each upload. See Rollup by supplier unit. |
The sections below describe true / task, then unit.
GET /ingestions/assets normally returns one row per asset — for a plot
upload that means one row per plot, often thousands per upload. ?rollup=true
collapses that noise: every plot upload is folded into a single row for
the whole upload (task). Facility and shed rows are left exactly as they are
(one per location) — only plots fold. Think of a rolled row as a one-line
summary of a plot upload.
▶How a plot upload is folded
asset_data is combined column by column. For each field in your uploaded
data, the rolled row looks across every plot in the upload:
- if the value is the same on every plot, the field keeps that single value;
- if the value differs between plots, the field becomes the list of the distinct values found.
So a country column that is BR on all plots stays "country": "BR", while a
producer_name that varies becomes "producer_name": ["Farm X", "Farm Y"].
Very high-cardinality fields are capped — the list is truncated and the field
name is added to a reserved _truncated list on the row (a "*" entry there
means the upload was large enough that the sample itself was capped).
▶Which plot's details you see (the "first plot")
A folded row still needs a single location, geometry, and assessment result to show. The API uses the upload's first plot — the earliest-resolved plot in the upload — as the representative:
location_id/location_type, thenormalised_datarelationship, theassessmentsrelationship, and the map geometry all come from that first plot;asset_data(above) still reflects all the plots.
So you get one plot's full detail alongside the combined data for the whole upload.
▶Filtering and sorting a rolled list
?rollup=true accepts the same filter[...] and ?sort= parameters as the
un-rolled list. Both are applied at plot grain, before the fold:
- Filters narrow the plots that make up each rolled row. A plot upload
appears only if at least one of its plots matches, and the fold — the
asset_datacolumns, the representative "first plot", and the counts — then summarises only the matching plots. (Facility/shed rows filter directly, as on the un-rolled list.) For example,filter[has_location]=eq:truefolds only the resolved plots of each upload, and an upload with no resolved plot drops out entirely. - Sorting orders rolled rows by an aggregate of their matching plots.
sort=legality_risk_level/article10_risk_levelorders each upload by its worst plot risk;created_at,status, andsource_filesort on the upload's own value. A single sort key is honoured (the list sorts one column at a time).
▶Deforestation and collection hashes
deforestation(withinclude=deforestation) is the whole upload's roll-up (all its plots together) — not just the first plot's verdict.collection_idsare the distinct plot-collection hashes for the upload.
▶What isn't affected
idon a rolled row is the ingestion task id;links.selfpoints at that task's assets andlinks.locationat the representative plot.- Works with page-number and cursor pagination, and across all four
response formats — JSON,
application/geo+json, and the streamingapplication/x-ndjson/application/x-ndgeo+jsonvariants. (Streaming pages the fold internally under a cursor, so memory stays bounded to one page.)
▶Limitations
- Only the first
?sort=key is applied; multi-key sort underrollupfalls back to that key plus the row id as a tie-break. deforestationon a rolled row is the whole upload's roll-up even when a filter narrows the fold — it is not re-scoped to the matching plots.
▶Example
GET /ingestions/assets?rollup=true&include=normalised_data,deforestation
{
"type": "asset",
"id": "<plot upload/task id>",
"attributes": {
"asset_data": {
"commodity": "cocoa", // same on every plot → one value
"country_code": "BR",
"name": ["Plot A", "Plot B", "Plot C"] // differs → list of distinct values
},
"location_id": "<first plot's id>", // representative first plot
"location_type": "plot",
"deforestation": { "total_plots": 3, "compliant_plots": 3 }, // whole upload
"collection_ids": ["0xf151…"]
},
"relationships": { "normalised_data": { /* first plot */ } }
}
▶Rollup by supplier unit — ?rollup=unit
?rollup=unit?rollup=unit folds rows onto the supplier unit instead of the upload. A
supplier unit is the group of locations that validation assigns to one
supplier at ingest. A plot upload that holds three supplier units returns
three rows. Each row folds only the plots of its own unit.
The grain is (organization_id, upload, supplier unit):
- The same unit in two uploads stays two rows.
- Two organisations never share a row, even when their unit ids are equal.
The fold, filters, sort and pagination work the same as under rollup=true,
at the unit grain.
▶Which rows split
- Plot uploads split per unit.
- Facility and shed uploads fold per unit when validation assigned one. A
facility or shed record with no unit stays its own row, exactly as under
rollup=true. - Uploads ingested before supplier units existed (before 2026-09-16) carry
no unit. They fall back to one row per plot upload and one row per facility
or shed record — the same rows
rollup=truereturns.
So two organisations can show different grains side by side. That is intended.
▶What changes on a unit row
| Field | Under unit |
|---|---|
id | A deterministic uuid for the (organisation, upload, unit) triple. It is stable across reads and works as a cursor tie-break. It is not a task id — use attributes.asset_task_id to link to the upload. A facility or shed record with no unit keeps its own asset id. |
asset_task_id | The upload the unit belongs to. |
normalised_data.supplier_unit | The unit's id (request include=normalised_data). |
deforestation | The unit's own totals, from the per-unit summary. If the upload has per-unit rows but not this unit's, the totals are zero. If the upload has no per-unit rows at all (computed before the per-unit summary existed, and not yet backfilled), the upload's totals are used when the upload has exactly one unit; with several units no per-unit figure exists, so the block is absent and live_plot_count with it. |
live_plot_count | The unit's own plot total — equal to deforestation.total_plots. Present with or without include=deforestation. |
collection_ids | Absent on a folded unit row. Collection hashes are defined per plot, not per unit. An unfolded facility or shed record keeps its own. |
superseded_plot_count | Absent on a folded unit row, for the same reason. |
▶Summary cards
Send the same ?rollup=unit to GET /ingestions/assets/summary and
GET /ingestions/assets/latest/summary. Their counted then equals the
list's total at the unit grain.
▶Limitation
Two different suppliers can in rare cases receive the same unit id. Their plots then fold into one row. This is tracked as EPO-3076.
▶Example
GET /ingestions/assets?rollup=unit&filter[assets.task_id]=eq:<task id>&include=normalised_data,deforestation
{
"data": [
{
"type": "asset",
"id": "db0eb544-7791-3eb4-8f67-978d73bb854f", // synthetic unit-row id
"attributes": {
"asset_task_id": "<task id>",
"asset_data": { "country_code": "BR", "name": ["Plot A1", "Plot A2"] },
"deforestation": { "total_plots": 2, "compliant_plots": 2 }, // this unit only
"live_plot_count": 2
},
"relationships": {
"normalised_data": { "attributes": { "supplier_unit": "5ae3999c8cdc" } }
}
},
{
"type": "asset",
"id": "d1456a0a-2b75-38e0-8f2a-41907fecff11",
"attributes": {
"asset_task_id": "<task id>",
"asset_data": { "country_code": "ID", "name": "Plot B1" },
"deforestation": { "total_plots": 1, "non_compliant_plots": 1 },
"live_plot_count": 1
},
"relationships": {
"normalised_data": { "attributes": { "supplier_unit": "b8d633605518" } }
}
}
]
}
The same upload under ?rollup=task returns one row with total_plots: 3.
▶Streaming Responses
List endpoints support streaming formats that return all matching records without pagination. Set the Accept header to request a streaming format:
| Accept header | Format | Description |
|---|---|---|
application/x-ndjson | NDJSON | One JSON object per line (no envelope) |
application/geo+json | GeoJSON | GeoJSON FeatureCollection with geometries |
application/x-ndgeo+json | NDGeoJSON | One GeoJSON Feature per line |
Streaming responses ignore page and page_size parameters and return all matching records.
▶cURL example
curl -H "x-api-key: sk-epoch-..." \
-H "Accept: application/x-ndjson" \
"https://api.epoch.blue/locations/facilities?filter[commodity]=eq:soy"
Each line is a complete JSON object:
{"type":"facility","id":"a1b2...","attributes":{"name":"Porto Plant","commodity":"soy",...}}
{"type":"facility","id":"c3d4...","attributes":{"name":"Santos Mill","commodity":"soy",...}}
▶Python example
import requests
response = requests.get(
"https://api.epoch.blue/locations/facilities",
headers={
"x-api-key": "sk-epoch-...",
"Accept": "application/x-ndjson",
},
params={"filter[commodity]": "eq:soy"},
stream=True,
)
for line in response.iter_lines():
if line:
record = json.loads(line)
print(record["attributes"]["name"])
▶Combining Parameters
All query parameter types can be combined in a single request:
GET /ingestions/assets?page=1&page_size=50&filter[assets.data.CITY]=eq:London&fields[asset]=asset_data,status&include=normalised_data,validation
Parameters are applied in this order:
- Filters narrow the result set.
- Pagination selects a page from the filtered results.
- Fields control which attributes appear in each resource.
- Include expands relationship references into full objects.