EUDR Compliance
The European Union Deforestation Regulation (EUDR, Regulation (EU) 2023/1115) requires operators and traders placing specific commodities on the EU market to demonstrate that their products are deforestation-free, legally produced, and covered by a due diligence statement (DDS). The Epoch API provides an end-to-end workflow for EUDR compliance.
βΆEpoch EUDR Workflow
Ingest plots (with deforestation) β Build audit-trail record β Cut version β Configure exclusions β Export β Submit as DDS
The lifecycle has two surfaces, distinguished only by state:
- Audit trail β
/regulations/eudr/audit-trail/*β the working draft: create a record, refine it (exclusions, justifications, operator/commodity, suppliers, report fields), cut immutable versions, and export a preview report. Nothing here is filed. - DDS β
/regulations/eudr/dds/*β the filed artifact: submit an audit-trail version (POST /dds/:recordId/submit) and read back the submitted records (GET /dds,GET /dds/:recordId). Submitting is what turns an audit-trail record into a DDS.
Two ways to build the record. The steps below walk the explicit path (
POST /regulations/eudr/audit-trailwith a plot filter). For a one-call shortcut, setcreate_audit_trail: trueon the ingestion upload (alongsidemetrics: ["deforestation"]) and the worker auto-creates the record once processing finishes β see Fast path below.
βΆ1. Ingest
Upload supplier plot locations via POST /ingestions/assets (file upload) and process them with the deforestation metric via PUT /ingestions/assets (filter-scoped). Supported formats: CSV, GeoJSON, Parquet, GeoPackage, Shapefile, Excel. See Plot Lifecycle Workflow.
Associate each upload with its supplier at this step β either with per-row supplier columns in the file or an upload-wide supplier object on the request body β so you can later build a DDS scoped to that supplier. See Suppliers and Asset Upload β Attaching a supplier. If one supplier spans several files, give every upload the same supplier identity; they reuse a single deduplicated registry row.
βΆ2. Validate & Stage
The system automatically normalizes coordinates, geocodes addresses, validates geometries, and matches against existing records. The asset_task progresses to staged then completed after processing. See Location Validation.
βΆ3. Process Deforestation
Set "metrics": ["deforestation"] on the ingestion PUT body. The analysis engine runs:
- Multi-source change detection (CCDC, GLAD, TMF) against the December 31, 2020 cut-off date
- Natural forest classification using the Nature Trace Forest Typology 2020 (FORTY, 10 m) and FDP commodity masks
- Managed forest exemption checks (35+ country-specific tenure datasets)
- Protected area (WDPA) overlap checks
- Spatio-temporal sieving (0.5 ha minimum connected component threshold)
Recommended for EUDR-critical workflows: opt into the Enhanced deforestation methodology by setting
metric_options.enhanced_deforestation: trueon ingestion (only valid whenmetricsincludes'deforestation'). Enhanced adds a higher-sensitivity time-series change-detection layer that catches sub-canopy / progressive change the alert systems alone can miss β typically the difference betweenis_compliant: trueandis_compliant: falsefor plots near the de minimis threshold. Defaults tofalse(Standard methodology) for cost; flip on for DDS submission and audit-response workloads. See Deforestation Methodology for the full trade-off and a worked example.
On the EUDR-specific ingestion-results endpoint (GET /ingestions/asset_results), the EUDR fields are grouped under a nested eudr object on the assessment: eudr.noncompliance_area, eudr.noncompliance_area_perc, eudr.is_compliant, alongside the top-level deforestation classifications (deforestation_alert, deforestation_confidence), generic deforestation fields, and thumbnails. Every row also carries enhanced_deforestation: boolean so consumers can see which methodology produced the values.
Note: the generic metric endpoints (
/metrics/plots/deforestation,/metrics/sheds/deforestation,/metrics/facilities/deforestation) emit these same fields flat on the response β noeudrnesting. See Metric Methodology β Key Response Fields for the metric response shape and the rules governingis_compliant(which isnulloutside the Β±14-day EUDR window). See Thumbnail Interpretation for the visual output guide.
Task-level roll-up: once processing completes, add
?include=deforestationonGET /ingestions,GET /ingestions/:id, orGET /ingestions/:id/summaryto get an EUDR compliance summary over every plot in the task (total_plots,compliant_plots,non_compliant_plots,unknown_plots,total_area_ha,compliant_area_ha). The block appears underattributes.deforestationon each resource and is batched server-side, so a paginated list of N tasks costs one extra query. See Query Parameters β Per-endpoint includes.
Per-location roll-up: the same block is available per item on
GET /ingestions/assetsvia?include=deforestation(or?deforestation=<view>). Each asset carries the roll-up for its ownlocation_idunderattributes.deforestationβ a facility/shed aggregates its whole catchment, a plot location rolls up the parts of its latest upload. Pick the variant with?deforestation=ccdc|eudr(else a prioritised fallback), and narrow the list to a status withfilter[compliance]=eq:non_compliant|pending|compliant|excluded|not_assessed. Facility/shed roll-ups are cached at task completion (no live catchment scan); plot verdicts are computed live.
βΆ4. Create the Audit-Trail Record & Configure Exclusions / Justifications
Once processing is complete, create an audit-trail record from a plot filter via POST /regulations/eudr/audit-trail. The record snapshots the included plots and their assessments, and persists per-record exclusions/justifications across versions. Creation is asynchronous β you receive a dds_record_task (operation create_record); poll GET /regulations/eudr/audit-trail/tasks/:taskId until status: "completed", then read the new record id from result.records[0].record_id.
Commodity is optional at create time. You can create a record before commodity details are known β omit the
commodityblock and the version's commodity columns are left null. Populate them viaPATCH /regulations/eudr/audit-trail/:recordIdbefore submit (thecommodity_missingpre-submission blocker enforces this). Theoperatorblock is likewise optional and falls back to the org's stored operator defaults when omitted.
PATCHeditsname,operatorandcommodityonly. Thecommodityblock includes the optional goodsMeasure supplementary-unit pair βsupplementary_unit(amount) +supplementary_unit_qualifier(MTQcubic metres /NARhead count; net weight in kg always rides onproduct_quantity_kg). The submission metadata (activity type, country of activity, etc.) is not editable viaPATCHβ it is set at create and finalised on the submit call (see step 7). The single-recordGET /audit-trail/:recordIdreturns ameta.editable_fieldsdescriptor (field name/label/type/options/conditions) of exactly whatPATCHaccepts, plusmeta.can_submitandmeta.submission_blockers(the pre-submission gate).
The filter resolves which plots the record covers. Conditions AND together, and the resolved set must be homogeneous (single commodity, single country). Available keys:
| Group | Keys |
|---|---|
| Location | location_id |
| Plot / geography | plot_id, country_code, commodity, area_ha, tag |
| Facility | facility_id, facility_name (normalized name) |
| Supplier | supplier_id, supplier_external_id, supplier_name, supplier_address_line1, supplier_address_line2, supplier_city, supplier_region, supplier_postcode, supplier_country, supplier_statement |
| Source asset | assets.task_id, assets.filename, assets.data.<column> |
location_idβ select by an opaque/locationsid.location_idaccepts the staged-location UUID surfaced as the resourceidonGET /locations/*, and resolves it the same way the locations API drills: a plotlocation_idselects that plot; a facilitylocation_idselects every plot under the facility (facility β shed β plot); a shedlocation_idselects every plot under the shed. Use it when you hold a/locationsid and don't want to care whether it's a plot, shed or facility. Useplot_id/facility_idinstead when you specifically hold aplot.idorfacility.idβ e.g. the values under a location'stile_ids. (Note: a facility'slocation_idis not the same value as itsfacility.id; for facilities the two selectors take different ids.)
To build one DDS for a whole supplier β gathering its plots across every facility and uploaded file β filter on the supplier (see Process per supplier below). The full per-key condition reference (UUID vs string conditions, limits) is on Create Audit Trail.
Configure exclusions and justifications on the record:
Bulk updates β set justification texts for many plots in one call:
PATCH /regulations/eudr/audit-trail/:recordId/config
{
"set_justifications": {
"plot-uuid-2": { "text": "Managed timber plantation, verified by local authority" }
}
}
The per-plot config endpoints were all removed in EPO-1983 β the
justificationpair, theexclude/includepair, andadd_excluded/remove_excludedonPATCH /config. Every one of them is replaced by the axis-generic pair below, and an exclusion is now a finding rather than a row in its own table. See the migration note further down.
add_excludedandremove_excludedanswer 400 naming the replacement. They are refused rather than ignored on purpose: a body pairingadd_excludedwith a justification edit would otherwise answer 200 having done half the work, and the caller would file a record still holding the plot they believed they had excluded.
Verdict overrides β one axis-generic endpoint set that replaces the removed per-plot justification pair:
PUT /regulations/eudr/audit-trail/:recordId/verdict-overrides
DELETE /regulations/eudr/audit-trail/:recordId/verdict-overrides
GET /regulations/eudr/audit-trail/:recordId/verdict-overrides
A finding is a written, attributed statement about what Epoch computed β either overturning it or justifying it. Findings are keyed on (axis, subject_type, subject_id), so a re-PUT with the same triple replaces rather than duplicates.
| Axis | Grain β subject_id is | Effect on submit | Notes |
|---|---|---|---|
plot_compliance | a plot id | hard β an override to compliant clears the plots_non_compliant blocker | the same row the removed per-plot justification used to write |
legality | the record id (subject_type: "record") | soft β warns only | renders in the Article 9(1)(h) section |
exclusion | a plot id | none β attest-only, removes the plot from the filing | basis required, from the Article 10 vocabulary |
Three things only these endpoints can do:
- Back a finding with a document. Send
document_attachment_id(upload it via the Attachments endpoints first) andprovenancebecomesdocument; the audit trail prints the file's name beside the attestation. Each axis accepts its own attachment categories. - Bound a finding in time.
valid_from/valid_toexist because FLEGT and SVLK licences are issued per shipment. When the clock passesvalid_tothe finding stops counting with no further call β the subject reverts to its computed verdict, leaves?include=compliant, and trips the submit gate again. The expired finding is retired, not deleted: it stays readable and stays printed, marked expired. - Attest a structured exclusion.
basison theexclusionaxis must be one ofnegligible_after_mitigation,pre_cutoff_production,outside_supply_base,otherβ so the reason is comparable across records rather than free text.de_minimis_areais retired and answers422: the EUDR has no de minimis area. Findings that already carry it still read.
Set verdict_override to flip the verdict; omit it to justify the computed one without changing it. An override never replaces what Epoch found β the report and the PDF carry both readings, and a legality clearance that moves the verdict also prints the move (Status: High (assessed) β Low (operator override)), so the assessed position cannot be hidden.
Once a version is filed to TRACES its findings are frozen: every write answers 409. Cut a new version to continue; findings carry forward with plot ids remapped.
Breaking changes (EPO-1983).
- The per-plot
PUT/DELETE /regulations/eudr/audit-trail/:recordId/plots/:plotId/justificationendpoints are removed. UsePUT/DELETE /verdict-overrideswithaxis: "plot_compliance". This is not a path swap: the removed PUT preserved an omittedcompliance_overrideandevidence_urls, while the replacement is a full replace (omitted βnull/[]). Send every field you want to keep. The response is the finding row, not an operation envelope with a previous/next delta β transition history is in the event log.- In the report JSON,
justification.textis nowjustification.narrative, on both the plot register'soverride.justificationblock and the standalonejustificationblock.Bulk
PATCH /configis unaffected and still takestextinset_justifications.
βΆ5. Cut a Version
Each record has an ordered set of immutable versions. A version snapshots the included plots + their assessments at cut time. Cut a new version (e.g. after editing the filter, processing fresh deforestation data, or changing exclusions) via:
POST /regulations/eudr/audit-trail/:recordId/tasks
{
"data": {
"type": "dds_record_task",
"attributes": {
"operation": "create_version",
"end_date": "2025-12-31",
"include_unaccounted_plots": false
}
}
}
β 202 Accepted
Returns a dds_record_task envelope; poll it via GET /regulations/eudr/audit-trail/:recordId/tasks/:taskId until status: "completed", then read result.version_id from the task body.
βΆ6. Export the Version
GET /regulations/eudr/audit-trail/:recordId/versions/:versionId/report.geojson
Returns the version's GeoJSON FeatureCollection β included plots only, with a minimal per-feature property set (plotid, producer_name, supplier_id, area_ha, noncompliance_area_ha, tags, compliance_verdict, non_agricultural_basis) and a top-level properties.dds_hash for audit. The full per-plot detail is on the plot_register in the JSON report below.
The JSON envelope is also available at β¦/versions/:versionId/report for the full report (operator/commodity/submission/deforestation_assessment blocks). To preview the record's current version without pinning a version id, use GET /regulations/eudr/audit-trail/:recordId/report (and β¦/report.geojson).
βΆ7. Submit DDS
Submit the version to EU TRACES asynchronously:
POST /regulations/eudr/dds/:recordId/submit
{
"data": {
"type": "dds_submission",
"attributes": {
"mode": "submit",
"activity_type": "IMPORT",
"country_of_activity": "NL",
"internal_reference_number": "ACME-2026-Q2-001"
}
}
}
β 202 Accepted
Authorisation required for on-behalf-of submission. Before the
mode: "submit"(SOAP) path will file, the operator's explicit "submit on my behalf" acknowledgement must be on record viaPOST /regulations/eudr/audit-trail/:recordId/authorisations. Without it, submit returns422 PRE_SUBMISSION_BLOCKEDwith anauthorization_missingblocker. Themode: "manual"path (recording an externally-filed DDS) does not require this.
One-call submit β finalise everything on the submit request. The submit
attributesaccept the same editable blocks asPATCH /audit-trail/:recordIdβname,operator,commodityβ plus the flat submission-metadata fields (activity_type,country_of_activity,internal_reference_number,geo_location_confidential,comment), so a single call can complete every TRACES-required input and file. Each is persisted onto the current version (merge-patch) before the pre-flight gates run; no separatePATCHis needed. The submission fields are validated strict β the identity fields (operator_role,associated_statements, fixed at create) and any unknown key are rejected400;country_of_activitymust be EU-27/XI and null forDOMESTIC. (PATCHitself editsname/operator/commodityonly β submission is submit-time.)
The submit gate == the record summary's
meta. Submit runs the samefindPreSubmissionBlockersthe record summary exposes asmeta.submission_blockers/meta.can_submitβ including the operator-completeness, resolved-country (EU-27+XI) and internal-reference checks (all uniform422 PRE_SUBMISSION_BLOCKEDblockers,operator_incomplete/country_of_activity_invalid/internal_reference_missing). Socan_submit: truewith an empty blocker list means the submit will pass its pre-flight gates.
Live TRACES acceptance pre-flight (EPO-1993). Beyond the local checks above, the byte-identical filing envelope is round-tripped against the EU TRACES acceptance environment β the authoritative check that catches opaque server-side rejections the local rules can't predict. Two entry points:
mode: "validate"onPOST /regulations/eudr/dds/:recordId/submitβ a synchronous dry-run. Returns adds_preflightverdict{ status: "accepted" | "rejected" | "unverified", checked_at, statement_count, errors[] }. Nothing is filed in production, and no plots are excluded β the non-compliant-plot auto-exclusion a real submit performs is computed in dry-run (so the verdict reflects exactly what would file) and rolled back, so validating never mutates your plot set, and it does not stamp an internal reference on your record. It does persist anyname/operator/commodityedits you send in the same call (identical to aPATCH), so the previewed envelope is byte-identical to what a submit would file.rejectedcarries the mapped TRACES fault + field detail;unverifiedmeans the acceptance env couldn't give an authoritative answer β it was unreachable, timed out, or answered about the caller (a permission or account problem) rather than about your statement.The TRACES V3 API has no dry-run operation, so the check works by filing the envelope into the EU acceptance (test) repository for real and withdrawing it again immediately. Your production filing is untouched either way.
mode: "submit"enforces it automatically: it reuses a green verdict for the exact current envelope (any edit invalidates it), else runs the check inline, and refuses to file (422with atraces_preflight_failedblocker) only when acceptancerejectedthe envelope. If the acceptance env is unreachable (unverified), the filing proceeds anyway β a deliberate fallback so an EC outage doesn't block all submissions (the production filing still runs its own validation).The last verdict is surfaced read-only on
GET /regulations/eudr/audit-trail/:recordIdasmeta.submission.submit.traces_preflight. The check also runs proactively at create time (once the record's version is written) and on a PATCH β in both cases only when the record is already submit-ready (local gate clean), so an acceptance rejection surfaces as early as creation/edit rather than only at submit. It is best-effort there (never fails the create task or the PATCH), so a freshly-created record surfaces atraces_preflightverdict without any extra call. On a PATCH the check runs in the background β the edit returns immediately and the refreshed verdict appears on a subsequentGETrather than in the PATCH response. Every attempt is logged with its verdict, and an unreachable acceptance env is logged distinctly.The pre-flight validates the compliant-only set β exactly what a submit would file. A
submitauto-excludes non-compliant plots before filing (compliant-only default), so every entry point (create / PATCH /validate/submit) drops those same plots from the round-tripped envelope. A record that still carries non-compliant plots therefore gets a realaccepted/rejectedverdict on its fileable remainder (not anunverified"plots not compliant"), and because the envelope matches, a latersubmitreuses that green verdict without a fresh round-trip. If every plot is non-compliant there is nothing to file β that's aplots_non_compliantlocal blocker, and no pre-flight runs.
Species name conformance (timber). TRACES value-validates each
speciesInfo.scientificNameagainst a taxonomy backbone: a name that isn't a real accepted binomial is rejected at submit with an opaque error. For timber commodities (HS chapters 44/47) the pre-submission gate resolves every declared scientific name against a curated snapshot of accepted binomials (sourced from the GBIF Backbone Taxonomy) and raises aspecies_name_unresolvedblocker when a name is malformed (not a binomial β a bare epithet,sp., or a delimited blob), is a known synonym, or is non-canonical (the binomial resolves, but the declared string carries extra tokens β an author authority likeQuercus robur L., an infraspecific tail, or a second species joined by a word β and that string is what TRACES receives) β the blocker'sissues[]carry the declared value, the reason, and (for a synonym or a non-canonical name) thesuggestionaccepted binomial to file instead. A structurally-plausible name that simply isn't in the curated snapshot is not blocked. This is distinct from thespecies_missingpresence/delimiter gate (EUDR Art 4(2)), which only checks that a complete, non-blended species list is present. Browse the accepted binomials and their synonyms viaGET /regulations/eudr/speciesto build a validated species picker.
Compliant-only default. A submission files only the compliant supply base. At submit, any included plot whose effective verdict is
non_compliant(alert-derived, or overridden to non_compliant) is auto-excluded from the filing and recorded in the audit log (config.exclusion_added,source: "system") β so a mix of compliant + non-compliant does not block;plots_non_compliantfires only when every plot is non-compliant (nothing would remain to file). An override to compliant keeps a plot in.
Plots awaiting human review are withheld too, on a separate ground. A plot carrying
plot_resolution: pending_reviewβ the automated adjudicator proposed a verdict and nobody has confirmed it β is auto-excluded at submit like a non-compliant one, but with its own narrative ("plot awaiting human review"), because withholding it is not an assertion that it is non-compliant. It does not block: a pending plot alongside compliant ones files the compliant remainder. This is decided on the review state itself, independently of the alert, so a plot awaiting review whose alert reads sub-threshold is still withheld rather than filed as compliant. An operator override on theplot_complianceaxis retires the pending state and returns the plot to the filing if it resolves compliant. The pending plot carries the adjudicator's suggested cause inassessment_driverand what accepting it implies insuggested_outcome(compliant, orexemptfor a non-agricultural land use). To accept anexemptoutcome, sendexemption_driverwith thecompliantoverride; the plot then reads exempt with that cause, and deleting the override withdraws both. When pending and non-compliant plots together leave nothing to file,plots_non_compliantfires and lists both, with a message naming the plots awaiting review. List the queue withfilter[plot_resolution]=eq:pending_reviewon the plots endpoint.
The response is a dds_record_task envelope (operation: submit). Poll GET /regulations/eudr/audit-trail/:recordId/tasks/:taskId until completion β result carries traces_reference_number, traces_verification_number, traces_dds_uuid, submitted_at. Under EUDR V3 the DDS UUID is returned synchronously; the reference + verification numbers are issued asynchronously by TRACES after acceptance (the audit-trail PDF shows them as "awaiting reference from TRACES" until then).
For records already filed via the EU portal directly, use mode: "manual" to record the TRACES identifiers inline (no SOAP call):
{
"data": {
"type": "dds_submission",
"attributes": {
"mode": "manual",
"reference_number": "DLE25/12345",
"verification_number": "ABC123",
},
},
}
Once a version is submitted it is permanently locked. Re-submitting the same version returns 409; submit another version by cutting v_{n+1} first.
βΆFast path: auto-create on upload
For straight-through, one-commodity uploads you can skip the explicit POST /regulations/eudr/audit-trail call. Set create_audit_trail: true on the ingestion upload together with metrics: ["deforestation"] (the flag returns 400 without deforestation, since the record must snapshot the signal):
POST /ingestions/assets
{
"location_type": "plot", // or "facility" β resolves to the plots beneath it
"commodity_type": "cocoa",
"metrics": ["deforestation"],
"create_audit_trail": true
}
The ingestion worker auto-creates the audit-trail record once the upload reaches a successful terminal. Poll the ingestion task (GET /ingestions/:taskId) until it carries audit_trail_task_id, then poll GET /regulations/eudr/audit-trail/tasks/:auditTaskId until it resolves the new record_id. Export the report at GET /regulations/eudr/audit-trail/:recordId/report.
The auto-created record uses the org's default operator and an empty commodity block β set the commodity via PATCH /regulations/eudr/audit-trail/:recordId before submit. Use the explicit path instead when you need to choose the plot selection, split into multiple records, or supply operator/commodity up front. See the EUDR Fast Path workflows for runnable, end-to-end recipes (plots and facilities).
βΆPDF pre-render
The audit-trail PDF is normally rendered on demand the first time it's requested, which for a large record can take a few minutes. To avoid that wait, the fast path pre-renders the PDF at record creation for single-asset records (all plots come from one upload). The PDF lands in a cache keyed to the record version, so consumers just download it β GET /regulations/eudr/audit-trail/:recordId/audit-trail/pdf returns 200 with a signed URL, no cold render.
POST /ingestions/assets
{
"location_type": "facility",
"commodity_type": "cocoa",
"metrics": ["deforestation"],
"create_audit_trail": true,
"create_audit_trail_pdf": false // optional β opt out of the pre-render
}
create_audit_trail_pdfβ defaults totrue. Set it tofalseto skip the automatic pre-render: the record is still created, but the PDF renders on demand on first GET (the earlier behaviour). Only meaningful alongsidecreate_audit_trail: true; ignored otherwise.- Aggregation records (built from more than one upload) are never pre-rendered, regardless of this flag β they render on demand on first GET.
- Pre-rendering is best-effort: if it fails, record creation still succeeds and the PDF simply renders on demand later.
βΆProcess per supplier
A common pattern is to file one DDS per supplier covering that supplier's entire footprint β every plot, across every facility, however many files it was uploaded in. Because suppliers are a first-class part of ingestion and the DDS filter, this is a four-step flow with no manual plot-id bookkeeping.
1. Upload each file with the supplier attached. Tag every upload that belongs to the supplier with the same identity β either per-row supplier columns or an upload-wide supplier object (see Suppliers). The same identity deduplicates to one registry row, so it doesn't matter how many files or facilities the supplier spans.
// File 1 and File 2 both carry the same supplier
POST /ingestions/assets
{
"location_type": "plot",
"commodity_type": "coffee",
"supplier": { "name": "CafΓ© Verde Cooperativa", "external_id": "CV-001", "country": "BR" }
}
2. Process deforestation on each upload β once staged, run PUT /ingestions/assets with metrics: ["deforestation"] (filter-scoped) and wait for the tasks to complete, exactly as in steps 2β3 above. (Upload and processing are distinct stages: the POST registers the file and its supplier; the PUT runs the metric.)
3. Create one DDS filtered by the supplier. Use a supplier_* filter β supplier_id (the registry UUID) or supplier_external_id (your supplierid) are the precise choices; supplier_name works for ad-hoc lookups. The resolver gathers every plot whose staged location is linked to that supplier, regardless of which facility or file it came from:
POST /regulations/eudr/audit-trail
{
"data": {
"type": "dds_record",
"attributes": {
"name": "CafΓ© Verde β all facilities",
"filter": { "supplier_id": { "eq": "2fdff3fb-480c-43b0-84d8-6dd8aaacce61" } }
// operator + commodity blocks optional β see step 4
}
}
}
You can find the supplier's
idfromGET /parties/suppliers(registry) or, on an existing record, from the per-supplier rollup atGET /regulations/eudr/audit-trail/:recordId/suppliers. Combinesupplier_*with other keys (e.g. addcommoditywhen a supplier ships more than one commodity) to keep the resolved set homogeneous.
4. Cut, export, and submit the version as in steps 5β7. The supplier identity is resolved per shed at version-create time and surfaces on GET /regulations/eudr/audit-trail/:recordId/suppliers and in the report's header.supplier; it is locked in at submit.
See Suppliers β Build a DDS for one supplier for the full supplier_* key table.
βΆFind the records covering a location
GET /regulations/eudr/audit-trail lists the working records visible to the caller. Narrow it with filter[<key>]=<condition>:<value> (the shared querystring filter convention). The supported keys are:
| Key | Conditions | Matches |
|---|---|---|
location_id | eq, in | Records whose current version contains the location identified by this staged-location UUID (the /locations resource id) β resolved like the locations API: a plot id matches that plot, a facility/shed id matches any record covering a plot under it |
plot_id | eq, in | Records whose current version contains this plot (plot.id) |
facility_id | eq, in | Records whose current version contains any plot under this facility (facility.id) |
assets.task_id | eq, in | Records that pulled at least one plot from this ingestion task |
commodity, country_code | eq, in | Records with this scope commodity / country |
supplier_name | eq, contains, startsWith | Records scoped to a matching supplier name |
supplier_external_id | eq, in | Records scoped to a supplier with this per-org external id |
status | eq:submitted | Only filed records |
These read filters are symmetric with the create-body selectors β the same location_id / plot_id / facility_id you used to build a record finds it again. So to answer "which DDS was created for this location?", take the location's id from GET /locations/* and call:
GET /regulations/eudr/audit-trail?filter[location_id]=eq:<location_id>
Conditions across different keys AND together. A plot can appear in more than one record, so a location filter may return several records ("touches this location" semantics). location_id and plot_id must be UUIDs (a malformed value returns 400).
βΆFrom the asset side
Coming the other way β "what DDS covers this row?" β GET /ingestions/assets and
its deduped and per-task siblings accept ?include=audit_trails, which resolves
the record set across the row's whole supersession lineage and flags whether each
filing was pinned from a record that is still current. See
List All Ingestion Assets.
βΆTwo caveats on the compliance counts
attributes.deforestation on an asset row (and the filter[compliance] verdict
built from it) is a roll-up over a location's catchment plots. Two things move
those numbers, and both are worth knowing before reconciling a figure recorded
earlier:
- It counts the plots of current records only. A plot whose record a later upload replaced is excluded. A total that fell after a re-upload usually means this rather than lost data.
- It still double counts across entity generations for a small number of locations, until the read-side lineage fix lands: one current record's location can own two generations of plots and both are counted.
Note also that attributes.deforestation is the compliance roll-up, while
relationships.metrics.deforestation (?include=metrics.deforestation) is the
measured deforestation metric. Same word, different figures β see
Deforestation Methodology.
βΆConcurrency & Locking Rules
| Rule | Behaviour |
|---|---|
| One task per record at a time | POST /:recordId/tasks and POST /:recordId/submit return 409 if a task is in flight |
| Version locked after submission | Re-submitting the same version returns 409 |
| Read-only endpoints always available | Versions, plots, report, tasks all readable after submission |
βΆAPI Endpoints Summary
The working lifecycle lives under /regulations/eudr/audit-trail; the submitted surface (list / get / submit) lives under /regulations/eudr/dds.
βΆRecords & Configure
| Method | Path | Description |
|---|---|---|
| POST | /regulations/eudr/audit-trail | Create a record + version 1 from a plot filter (async) |
| GET | /regulations/eudr/audit-trail | List working records visible to the caller (filterable by location_id / plot_id / facility_id / assets.task_id / supplier / commodity / country / status β see Find the records covering a location) |
| GET | /regulations/eudr/audit-trail/tasks/:taskId | Poll a record-less create task; carries result.record_id on completion |
| GET | /regulations/eudr/audit-trail/:recordId | Get a single record + current version |
| PATCH | /regulations/eudr/audit-trail/:recordId | Update operator / commodity attributes |
| PATCH | /regulations/eudr/audit-trail/:recordId/config | Bulk-update exclusions + justifications |
| GET/PUT | /regulations/eudr/audit-trail/:recordId/data-fields | Read / replace report data-field projections |
| GET | /regulations/eudr/audit-trail/:recordId/suppliers | Per-DDS supplier rollup |
| PATCH | /regulations/eudr/audit-trail/:recordId/suppliers/:supplierId | Per-DDS supplier override |
βΆPer-plot config
| Method | Path | Description |
|---|
The per-plot exclude / include pair was removed in EPO-1983 β use Verdict overrides with
axis: "exclusion".
The per-plot justification pair was removed in EPO-1983 β see Verdict overrides below.
βΆVerdict overrides
| Method | Path | Description |
|---|---|---|
| PUT | /regulations/eudr/audit-trail/:recordId/verdict-overrides | Create or replace the finding on one (axis, subject) |
| DELETE | /regulations/eudr/audit-trail/:recordId/verdict-overrides | Delete that finding β scoped to the named axis only |
| GET | /regulations/eudr/audit-trail/:recordId/verdict-overrides | List findings on the current version (?axis= to filter) |
βΆVersions, Plots & Reprocess
| Method | Path | Description |
|---|---|---|
| POST | /regulations/eudr/audit-trail/:recordId/tasks | Cut a new version / reprocess (async) |
| GET | /regulations/eudr/audit-trail/:recordId/tasks | List version + submit tasks |
| GET | /regulations/eudr/audit-trail/:recordId/tasks/:taskId | Get a single task envelope |
| GET | /regulations/eudr/audit-trail/:recordId/versions | List versions on the record |
| GET | /regulations/eudr/audit-trail/:recordId/versions/:versionId | Get a single version |
| GET | /regulations/eudr/audit-trail/:recordId/versions/:versionId/plots | List plots in the version |
βΆExport
| Method | Path | Description |
|---|---|---|
| GET | /regulations/eudr/audit-trail/:recordId/report | JSON report (current version) |
| GET | /regulations/eudr/audit-trail/:recordId/report.geojson | GeoJSON FeatureCollection (current version) |
| GET | /regulations/eudr/audit-trail/:recordId/versions/:versionId/report | JSON report (pinned version) |
| GET | /regulations/eudr/audit-trail/:recordId/versions/:versionId/report.geojson | GeoJSON FeatureCollection (pinned version) |
βΆAuthorisation & Submission
| Method | Path | Description |
|---|---|---|
| POST | /regulations/eudr/audit-trail/:recordId/authorisations | Record the operator's "submit on my behalf" acknowledgement |
| GET | /regulations/eudr/audit-trail/:recordId/authorisations | List authorisations on file |
| POST | /regulations/eudr/dds/:recordId/submit | Submit (async SOAP) or record (sync manual) |
| GET | /regulations/eudr/dds | List submitted DDS records |
| GET | /regulations/eudr/dds/:recordId | Get a submitted record + traces block |
| GET | /regulations/eudr/audit-trail/:recordId/traces | Live TRACES overview for the current version's filed statements |
| GET | /regulations/eudr/audit-trail/:recordId/versions/:versionId/traces | The same overview, pinned to one version |
| GET | /regulations/eudr/audit-trail/:recordId/traces/statement | The full as-filed statement TRACES holds |
| GET | /regulations/eudr/audit-trail/:recordId/versions/:versionId/traces/statement | The same statement, pinned to one version |
| POST | /regulations/eudr/dds/lookup | Look up any DDS by its identifiers (eudr:verify) |
βΆAudit Trail
| Method | Path | Description |
|---|---|---|
| GET | /regulations/eudr/audit-trail/:recordId/events | Append-only audit log for the record, newest-first |
| GET | /regulations/eudr/audit-trail/:recordId/versions/:versionId/events | Cumulative history up to a named version |
| POST | /regulations/eudr/audit-trail/:recordId/events | Append a user-authored audit entry |
See Audit Trail for the event shape, the full action vocabulary, and per-action payload contracts.
βΆArticle 10 Risk Assessment
EUDR Article 10 requires operators to assess the risk that commodities are non-compliant. The assessment is partial β the audit trail for potential audits should also include supplier-provided evidence and supporting documents. This risk assessment provides country- and region-specific supporting information.
The Epoch API evaluates each clause of Article 10(2) as follows:
(a) The assignment of risk to the relevant country of production or parts thereof in accordance with Article 29.
Country-level risk classifications are pending publication by the European Commission. Once published, they will be automatically integrated into the risk assessment. Until then, all countries are treated as standard risk.
(b) The presence of forests in the country of production or parts thereof.
Automatically assessed during deforestation checks on the geolocation data.
(c) The presence of indigenous peoples in the country of production or parts thereof.
The Native Land dataset is used to assess the presence of indigenous peoples in the country of production or parts thereof.
(d) The consultation and cooperation in good faith with indigenous peoples.
If presence of indigenous people is confirmed under (c), the operator or party managing compliance on behalf of the operator must provide further information about consultation and cooperation with indigenous peoples.
(e) The existence of duly reasoned claims by indigenous peoples regarding the use or ownership of the area used for producing the relevant commodity.
If presence is confirmed under (c), the operator must provide further information about the existence of claims regarding use or ownership of the production area.
(f) Prevalence of deforestation or forest degradation in the country of production or parts thereof.
Automatically assessed during deforestation checks on the geolocation data, using regional deforestation rates from the analysis engine.
(g) The source, reliability, validity, and links to other available documentation of the information referred to in Article 9(1).
All information provided under Article 9 about information requirements is supported by the audit trail PDF document generated by the API, as well as Epoch's system of record accessible at epoch.blue.
(h) Concerns in relation to the country of production and origin or parts thereof.
The following indicators are assessed:
- Corruption: Transparency International's Corruption Perceptions Index for the latest available year.
- Prevalence of document and data falsification and Lack of law enforcement: World Bank's Worldwide Governance Indicators for the latest available year: Voice and Accountability (VA), Political Stability and Absence of Violence/Terrorism (PV), Government Effectiveness (GE), Regulatory Quality (RQ), Rule of Law (RL), Control of Corruption (CC).
- Violation of international human rights: Freedom in the World's Freedom Index for the latest available year.
- Armed conflict: The UCDP database is used to identify countries and parts thereof where conflicts are ongoing.
- Presence of sanctions: Sanctioned entities in the relevant country listed by the UN Security Council or the EU.
(i) The complexity of the relevant supply chain and the stage of processing of the relevant products.
Assessed across the following categories:
- Number of tiers in the supply chain β to be provided by the operator or supply chain management software provider.
- Processing complexity β assessed using the commodity complexity matrix (see below).
- Traceability system (directly traceable or mass balance) β to be provided by the operator.
- Known land plots β if deforestation checks were carried out via supply shed analysis (geo-locations not manually provided), this indicates higher supply chain complexity (collecting geo-locations manually is difficult due to supply chain size and fragmentation). If geo-locations were exhaustively collected by the supplier, this indicates low to moderate complexity, unless the number of plots is in the tens of thousands.
(j) The risk of circumvention or mixing with products of unknown origin.
Based on: (1) the complexity of the supply chain (see (i)); and (2) the prevalence of deforestation and forest degradation in the surrounding regions from where the commodity is sourced (see (f)).
(k) Conclusions of the meetings of the Commission expert groups.
To be provided by the European Commission as published in the Commission's expert group register.
(l) Substantiated concerns submitted under Article 31, and information on the history of non-compliance.
To be provided by the European Commission.
(m) Any information that would point to a risk that the relevant products are non-compliant.
Beyond what is provided by the Epoch compliance service, additional information is to be provided by the operator or supply chain management software provider.
(n) Complementary information on compliance, including information supplied by certification or other third-party verified schemes.
To be provided by the operator, including voluntary schemes recognised by the Commission under Article 30(5) of Directive (EU) 2018/2001, provided they meet the requirements of Article 9.
βΆComposite Article 10 Risk Score
The Article 10.2 criteria above are combined into a deterministic composite Article 10 risk score (tier: low / medium / high / not_assessed) that classifies each supply base. The canonical scorer is services/article10_scoring.py::calculate_article10_points (with calculate_article10_score); the historical risk_assessment.py::calculate_legality_score now delegates to it, so the plot/supply-shed path and the facility path score identically. The rules below reproduce the score by hand from the same inputs.
βΆFactors that add risk points
max_points counts only the criteria actually populated, so a criterion with no signal contributes to neither the score nor the tier. For criteria with multiple thresholds, only the highest matching tier applies.
| Criterion | Trigger | Points |
|---|---|---|
| Forest presence (b) | any forest area > 0 ha within the supply base | +1 |
| Indigenous-territory presence (c) | Native Land overlay returns "yes" | +1 |
| Indigenous consultation / land claims (d, e) | indigenous-tagged adverse media (land_rights / indigenous_conflict): same district/province (local/regional) or β₯ 3 country-level sources | +1 |
| a source naming the supplier (facility relevance) | +2 | |
| Deforestation prevalence (f) | regional deforestation risk "medium" (rate > 2 %) | +2 |
| risk "high" (rate > 5 %) | +3 | |
| otherwise any deforestation present (rate > 0.5 %) | +1 | |
| CPI corruption (h) | CPI score < 70 | +1 |
| CPI score < 50 | +2 | |
| CPI score < 30 | +3 | |
| WGI governance deficit (h) | average of the six WGI dimensions < 50 | +1 |
| average < 25 | +2 | |
| Armed-conflict presence (h) | UCDP conflict-intensity signal present (level 1) | +1 |
| high intensity (level β₯ 2) | +2 | |
| Sanctions (h) | EU/UN sanctions match (GSDB, filtered to sanctioning_state matching UN or EU at data-load time) | +2 |
| Supply-chain complexity (i) | processing-complexity tier 3 (commodity matrix below) | +1 |
| tier 4 or 5 | +2 | |
| Circumvention / mixing (j) | risk "medium" | +2 |
| risk "high" | +3 | |
| Protected-area overlap (supplementary) | WDPA overlap count > 0 | +1 |
Bare indigenous-territory presence (c) is a 1-point flag. Article 10.2(d) consultation/FPIC and (e) duly-reasoned land claims are scored separately, from the indigenous-tagged adverse media (land_rights / indigenous_conflict) the open-web sweep surfaces, by source relevance β so "indigenous present, no adverse evidence" scores +1 (presence only) while genuine consultation-failure or land-claim evidence adds up to +2. (d) and (e) share the evidence pool and contribute one combined escalator. On the facility-screening path the sweep runs inline; on the DDS supply-shed path the per-supplier sources gathered for the Legality section are reused (no extra calls). Protected-area overlap is a supplementary investigate-flag, not an Article 9 legality determination β whether an overlap is unlawful depends on national law and the IUCN category.
βΆFactors that discount risk points (recognising strong governance)
Strong rule-of-law offsets accumulated risk points so the composite stays conservative and is not driven into medium/high by the country (h) criteria alone. The discount reduces the point total (floored at 0); it does not change max_points.
| Trigger | Discount |
|---|---|
| WGI average β₯ 50 | β1 |
| WGI average β₯ 60 | β2 |
| WGI average β₯ 75 | β3 |
| affirmative "no conflict" and "no sanctions" | additional β1 |
The composite is max(0, points β discount).
Conservative by design. Article 10.2(h) country signals (corruption, conflict, sanctions) are scored and offset by governance, so most standard-risk sourcing lands low or medium; high (enhanced DD) is reserved for genuinely elevated risk β active conflict, sanctions, or severe corruption stacked with on-the-ground deforestation. The UCDP (conflict) and GSDB (sanctions) datasets list only affected countries, so the lookup is floored: a country absent from them reads as "no conflict" / "no sanctions" rather than inheriting the nearest fuzzy-matched row. Article 29's country tier carries the country-risk routing (simplified vs full DD) alongside this composite, not instead of it.
βΆRisk classification
Raw points are normalised to a 0..1 score (points / 11, capped at 1.0) so the point tiers and the score bands agree at every boundary:
- Low β 0β4 points (score < 0.4) β standard due diligence may be appropriate.
- Medium β 5β7 points (0.4 β€ score < 0.7) β standard-to-enhanced due diligence, targeted monitoring recommended.
- High β 8+ points (score β₯ 0.7) β enhanced due diligence and Art. 11 risk-mitigation measures required.
- Not assessed β no scorable signal (e.g. no valid country / address).
The score is not a pass/fail. It is a structured, auditable basis for the operator's compliance determination. Per-criterion contributions are exposed in the audit trail so an operator (or a CA inspector) can re-derive any score from the underlying inputs.
βΆField naming: legality vs. Article 10
Two distinct risk signals, easy to confuse because of a historical field name:
- Article 10 composite (this section) is persisted under the key
legality_score(and FirestorelegalityRisk). The name is historical; the value is the procedural country / governance composite scored above. - Article 9 legality (adverse-media) is a separate, producer-attributable signal from open-web screening, persisted as
legality_risk_score/legality_risk_level(now also emitted asadverse_media_risk_score/adverse_media_risk_level). It is independent of the Article 10 composite and of the per-plot deforestation verdict.
The canonical Article 10 composite is also emitted under its own names β article10_risk_score (0..1) / article10_risk_level β alongside the scoring arithmetic the audit trail surfaces: article10_raw_points (pre-discount), article10_governance_discount, article10_points (final), and article10_max_points. The DDS audit-trail Article 10 block renders these as a one-line "raw N Β· governance βM Β· final K / max β tier" summary so a competent authority can read the derivation at a glance.
βΆDeforestation verdict fields
Returned per plot on the EUDR ingestion-results endpoint (see Β§Process Deforestation above):
deforestation_alert: one ofcritical/non-critical/no deforestation. Onlycriticalsetseudr.is_compliant = falseand contributes to the non-compliance area and plot counts.deforestation_confidence: one ofvery high/high/medium/low/very low/no detection.eudr_country_risk_tier: the Article 29 routing gate (low/standard/high), distinct from the composite score above.
βΆSupply Chain Complexity Matrix
| Commodity | Typical Processing Stages | Complexity (1-5) | Notes |
|---|---|---|---|
| Soy | Soybeans β oil, meal β animal feed β meat/dairy | 4 | Often mixed, cross-border crushing, feed traceability is hard |
| Oil palm | Fresh fruit bunches β crude palm oil β refined oil | 5 | Highly blended, many processors, smallholders involved |
| Cattle | Cattle β meat, leather β processed food/fashion | 5 | Indirect suppliers common, land linkage is difficult |
| Cocoa | Cocoa beans β liquor β butter/powder β chocolate | 4 | Often mixed at coop/exporter level, many smallholders |
| Coffee | Cherries β beans β roasted β retail packs | 3 | Some traceability via co-ops; roasting adds complexity |
| Rubber | Latex tapping β sheets β processing β rubber goods | 4 | Mixed supply at processors, high smallholder participation |
| Timber | Logs β lumber/panels/pulp β furniture, paper | 3 | Depends on chain of custody; easier with FSC/PEFC |
βΆDDS Export Package
The DDS export produces a ZIP archive containing:
GeoJSON files (one per collection) with geolocation data ready for EU TRACES submission. Each feature includes plot geometry, identifiers, deforestation results, and compliance classification.
PDF audit trail providing a human-readable summary:
- Operator and commodity details
- Summary of all plots assessed with compliance status
- Country and plot-level maps with deforestation alert colouring
- Deforestation statistics and annual deforestation chart
- Low-compliance plot cards with thumbnails and justifications
- Article 10 risk assessment results
- Methodology description and data source references
- Timestamp and version information
Each GeoJSON should be submitted separately per HS code/supplier as per EU TRACES requirements. The PDF should be retained as part of the audit trail for up to 5 years after DDS submission.
The PDF stays in sync with the audit trail automatically. It is tied to the exact state of the version, so any change β excluding/including a plot, editing a justification or compliance override β marks the stored PDF stale and triggers an automatic rebuild. The download endpoint (
GET .../audit-trail/pdf) returnsstaleandrebuildingflags alongside the link so a UI can show an "outdated β regeneratingβ¦" state and re-fetch once they clear. You can also force a rebuild on demand withPOST .../audit-trail?force=true. Once a version is submitted to TRACES its PDF is frozen β it is never marked stale, and rebuild requests (includingforce) return409. See the Audit Trail PDF endpoints for details.
βΆDDS Submission to EU TRACES
The API submits a DDS version to EU TRACES via POST /regulations/eudr/dds/:recordId/submit. The operator, commodity, and HS-code metadata is taken from the record (set at create time via POST /regulations/eudr/audit-trail and editable via PATCH /regulations/eudr/audit-trail/:recordId). The submit body is a small discriminated union:
// Async SOAP submission (default)
{ "data": { "type": "dds_submission", "attributes": { "mode": "submit" } } }
// Inline record-only β for operators who filed via the EU portal directly
{
"data": {
"type": "dds_submission",
"attributes": {
"mode": "manual",
"reference_number": "DLE25/12345",
"verification_number": "ABC123",
"dds_uuid": "...", // optional
"submitted_at": "..." // optional ISO; defaults to now()
}
}
}
The async path returns 202 + a dds_record_task envelope (operation: submit). The manual path returns 200 + a dds_submission envelope with the supplied identifiers.
βΆHS codes TRACES accepts
Declare commodity.hs_code to at least the digits Annex I lists for your product, up to 6 (EUDR FAQ 7.24). A shorter code is blocked at submit with hs_code_too_broad, whose message names the codes TRACES accepts; hs_code_not_accepted_by_traces does not fire as well.
Being in Annex I is not enough. TRACES checks the code against its own exact list and rejects anything else with EUDR_COMMODITIES_HS_CODE_INVALID. fm-api checks every record against a reviewed list based on TRACES acceptance probes before it files. This list can deliberately remain stricter than the latest acceptance behaviour:
- Coffee accepts the bare heading or a listed subheading. TRACES acceptance accepts bare
0901(confirmed for IMPORT and EXPORT on 2026-10-19), and fm-api now allows it alongside the five listed subheadings. Production has not been verified. Declare0901or one of090111,090112,090121,090122or090190. - Some products can't be filed today. TRACES accepts no code for the printed products of chapter
49, although they are in EUDR scope. A record for them is blocked until TRACES adds a code. - Before you submit, look a code up with
GET /regulations/eudr/scope?hs_code=β¦.traces_acceptedreports fm-apiβs reviewed acceptance policy,traces_wire_hs_codeis what fm-api would send, andaccepted_hs_codeslists what to declare instead.GET /regulations/eudr/scope/entrieslists the accepted codes of every Annex I entry (traces_hs_codes); filter it withtraces_fileable=trueto build a picker.
A code refused by fm-apiβs reviewed acceptance policy, with no shorter form it accepts, raises the hs_code_not_accepted_by_traces blocker on GET /regulations/eudr/audit-trail/:recordId and a 422 PRE_SUBMISSION_BLOCKED at submit. Its accepted_hs_codes lists the codes to declare instead:
{
"type": "hs_code_not_accepted_by_traces",
"hs_code": "401290",
"wire_hs_code": "401290",
"accepted_hs_codes": [
{
"code": "40129030",
"description": "Solid or cushion tyres, tyre treads and tyre flaps, of rubber"
}
],
"message": "TRACES does not accept hs_code 401290. Declare one of 40129030 via PATCH /regulations/eudr/audit-trail/:recordId before submitting."
}
Clear it by declaring one of them with PATCH /regulations/eudr/audit-trail/:recordId:
{ "data": { "type": "dds_record", "attributes": { "commodity": { "hs_code": "40129030" } } } }
When accepted_hs_codes is empty the message reads TRACES accepts no HS code for hs_code <code>; this product currently can't be filed through TRACES. No PATCH clears it.
A horizontal exemption (commodity.exemption_claim) waives only the Annex-scope blockers, out_of_scope_hs_code and hs_code_too_broad. It does not waive hs_code_not_accepted_by_traces or the supplementary-unit blockers below, because the filing still goes to TRACES. A claimed code outside Annex I goes on the wire as its first six digits. It is blocked only when the probe saw TRACES reject that code; a code the probe never tried raises low_confidence_hs_code instead.
What to declare (scope version 2026-da). "Sent to TRACES as" is the value fm-api files for the codes in the "Declare as" column.
| Commodity | Declare as | Sent to TRACES as | No accepted code today |
|---|---|---|---|
| Cattle | 0102, 010221, 010229 | 0102, with no supplementary unit | β |
| Cattle | 0201, 0202, or any HS6 under them; 020610, 020622, 020629, 160250 | as declared | β |
| Cocoa | 1801, 1802, 1804, 1805 (or XXXX00) | the 4-digit heading | β |
| Cocoa | 1803, 1806, or any HS6 under them | as declared | β |
| Coffee | 0901, 090111, 090112, 090121, 090122, 090190 | as declared | β |
| Palm | 1511 or any HS6 under it; 120710, 151321, 151329, 230660, 290545, 291570, 291590, 382311, 382312, 382319, 382370 | as declared | β |
| Rubber | 4001, 4005, 4006, 4008, 4011, 4013, 4015, or any HS6 under them | as declared | β |
| Rubber | 40129030 (tyre treads and flaps; never 401290) | 40129030 (8 digits) | β |
| Rubber | 4007, 4017 (or XXXX00) | the 4-digit heading | β |
| Soy | 1507 or any HS6 under it; 120190 (from CN 12019000); 120810 | as declared | β |
| Soy | 2304 (or 230400) | 2304 | β |
| Timber | 4401β4404, 4406β4412, 4414, 4415, 4418β4421, or any HS6 under them | as declared | β |
| Timber | ex chapter 49 (printed products), or any code under it | β | ex chapter 49 |
| Timber | 94019190 (parts of wooden seats; never 940191) | 94019190 (8 digits) | β |
| Timber | 4405, 4413, 4416, 4417 (or XXXX00) | the 4-digit heading | β |
| Timber | chapters 47, 48, or any 4- or 6-digit code under them except a zero tail and the excluded codes | as declared | β |
| Timber | 470100, 470200, 470500, 480100, 480300, 480700, 481200 | the 4-digit heading | β |
| Timber | 940330, 940340, 940350, 940360, 940391, 940610, 940131, 940141, 940161, 940169, 940180 (the last five are their own 2026-da entries, CN 94013100β94018000; 9401 itself is superseded) | as declared | β |
A code of 5 digits is sent as its 4-digit heading. An 8-digit CN code is sent as its 6-digit subheading (44071000 β 440710), except 40129030 and 94019190, which TRACES takes only in 8 digits.
βΆTruncation and advisories
fm-api sometimes sends TRACES a shorter code than you declared: the longest form of it that TRACES accepts. The stored hs_code on your record is unchanged; only the wire value differs.
010229β0102: TRACES takes live cattle only as the heading.180100β1801: no HS heading ends in00, so a zero tail is cut.44031β4403: 5 digits is not an HS level.
A declared supplementary unit is also not sent where TRACES refuses one (see Supplementary units). The net weight is still filed.
GET /regulations/eudr/audit-trail/:recordId reports both under meta.submission.advisories. Advisories do not block; can_submit is unaffected.
| Advisory | When | Example message |
|---|---|---|
hs_code_normalised | TRACES accepts only a shorter form | TRACES accepts this product only as 0102; hs_code 010229 will be submitted to TRACES as 0102. Confirm 0102 describes your product. |
hs_code_normalised | The code is not 2, 4 or 6 digits | hs_code 44031 is not a 2-, 4- or 6-digit HS code; it will be submitted to TRACES as 4403. |
hs_code_normalised | A zero tail (180100, 18010000) | hs_code 180100 will be submitted to TRACES as its Annex I heading 1801 (CN 1801); confirm this heading still describes your product. |
supplementary_unit_dropped | IMPORT or EXPORT, TRACES accepts the code, and a unit is stored on a heading TRACES takes none on | TRACES does not accept a supplementary unit on 4407; the supplementary unit (1.5 MTQ) will not be submitted. The net weight is still filed. |
low_confidence_hs_code | TRACES does not accept the code, and it is broader than its Annex entry, matches only a chapter, matches no entry, or is in scope under an 8-digit Annex entry (cut to 6 digits on the wire) | hs_code <code> will be submitted to TRACES as <wire>, which TRACES may reject (EUDR_COMMODITIES_HS_CODE_INVALID). β¦ |
low_confidence_hs_code is never raised for a code TRACES accepts.
Where the filed values appear.
- Before filing,
meta.submission.wire_hs_codeonGET /regulations/eudr/audit-trail/:recordIdis the code a filing will send. If you file via the EU portal directly (mode: "manual"), enter this value, not the storedhs_code. - After filing, each entry of
traces_statements[]on the record carrieslines: per commodity line, thehs_headingandsupplementary_unitfm-api actually sent (nullwhen no unit was sent).linesis empty for a manual filing, or for one filed before fm-api recorded lines. - The
version.submittedaudit event recordswire_hs_code. - The audit PDF of a filed version adds "HS code sent to TRACES" when the filed code differs from the stored one, and "Supplementary unit sent to TRACES" when a stored unit was not sent.
Every submit and validate call also runs a live pre-flight against the TRACES acceptance environment before it files, so a code TRACES would still refuse is reported as a 422 PRE_SUBMISSION_BLOCKED blocker rather than a late failure.
βΆSupplementary units
On IMPORT and EXPORT, TRACES requires a supplementary unit on some headings and refuses one on every other:
| Heading | Supplementary unit |
|---|---|
4403, 4406, 4408, 4410, 4411, 4412, 4413 | required, MTQ (cubic metres) |
4011, 4013 | required, NAR (number of items) |
4701, 4702, 4704, 4705 | required, KSD (kilograms of dry substance) |
every other in-scope code, including 0102, 4407, 4703, 47, 48 | not allowed; fm-api drops it on IMPORT and EXPORT, with an advisory |
The heading is that of the code sent to TRACES. On DOMESTIC a unit is never needed, and one that is declared is sent as is.
Two blockers enforce the required headings:
supplementary_unit_requiredβ no unit is stored. Message:TRACES requires a supplementary unit in MTQ for heading 4403 on IMPORT and EXPORT. Set supplementary_unit and supplementary_unit_qualifier ("MTQ") via PATCH /regulations/eudr/audit-trail/:recordId before submitting.supplementary_unit_qualifier_mismatchβ the stored unit is the wrong one. Message:Heading 4403 takes its supplementary unit in MTQ, but the record has NAR. Correct supplementary_unit_qualifier (and the amount) via PATCH /regulations/eudr/audit-trail/:recordId before submitting.
Both carry hs_code, wire_hs_code, heading and the required qualifier; the mismatch adds stored_qualifier. Clear either by setting the pair:
{
"data": {
"type": "dds_record",
"attributes": {
"commodity": { "supplementary_unit": 12.5, "supplementary_unit_qualifier": "MTQ" }
}
}
}
βΆExcluded products
Annex I excludes these products, so they are out of EUDR scope and need no DDS. GET /regulations/eudr/scope returns out_of_scope_kind: "annex_exclusion" and dds_required: false for them.
| Product | Codes |
|---|---|
| Recovered fibre | recovered-paper pulp 470620; recovered paper 4707 |
| Bamboo | bamboo pulp 470630; bamboo tableware 482361 |
| Non-wood fibre | cotton-linters pulp 470610 |
| Not genus Bos | buffalo 010231, 010239; other bovines 010290 |
HS code, quantity and description are never assumed. A record takes its hs_code from the commodity block you send, then from the uploaded rows, then from the upload's top-level hs_code. Its product_quantity_kg and product_description come from the commodity block or the rows. A field none of these supply stays empty, and meta.submission reports a commodity_missing blocker until you set it with PATCH /regulations/eudr/audit-trail/:recordId. A new version of a record carries forward an HS code or quantity you set with PATCH; otherwise it takes them from the new upload.
βΆDDS Status Retrieval
Once a submit-mode task completes, the TRACES identifiers live on the task's attributes.result:
GET /regulations/eudr/audit-trail/:recordId/tasks/:taskId
β {
"data": { "attributes": {
"operation": "submit",
"status": "completed",
"result": {
"version_id": "...",
"version_number": 2,
"traces_reference_number": "DLE25/12345",
"traces_verification_number": "ABC123",
"traces_dds_uuid": "...",
"submitted_at": "..."
}
} }
}
| Field | Meaning |
|---|---|
traces_reference_number | Reference number issued by TRACES |
traces_verification_number | Verification number for customs clearance |
traces_dds_uuid | TRACES-issued DDS UUID (nullable) |
submitted_at | ISO submission timestamp |
βΆReading the filing back from TRACES
The fields above are what fm-api stored at submit time, refreshed by a background reconciliation sweep. To ask TRACES directly what it holds right now, use the read-through endpoints.
| Method | Path | Returns |
|---|---|---|
| GET | /regulations/eudr/audit-trail/:recordId/traces | One row per filed statement: the stored identifiers and status beside the live ones TRACES reports |
| GET | /regulations/eudr/audit-trail/:recordId/traces/statement | The complete statement TRACES accepted β commodities, producers, geolocations |
| POST | /regulations/eudr/dds/lookup | Any DDS by identifier, including one filed by a third party |
Both GET routes also exist pinned to a historic version under
/versions/:versionId/β¦, for auditing what an earlier submission contained.
The overview never fails on TRACES being down. meta.traces.reachable
reports whether the live call landed; when it is false the stored values
are still served, so a transient outage degrades the response rather than
breaking it. Comparing stored against live is how you spot drift before
the sweep next runs.
The statement is a separate, heavier call. A single statement can carry
up to 25 MiB of geometry across hundreds of producers, so it is never folded
into the overview. It needs the statement's reference and verification
number to have been issued β until then it answers 409 TRACES_REFERENCE_PENDING. Use filter[statement_index] to select one
statement of a split (batched) filing; it defaults to 0. Nothing retrieved
is persisted: TRACES is the durable store for an as-filed statement, and this
is what makes it recoverable.
Manual filings need eudr:verify. When a DDS was recorded with
mode: "manual", its identifiers came from you rather than from TRACES, so
nothing in our data proves your organisation owns them. The overview withholds
the live half for those statements unless the key carries eudr:verify, and
the statement route answers 403. Filings made through mode: "submit" are
unaffected.
βΆLooking up a DDS by identifier
POST /regulations/eudr/dds/lookup takes exactly one of two body forms, and
requires the eudr:verify scope.
// Your own filing, by the internal reference you set at submit
{ "internal_reference": "E-3dbd4cd2f9d74c7aa6f726dde64a3924" }
// A third party's DDS, by the numbers they gave you
{ "reference_number": "24NL30EB7F3B4D", "verification_number": "C5D39C79" }
The internal_reference form resolves against your organisations before
any TRACES call, so a reference that is not yours never reaches TRACES. It
answers with the resolved identifiers plus a best-effort live overview and
verify verdict; either may be null if TRACES is briefly unreachable.
The identifiers form has nothing to scope and nothing to fall back to, so it
returns the bare verifyDeclaration verdict β EXISTING_USABLE,
NON_USABLE or NON_EXISTENT β with no overview, statement or geometry. An
unreachable or rejecting TRACES fails the request (502/503) rather than
returning a half-empty 200. This is the endpoint to use when a supplier
hands you a reference and verification number and you need to confirm the
statement behind it is real and usable.
A fixed allowance of 20 requests per minute applies to this route, separate from your organisation's normal rate limit, to bound how fast a key can probe identifiers.