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.

ParameterTypeDefaultDescription
page[number]integer11-based page index (page-number / offset mode).
page[size]integer100Items 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 shapePage-numberCursorSpeedup
/metrics/<type>/<metric> over 1M plots60-100s50-200ms~500×
/metrics/<type>/<metric>?filter[location_id]=… (drill)18-27s0.8-1.4s~20×
/locations/{type} over 1M plots2-50s40-400ms10-1000×
/locations/{type}?filter[tags]=…4-10s200-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 total so 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, so meta.total is null on cursor responses — use meta.has_next / meta.next_cursor to 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=

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 /ingestions sorts name NULLS 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 with page[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 sort keeps each endpoint's default order (above) — fully backward compatible.

Allowed sort keys by endpoint

EndpointSortable keysNotes
GET /ingestionscreated_at (default), name, source_file, overall_status, idcreated_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/:idcreated_at, status, source_file, legality_risk_level, article10_risk_levelPer-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_levelTyped 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/:commodityidUntyped 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_countFacility-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 return 400 until 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": [...]
}
FieldTypeDescription
meta.pageintegerCurrent page number (always 1 in cursor mode — the value is informational).
meta.page_sizeintegerPage size used for this request.
meta.totalinteger | nullTotal matching items in page-number mode. In approx-count mode (default) this is capped — see meta[count]. Always null in cursor mode.
meta.has_nextbooleanWhether another page exists. Authoritative across both modes — use this rather than deriving from total.
meta.total_is_approxbooleanPresent 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_cursorstringOpaque 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.selfstringURL 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.prevstringURL for the previous page in page-number mode (omitted in cursor mode — keyset paging is forward-only).
links.nextstringURL for the next page. Carries page[number] in page-number mode, page[cursor] in cursor mode.

Count strategy

ParameterValuesDefaultDescription
meta[count]approx, exactapproxapprox 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

ConditionDescriptionExample
eqExact match.filter[country_code]=eq:BR
neEverything eq would not match, including locations with no value. Only on the attributes that list it.filter[city]=ne:Pekanbaru
inMatches any value in a comma-separated list.filter[status]=in:pending,approved
containsCase-sensitive substring match.filter[name]=contains:Farm
startsWithValue starts with the given prefix.filter[name]=startsWith:Faz
gtGreater than (text/numeric comparison).filter[score]=gt:80
gteGreater than or equal to.filter[score]=gte:80
ltLess than.filter[score]=lt:50
lteLess 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-
FilterTypeConditions
filter[supplier_id]uuideq, in
filter[supplier_external_id]stringeq, in, contains, startsWith
  • supplier_id is the global supplier UUID returned by GET /parties/suppliers.
  • supplier_external_id is the identifier you supplied for the supplier in your own namespace (e.g. a supplierid column at upload, or external_id on the supplier object). For best performance prefer eq / in / startsWith — contains cannot use the supplier index.
  • Drill-through-parents: on the typed /locations/sheds, /locations/plots and 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 /locations route, 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)
ValueMeaning
eudrRows whose monitoring_start is within ±14 days of the EUDR cut-off (2020-12-31). Use for EUDR compliance reporting/screening.
userRows 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:

ValueMeaning
non_compliantAt least one plot is non-compliant.
pendingNo non-compliant plot, and at least one awaits adjudication review.
not_assessedNot 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.
compliantEvery plot that is not excluded has a compliant result, and at least one does.
excludedThe operator excluded every plot from the filing.
  • Same vocabulary as filter[compliance] on GET /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.total reflects the filter. eq only.

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; eq and in).
  • 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 null level on the row.
  • On GET /ingestions/assets* the attributes are assessments.legality_risk_level / assessments.article10_risk_level (matching the confidence filters); on the typed /locations/* routes they are plain legality_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_ERROR code.
  • 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.

TypeAllowed conditionsValue validation
stringeq, in, contains, startsWith; ne on name, city, admin_region, country and supplier_unit1–255 characters (customizable per attribute).
enumeq, inMust be one of the documented allowed values.
numericeq, in, gt, gte, lt, lteMust be a valid number. May have a min/max range.
uuideq, inMust be a valid UUID v4 format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
iso-countryeq, inMust be a 2-letter ISO 3166-1 alpha-2 country code (e.g. BR, US).
wildcardAll conditionsNo value validation. Used for nested field dot-notation paths (e.g. asset_data.*).
tagseq, in, containsAny 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

Geometry 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

FormatGeometry returned?
application/jsonOnly when include_geometries=true. Otherwise the geo attribute is omitted from the resource.
application/x-ndjsonSame as JSON — opt-in via the flag.
application/geo+jsonAlways — each Feature's geometry is populated regardless of the flag. Geometry is the point of this format.
application/x-ndgeo+jsonAlways — 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, geo was always present in JSON responses. Clients that read attributes.geo (or normalised_data's geometry) from a plain JSON/NDJSON response must now pass include_geometries=true, or switch to a geo+json media 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

RelationshipDescription
normalised_dataNormalised location data (match method, location type, commodity, geometry, address fields)
validationConfidence score, confidence level, and validation flags
eudrEUDR compliance fields (HS code, product trade name, supplier, production country)
deforestationPer-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
ccdcCCDC (continuous change detection) metrics only
eudrEUDR 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

IncludeRoutesDescription
deforestationall threeEUDR 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).
paymentGET /ingestions/:id onlyWhether 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

RelationshipDescription
shedThe supply shed containing this plot
plotThe plot location resource
facilityThe facility linked to the plot's shed

GET /metrics/sheds/:metric

RelationshipDescription
shedThe shed location resource
facilityThe facility linked to this shed

Rollup — ?rollup= (ingestion assets)

?rollup= takes four values:

ValueGrain
false (default)One row per asset.
trueOne row per plot upload (task). Facility and shed rows stay one per location.
taskThe same as true. true is the legacy spelling; both return identical responses.
unitOne 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, the normalised_data relationship, the assessments relationship, 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_data columns, 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:true folds 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_level orders each upload by its worst plot risk; created_at, status, and source_file sort 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 (with include=deforestation) is the whole upload's roll-up (all its plots together) — not just the first plot's verdict.
  • collection_ids are the distinct plot-collection hashes for the upload.

What isn't affected

  • id on a rolled row is the ingestion task id; links.self points at that task's assets and links.location at the representative plot.
  • Works with page-number and cursor pagination, and across all four response formats — JSON, application/geo+json, and the streaming application/x-ndjson / application/x-ndgeo+json variants. (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 under rollup falls back to that key plus the row id as a tie-break.
  • deforestation on 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 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=true returns.

So two organisations can show different grains side by side. That is intended.

What changes on a unit row

FieldUnder unit
idA 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_idThe upload the unit belongs to.
normalised_data.supplier_unitThe unit's id (request include=normalised_data).
deforestationThe 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_countThe unit's own plot total — equal to deforestation.total_plots. Present with or without include=deforestation.
collection_idsAbsent 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_countAbsent 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 headerFormatDescription
application/x-ndjsonNDJSONOne JSON object per line (no envelope)
application/geo+jsonGeoJSONGeoJSON FeatureCollection with geometries
application/x-ndgeo+jsonNDGeoJSONOne 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:

  1. Filters narrow the result set.
  2. Pagination selects a page from the filtered results.
  3. Fields control which attributes appear in each resource.
  4. Include expands relationship references into full objects.