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-trail with a plot filter). For a one-call shortcut, set create_audit_trail: true on the ingestion upload (alongside metrics: ["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: true on ingestion (only valid when metrics includes '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 between is_compliant: true and is_compliant: false for plots near the de minimis threshold. Defaults to false (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 β€” no eudr nesting. See Metric Methodology β€” Key Response Fields for the metric response shape and the rules governing is_compliant (which is null outside the Β±14-day EUDR window). See Thumbnail Interpretation for the visual output guide.

Task-level roll-up: once processing completes, add ?include=deforestation on GET /ingestions, GET /ingestions/:id, or GET /ingestions/:id/summary to 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 under attributes.deforestation on 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/assets via ?include=deforestation (or ?deforestation=<view>). Each asset carries the roll-up for its own location_id under attributes.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 with filter[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 commodity block and the version's commodity columns are left null. Populate them via PATCH /regulations/eudr/audit-trail/:recordId before submit (the commodity_missing pre-submission blocker enforces this). The operator block is likewise optional and falls back to the org's stored operator defaults when omitted.

PATCH edits name, operator and commodity only. The commodity block includes the optional goodsMeasure supplementary-unit pair β€” supplementary_unit (amount) + supplementary_unit_qualifier (MTQ cubic metres / NAR head count; net weight in kg always rides on product_quantity_kg). The submission metadata (activity type, country of activity, etc.) is not editable via PATCH β€” it is set at create and finalised on the submit call (see step 7). The single-record GET /audit-trail/:recordId returns a meta.editable_fields descriptor (field name/label/type/options/conditions) of exactly what PATCH accepts, plus meta.can_submit and meta.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:

GroupKeys
Locationlocation_id
Plot / geographyplot_id, country_code, commodity, area_ha, tag
Facilityfacility_id, facility_name (normalized name)
Suppliersupplier_id, supplier_external_id, supplier_name, supplier_address_line1, supplier_address_line2, supplier_city, supplier_region, supplier_postcode, supplier_country, supplier_statement
Source assetassets.task_id, assets.filename, assets.data.<column>

location_id β€” select by an opaque /locations id. location_id accepts the staged-location UUID surfaced as the resource id on GET /locations/*, and resolves it the same way the locations API drills: a plot location_id selects that plot; a facility location_id selects every plot under the facility (facility β†’ shed β†’ plot); a shed location_id selects every plot under the shed. Use it when you hold a /locations id and don't want to care whether it's a plot, shed or facility. Use plot_id / facility_id instead when you specifically hold a plot.id or facility.id β€” e.g. the values under a location's tile_ids. (Note: a facility's location_id is not the same value as its facility.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 justification pair, the exclude / include pair, and add_excluded / remove_excluded on PATCH /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_excluded and remove_excluded answer 400 naming the replacement. They are refused rather than ignored on purpose: a body pairing add_excluded with 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.

AxisGrain β€” subject_id isEffect on submitNotes
plot_compliancea plot idhard β€” an override to compliant clears the plots_non_compliant blockerthe same row the removed per-plot justification used to write
legalitythe record id (subject_type: "record")soft β€” warns onlyrenders in the Article 9(1)(h) section
exclusiona plot idnone β€” attest-only, removes the plot from the filingbasis 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) and provenance becomes document; 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_to exist because FLEGT and SVLK licences are issued per shipment. When the clock passes valid_to the 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. basis on the exclusion axis must be one of negligible_after_mitigation, pre_cutoff_production, outside_supply_base, other β€” so the reason is comparable across records rather than free text. de_minimis_area is retired and answers 422: 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).

  1. The per-plot PUT / DELETE /regulations/eudr/audit-trail/:recordId/plots/:plotId/justification endpoints are removed. Use PUT / DELETE /verdict-overrides with axis: "plot_compliance". This is not a path swap: the removed PUT preserved an omitted compliance_override and evidence_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.
  2. In the report JSON, justification.text is now justification.narrative, on both the plot register's override.justification block and the standalone justification block.

Bulk PATCH /config is unaffected and still takes text in set_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 via POST /regulations/eudr/audit-trail/:recordId/authorisations. Without it, submit returns 422 PRE_SUBMISSION_BLOCKED with an authorization_missing blocker. The mode: "manual" path (recording an externally-filed DDS) does not require this.

One-call submit β€” finalise everything on the submit request. The submit attributes accept the same editable blocks as PATCH /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 separate PATCH is needed. The submission fields are validated strict β€” the identity fields (operator_role, associated_statements, fixed at create) and any unknown key are rejected 400; country_of_activity must be EU-27/XI and null for DOMESTIC. (PATCH itself edits name/operator/commodity only β€” submission is submit-time.)

The submit gate == the record summary's meta. Submit runs the same findPreSubmissionBlockers the record summary exposes as meta.submission_blockers / meta.can_submit β€” including the operator-completeness, resolved-country (EU-27+XI) and internal-reference checks (all uniform 422 PRE_SUBMISSION_BLOCKED blockers, operator_incomplete / country_of_activity_invalid / internal_reference_missing). So can_submit: true with 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" on POST /regulations/eudr/dds/:recordId/submit β€” a synchronous dry-run. Returns a dds_preflight verdict { 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 any name/operator/commodity edits you send in the same call (identical to a PATCH), so the previewed envelope is byte-identical to what a submit would file. rejected carries the mapped TRACES fault + field detail; unverified means 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 (422 with a traces_preflight_failed blocker) only when acceptance rejected the 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/:recordId as meta.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 a traces_preflight verdict without any extra call. On a PATCH the check runs in the background β€” the edit returns immediately and the refreshed verdict appears on a subsequent GET rather 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 submit auto-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 real accepted / rejected verdict on its fileable remainder (not an unverified "plots not compliant"), and because the envelope matches, a later submit reuses that green verdict without a fresh round-trip. If every plot is non-compliant there is nothing to file β€” that's a plots_non_compliant local blocker, and no pre-flight runs.

Species name conformance (timber). TRACES value-validates each speciesInfo.scientificName against 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 a species_name_unresolved blocker 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 like Quercus robur L., an infraspecific tail, or a second species joined by a word β€” and that string is what TRACES receives) β€” the blocker's issues[] carry the declared value, the reason, and (for a synonym or a non-canonical name) the suggestion accepted binomial to file instead. A structurally-plausible name that simply isn't in the curated snapshot is not blocked. This is distinct from the species_missing presence/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 via GET /regulations/eudr/species to 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_compliant fires 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 the plot_compliance axis retires the pending state and returns the plot to the filing if it resolves compliant. The pending plot carries the adjudicator's suggested cause in assessment_driver and what accepting it implies in suggested_outcome (compliant, or exempt for a non-agricultural land use). To accept an exempt outcome, send exemption_driver with the compliant override; 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_compliant fires and lists both, with a message naming the plots awaiting review. List the queue with filter[plot_resolution]=eq:pending_review on 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 to true. Set it to false to skip the automatic pre-render: the record is still created, but the PDF renders on demand on first GET (the earlier behaviour). Only meaningful alongside create_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 id from GET /parties/suppliers (registry) or, on an existing record, from the per-supplier rollup at GET /regulations/eudr/audit-trail/:recordId/suppliers. Combine supplier_* with other keys (e.g. add commodity when 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:

KeyConditionsMatches
location_ideq, inRecords 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_ideq, inRecords whose current version contains this plot (plot.id)
facility_ideq, inRecords whose current version contains any plot under this facility (facility.id)
assets.task_ideq, inRecords that pulled at least one plot from this ingestion task
commodity, country_codeeq, inRecords with this scope commodity / country
supplier_nameeq, contains, startsWithRecords scoped to a matching supplier name
supplier_external_ideq, inRecords scoped to a supplier with this per-org external id
statuseq:submittedOnly 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

RuleBehaviour
One task per record at a timePOST /:recordId/tasks and POST /:recordId/submit return 409 if a task is in flight
Version locked after submissionRe-submitting the same version returns 409
Read-only endpoints always availableVersions, 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

MethodPathDescription
POST/regulations/eudr/audit-trailCreate a record + version 1 from a plot filter (async)
GET/regulations/eudr/audit-trailList 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/:taskIdPoll a record-less create task; carries result.record_id on completion
GET/regulations/eudr/audit-trail/:recordIdGet a single record + current version
PATCH/regulations/eudr/audit-trail/:recordIdUpdate operator / commodity attributes
PATCH/regulations/eudr/audit-trail/:recordId/configBulk-update exclusions + justifications
GET/PUT/regulations/eudr/audit-trail/:recordId/data-fieldsRead / replace report data-field projections
GET/regulations/eudr/audit-trail/:recordId/suppliersPer-DDS supplier rollup
PATCH/regulations/eudr/audit-trail/:recordId/suppliers/:supplierIdPer-DDS supplier override

Per-plot config

MethodPathDescription

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

MethodPathDescription
PUT/regulations/eudr/audit-trail/:recordId/verdict-overridesCreate or replace the finding on one (axis, subject)
DELETE/regulations/eudr/audit-trail/:recordId/verdict-overridesDelete that finding β€” scoped to the named axis only
GET/regulations/eudr/audit-trail/:recordId/verdict-overridesList findings on the current version (?axis= to filter)

Versions, Plots & Reprocess

MethodPathDescription
POST/regulations/eudr/audit-trail/:recordId/tasksCut a new version / reprocess (async)
GET/regulations/eudr/audit-trail/:recordId/tasksList version + submit tasks
GET/regulations/eudr/audit-trail/:recordId/tasks/:taskIdGet a single task envelope
GET/regulations/eudr/audit-trail/:recordId/versionsList versions on the record
GET/regulations/eudr/audit-trail/:recordId/versions/:versionIdGet a single version
GET/regulations/eudr/audit-trail/:recordId/versions/:versionId/plotsList plots in the version

Export

MethodPathDescription
GET/regulations/eudr/audit-trail/:recordId/reportJSON report (current version)
GET/regulations/eudr/audit-trail/:recordId/report.geojsonGeoJSON FeatureCollection (current version)
GET/regulations/eudr/audit-trail/:recordId/versions/:versionId/reportJSON report (pinned version)
GET/regulations/eudr/audit-trail/:recordId/versions/:versionId/report.geojsonGeoJSON FeatureCollection (pinned version)

Authorisation & Submission

MethodPathDescription
POST/regulations/eudr/audit-trail/:recordId/authorisationsRecord the operator's "submit on my behalf" acknowledgement
GET/regulations/eudr/audit-trail/:recordId/authorisationsList authorisations on file
POST/regulations/eudr/dds/:recordId/submitSubmit (async SOAP) or record (sync manual)
GET/regulations/eudr/ddsList submitted DDS records
GET/regulations/eudr/dds/:recordIdGet a submitted record + traces block
GET/regulations/eudr/audit-trail/:recordId/tracesLive TRACES overview for the current version's filed statements
GET/regulations/eudr/audit-trail/:recordId/versions/:versionId/tracesThe same overview, pinned to one version
GET/regulations/eudr/audit-trail/:recordId/traces/statementThe full as-filed statement TRACES holds
GET/regulations/eudr/audit-trail/:recordId/versions/:versionId/traces/statementThe same statement, pinned to one version
POST/regulations/eudr/dds/lookupLook up any DDS by its identifiers (eudr:verify)

Audit Trail

MethodPathDescription
GET/regulations/eudr/audit-trail/:recordId/eventsAppend-only audit log for the record, newest-first
GET/regulations/eudr/audit-trail/:recordId/versions/:versionId/eventsCumulative history up to a named version
POST/regulations/eudr/audit-trail/:recordId/eventsAppend 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:

  1. Number of tiers in the supply chain β€” to be provided by the operator or supply chain management software provider.
  2. Processing complexity β€” assessed using the commodity complexity matrix (see below).
  3. Traceability system (directly traceable or mass balance) β€” to be provided by the operator.
  4. 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.

CriterionTriggerPoints
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.

TriggerDiscount
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 Firestore legalityRisk). 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 as adverse_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 of critical / non-critical / no deforestation. Only critical sets eudr.is_compliant = false and contributes to the non-compliance area and plot counts.
  • deforestation_confidence: one of very 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

CommodityTypical Processing StagesComplexity (1-5)Notes
SoySoybeans β†’ oil, meal β†’ animal feed β†’ meat/dairy4Often mixed, cross-border crushing, feed traceability is hard
Oil palmFresh fruit bunches β†’ crude palm oil β†’ refined oil5Highly blended, many processors, smallholders involved
CattleCattle β†’ meat, leather β†’ processed food/fashion5Indirect suppliers common, land linkage is difficult
CocoaCocoa beans β†’ liquor β†’ butter/powder β†’ chocolate4Often mixed at coop/exporter level, many smallholders
CoffeeCherries β†’ beans β†’ roasted β†’ retail packs3Some traceability via co-ops; roasting adds complexity
RubberLatex tapping β†’ sheets β†’ processing β†’ rubber goods4Mixed supply at processors, high smallholder participation
TimberLogs β†’ lumber/panels/pulp β†’ furniture, paper3Depends 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) returns stale and rebuilding flags 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 with POST .../audit-trail?force=true. Once a version is submitted to TRACES its PDF is frozen β€” it is never marked stale, and rebuild requests (including force) return 409. 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. Declare 0901 or one of 090111, 090112, 090121, 090122 or 090190.
  • 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_accepted reports fm-api’s reviewed acceptance policy, traces_wire_hs_code is what fm-api would send, and accepted_hs_codes lists what to declare instead. GET /regulations/eudr/scope/entries lists the accepted codes of every Annex I entry (traces_hs_codes); filter it with traces_fileable=true to 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.

CommodityDeclare asSent to TRACES asNo accepted code today
Cattle0102, 010221, 0102290102, with no supplementary unit–
Cattle0201, 0202, or any HS6 under them; 020610, 020622, 020629, 160250as declared–
Cocoa1801, 1802, 1804, 1805 (or XXXX00)the 4-digit heading–
Cocoa1803, 1806, or any HS6 under themas declared–
Coffee0901, 090111, 090112, 090121, 090122, 090190as declared–
Palm1511 or any HS6 under it; 120710, 151321, 151329, 230660, 290545, 291570, 291590, 382311, 382312, 382319, 382370as declared–
Rubber4001, 4005, 4006, 4008, 4011, 4013, 4015, or any HS6 under themas declared–
Rubber40129030 (tyre treads and flaps; never 401290)40129030 (8 digits)–
Rubber4007, 4017 (or XXXX00)the 4-digit heading–
Soy1507 or any HS6 under it; 120190 (from CN 12019000); 120810as declared–
Soy2304 (or 230400)2304–
Timber4401–4404, 4406–4412, 4414, 4415, 4418–4421, or any HS6 under themas declared–
Timberex chapter 49 (printed products), or any code under it–ex chapter 49
Timber94019190 (parts of wooden seats; never 940191)94019190 (8 digits)–
Timber4405, 4413, 4416, 4417 (or XXXX00)the 4-digit heading–
Timberchapters 47, 48, or any 4- or 6-digit code under them except a zero tail and the excluded codesas declared–
Timber470100, 470200, 470500, 480100, 480300, 480700, 481200the 4-digit heading–
Timber940330, 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 in 00, 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.

AdvisoryWhenExample message
hs_code_normalisedTRACES accepts only a shorter formTRACES accepts this product only as 0102; hs_code 010229 will be submitted to TRACES as 0102. Confirm 0102 describes your product.
hs_code_normalisedThe code is not 2, 4 or 6 digitshs_code 44031 is not a 2-, 4- or 6-digit HS code; it will be submitted to TRACES as 4403.
hs_code_normalisedA 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_droppedIMPORT or EXPORT, TRACES accepts the code, and a unit is stored on a heading TRACES takes none onTRACES 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_codeTRACES 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_code on GET /regulations/eudr/audit-trail/:recordId is the code a filing will send. If you file via the EU portal directly (mode: "manual"), enter this value, not the stored hs_code.
  • After filing, each entry of traces_statements[] on the record carries lines: per commodity line, the hs_heading and supplementary_unit fm-api actually sent (null when no unit was sent). lines is empty for a manual filing, or for one filed before fm-api recorded lines.
  • The version.submitted audit event records wire_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:

HeadingSupplementary unit
4403, 4406, 4408, 4410, 4411, 4412, 4413required, MTQ (cubic metres)
4011, 4013required, NAR (number of items)
4701, 4702, 4704, 4705required, KSD (kilograms of dry substance)
every other in-scope code, including 0102, 4407, 4703, 47, 48not 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.

ProductCodes
Recovered fibrerecovered-paper pulp 470620; recovered paper 4707
Bamboobamboo pulp 470630; bamboo tableware 482361
Non-wood fibrecotton-linters pulp 470610
Not genus Bosbuffalo 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": "..."
      }
    } }
  }
FieldMeaning
traces_reference_numberReference number issued by TRACES
traces_verification_numberVerification number for customs clearance
traces_dds_uuidTRACES-issued DDS UUID (nullable)
submitted_atISO 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.

MethodPathReturns
GET/regulations/eudr/audit-trail/:recordId/tracesOne row per filed statement: the stored identifiers and status beside the live ones TRACES reports
GET/regulations/eudr/audit-trail/:recordId/traces/statementThe complete statement TRACES accepted β€” commodities, producers, geolocations
POST/regulations/eudr/dds/lookupAny 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.