Assessments API
The assessments surface (/assessments/...) is the read + refresh API over
the assessment rows every staged location accumulates. Each assessment answers a
different question about a location (confidence, hazard, legality, country risk,
…) and is exposed under a stable, regulation-neutral URL slug.
This page documents the API contract: the URL surface, the slug map, the filter matrix, refresh semantics, the DDS audit-log interaction, and the cross-org dedup caveat.
- For what each assessment computes (purpose, signals, when to opt in), see Assessments Overview.
- For the field-by-field reference of each row's attributes, see Assessment Fields.
- For shared mechanics — pagination, filter operators, response formats, scopes — see Query Parameters, Response Formats, and Scopes.
â–¶URL surface
location_type is one of facilities, sheds, or plots. slug is one of
the regulation-neutral slugs in the slug map. Reads require the
assessments:read scope; refresh requires assessments:write.
â–¶Reads
| Method & path | Purpose |
|---|---|
GET /assessments/{location_type}/{slug} | Latest assessment per location (paginated list). |
GET /assessments/{location_type}/{id}/{slug} | Single location's latest assessment (404 when none). |
Every list response uses the standard meta / links / data envelope; each
item is { "type": "assessment", "id", "attributes": { "location_id", "location_type", …slug fields }, "links": { "self" } }. All four list formats
(application/json, application/geo+json, application/x-ndjson,
application/x-ndgeo+json) are available via the Accept header — see
Response Formats.
â–¶Flatteners (legality only)
Legality additionally exposes row-level sub-resources that flatten the
nested applicable_laws[] / adverse_media_concerns[] arrays into one row each,
paginated and filtered at the row level:
| Method & path | Purpose |
|---|---|
GET /assessments/{location_type}/legality/applicable-laws | One row per applicable law. |
GET /assessments/{location_type}/{id}/legality/applicable-laws | The applicable-law rows for a single location. |
GET /assessments/{location_type}/legality/adverse-media | One row per adverse-media concern. |
GET /assessments/{location_type}/{id}/legality/adverse-media | The adverse-media rows for a single location. |
Each row carries a 1-based ordinal (law_ordinal for concerns) that
correlates it back to its parent law on the per-location /legality surface.
â–¶Refresh (the write surface)
refresh is a trailing path segment (not a colon-verb), so it renders as a
literal route in the OpenAPI spec. One handler backs every call shape and
disambiguates from the path + body:
| Method & path | Shape | selection_kind |
|---|---|---|
POST /assessments/{location_type}/refresh | Batch, multi-slug (slugs in body) | filter (or list) |
POST /assessments/{location_type}/{idOrSlug}/refresh | Batch single-slug ({slug}) or single multi-slug ({id}) | filter / single |
POST /assessments/{location_type}/{id}/{slug}/refresh | Single-location, single-slug | single |
Refresh is asynchronous: it returns 202 Accepted with an
assessment_task resource and a meta.poll hint. See
Refresh semantics.
â–¶Tasks
| Method & path | Purpose |
|---|---|
GET /assessments/tasks | List the caller's recent refresh tasks (paginated). |
GET /assessments/tasks/{id} | Poll a single refresh task to terminal state. |
â–¶Slug mapping
Public URLs are regulation-neutral so future laws can land as new internal assessment types without breaking the URL contract. The slug → internal type map is the stable part; the internal enum names are an implementation detail.
| Slug | Internal type | What the read projects |
|---|---|---|
legality | art9 | Applicable laws, adverse-media concerns, legality_risk_level / _score, legality_sources. |
country-risk | art10 | Article-10 procedural risk: article10_risk_score / _level / _deterministic_tier, corruption_perception_index. |
eudr-profile | eudr_profile | EUDR country benchmark tier, facility description/profile, Article-29 citation + effective date. |
deforestation-screen | deforestation_screen | Per-plot deforestation risk band, deforestation_present, deforestation_area_ha. |
hazard | hazard | Natural-hazard risk: flood, extreme heat, water stress, precipitation trend; nearest power-grid distance. |
biodiversity | biodiversity | Landscape biodiversity: landscape_integrity_score / _level, natural cover fraction, patch count, Rao's Q, WDPA / IFL proximity. |
water | water | Climatic water stress: water_budget_score / _level, seasonal climatic water deficit. |
luc-emissions | luc_emissions | Land-cover change (converted area, transitions) and its emissions: luc_tco2eyear, intensity, luc_risk_level. |
infrastructure | infrastructure | Three named sub-objects — distances (water/port/road/grid), shed (art9 sibling), climate (hazard sibling). |
core | core | Confidence score/level + per-check geometric counts + plausibility summaries. |
â–¶Future-regulation roadmap
Because the slug is the contract, additional regulations (e.g. UFLPA, the EU Forced Labour Regulation, the German LkSG) land as new slugs backed by new internal assessment types — existing slugs and their fields keep working unchanged. A new internal enum value can also exist without a public slug until one is added, so internal experiments never leak into the URL surface.
â–¶Filter matrix
All read endpoints accept filter[...] query parameters (see
Query Parameters for operator syntax). Each filter
constrains the operators it honours, so filter[risk_level]=high (bare = eq)
and filter[risk_level]=in:low,medium work, but unsupported operators 400.
Filters fall into three tiers:
â–¶1. Location-level (shared by every slug)
organization_id, location_id, commodity, country_code, name, tags,
created_at. The legality surfaces additionally support the location-parity
filters country, admin_region, city, facility_id, shed_id, plot_id.
â–¶2. Ingestion-trace (shared by every slug)
Scope a read back to the upload it came from:
| Filter | Example |
|---|---|
assets.task_id | filter[assets.task_id]=eq:<ingestion-task-uuid> |
assets.filename | filter[assets.filename]=plots.geojson |
assets.data.* | filter[assets.data.CITY]=London (user-uploaded source columns) |
â–¶3. Per-slug
Each slug adds filters over its own row data. Representative examples:
| Slug | Per-slug filters |
|---|---|
legality (+ flatteners) | risk_level, risk_score_min, risk_score_max, category, has_adverse_media, media_relevance, since |
country-risk | Article-10 risk band / score + since |
hazard | Natural-hazard band / score + since |
core | confidence_level + since |
luc-emissions | luc_risk_level (eq / in; a null band never matches) + since |
The authoritative, current per-slug filter list lives in the generated OpenAPI spec (the Request/Query tabs on each endpoint page).
â–¶Refresh semantics
â–¶Request shape
- Selection is exactly one of: a path
:id(single), alocation_idsarray in the body (list, ≤ 10,000 ids), or afilter[...]querystring (filter). A by-filterselection resolving to > 10,000 locations is rejected (400) rather than silently truncated. - Slugs come from either the path slug or a body
slugsarray — never both (supplying both is a400).slugsomitted or"all"expands to every slug. Example multi-slug body:{ "slugs": ["legality", "country-risk"] }.
â–¶Async + polling
A 202 carries the assessment_task plus a meta.poll block:
{
"meta": {
"poll": {
"href": "/assessments/tasks/<id>",
"attribute": "data.attributes.status",
"terminal_states": ["completed", "partial", "failed"],
"interval_ms": 2000
}
}
}
Poll GET /assessments/tasks/:id until data.attributes.status is terminal.
External status vocabulary: queued · running · completed · partial
(some units failed) · failed.
â–¶Counts & per-slug breakdown
data.attributes.counts always satisfies:
total = completed + failed + skipped_cached + in_progress + queued
data.attributes.per_slug.<slug> repeats the same count keys plus that slug's
own status, so a multi-slug refresh reports progress per partition.
â–¶Dedup & freshness
- Freshness short-circuit — a (location, slug) pair whose existing
validation row has an
sco2_run_atwithin the freshness window (24h default) is counted underskipped_cachedand excluded from the work set. A refresh where every pair is fresh completes immediately with no work dispatched.?force=truebypasses the window and re-runs everything. - Dedup / claims — concurrent refreshes that target the same pairs attach to the already-in-flight task rather than double-running; the attached request's task finalises against the owning run.
- Concurrency cap — a soft per-organization cap on in-flight refresh tasks;
exceeding it returns
429. - Retry —
?retry=failed_only&from=<prior-task-id>re-runs only the failed subset of a prior task (skipping the freshness check), using the prior task's original selection snapshot.
â–¶Audit-log interaction with open DDS records
A refresh that overwrites a validation row pinned by an open (unsigned) DDS
version writes an underlying_assessment_refreshed audit event on that DDS
record's audit feed, in the same transaction as the overwrite. Read it via:
GET /regulations/eudr/audit-trail/{recordId}/events?filter[action][eq]=underlying_assessment_refreshed
Each event's payload carries:
| Field | Meaning |
|---|---|
assessment_slug / assessment_type | Which assessment was refreshed. |
staged_location_id | The location whose pinned row was overwritten. |
previous_sco2_run_at / new_sco2_run_at | Before → after (previous is null if the overwrite filled a prior absence). |
assessment_task_id | The refresh task that caused the overwrite. |
selection_kind | single / list / filter. |
trigger | Always "refresh" for this action. |
Suppression falls out of the walk: sealed versions (signed_at set) and
validation rows not pinned by any open version yield zero events; so do
freshness-skipped and failed pairs (they never reach the overwrite).
On the task side, GET /assessments/tasks/:id reports
affected_open_dds_versions (terminal tasks only) — an array of
{ dds_id, dds_version_id, plot_count } summarising which open versions this
refresh touched and how many of their pinned plots. See
EUDR Audit Trail for the broader event feed.
â–¶Cross-org dedup caveat
Assessment rows live on the shared underlying location, not per-org — a
parent org and its children link to the same staged_location via pivot tables.
The refresh dedup + freshness probe are org-agnostic by design: they match on
the shared validation row's sco2_run_at regardless of which org last refreshed
it. Two consequences:
- A refresh triggered by one linked org makes the row "fresh" for every org
that links to that location — a second org's non-
forcerefresh of the same location will short-circuit (skipped_cached) even though that org never ran it. Pass?force=trueto re-run regardless. - Access control is still strict: a single-location or by-list refresh of an
:idthe caller's orgs cannot see returns404(never403— no cross-org existence leak), and a by-filterrefresh simply resolves to an empty selection. Cross-org callers can neither observe nor attach to another org's in-flight refresh; only the shared assessment data is common.