Create Audit Trail
Create a dds_record and its initial version 1 from a plot filter.
โถAsynchronous โ track the task
This endpoint is asynchronous. It returns 202 Accepted with a
dds_record_task envelope (and a Location header) โ not the
record(s). A worker resolves the filter, groups the resolved plots by
(country_code, commodity, supplier), runs assessments per group, and
inserts one dds_record + version 1 atomically per group. The
record(s) exist only once the task reaches completed.
โถOne call โ one or many records (auto-split)
A DDS is bound to a single (commodity, country, supplier) scope, so the
worker splits a heterogeneous plot set into one record per distinct
(country_code, commodity, supplier) rather than rejecting it. A
filter that resolves to cocoa + coffee across GH + CI, each from two
suppliers, yields up to eight records, all from this one call. Commodity
is canonicalised (alias-aware) before grouping, so wood/timber
collapse into one group. The supplier is resolved per plot from its
staged-location supplier link (scoped to the owning org); plots with no
resolvable supplier collapse into a single null-supplier record for their
(country, commodity). A homogeneous, single-supplier filter is simply
the one-group case (one record). Per-supplier records are recombined into
one TRACES filing at submit time where compatible.
Earlier versions rejected a mixed set with
400 MIXED_COMMODITY_OR_COUNTRYโ that no longer happens; the set is grouped instead.
โถA repeat create versions the existing record
Each group's record stands for one piece of land: its exact plot set in
its (country_code, commodity, supplier) scope. A later create whose
group resolves to the same land cuts a new version on that record instead
of minting a second one, and two concurrent creates converge on one
record. The new version takes this request's report_data_fields,
report_model and resolve_supplier_by_name where the request sends
them, and keeps the record's own values otherwise. The record keeps its
name; rename it with a PATCH.
A request naming a different HS code than the record files gets its own
record over the same land. A record whose HS code was corrected by PATCH
no longer matches a repeat of the original request, which mints a new one.
Read the record id from the completed task, not from the 202:
result.records[].record_id names the record each group landed on. The id
in the 202 belongs to the first group, and only when that group mints a
new record.
Poll the task at the returned links.self /
GET /regulations/eudr/audit-trail/tasks/:taskId โ see
Get Audit Trail Task. On completed,
result.records is the authoritative per-group list (see
Response below). Resolution failures (e.g. the filter
matched zero plots) surface there as status: "failed" with an
error_message โ they are not returned on this POST. Per-group
failures are isolated: a group that fails is recorded in
result.records with status: "failed" while the others still create.
Only synchronous body validation (Zod, an incomplete operator fallback,
or an unsupported separate_by) returns 400 here.
Each group's snapshot pins the EUDR-valid deforestation metric and every assessment attached to its plots. The snapshot is immutable โ subsequent edits create new versions rather than mutating existing data.
โถPreconditions
None at the URL โ the route is top-level under /regulations/eudr/audit-trail.
Plot visibility is enforced server-side: only plots visible to the
caller's organization are eligible. The new
record is owned by the caller's active org;
its plot set may span any number of asset tasks the caller can see.
โถRequest body
Only filter (which resolves the plot set) is meaningful as a required
input; every other block is optional:
operatorโ optional. When omitted, the acting organisation's stored operator defaults are used (see PATCH /organizations/me); if neither the body nor the org defaults provide a complete operator, the POST returns400naming the missing fields. When supplied it must be complete and valid โ there is no per-field merge with the org defaults.commodityโ optional. You can create a record before the product/HS-code details are known. When omitted, the version's commodity columns are left NULL; you populate them later via Update DDS Record. Filing is then blocked until they're set โ Submit DDS returns422with acommodity_missingpre-submission blocker. NOTE: when a call auto-splits into multiple records (across commodity, country, or supplier), a single suppliedcommodityblock โ whose quantity can't be apportioned across the split โ is not applied; every split record stores NULL commodity and you set each via PATCH before submit.separate_byโ accepted by the schema (["facility" | "tag"]) but not yet supported: records are auto-split by(country_code, commodity, supplier)only for now, so a non-emptyseparate_byreturns400. Finer separation (one record per facility / per tag) is a planned follow-up.
operator and commodity are editable via
Update DDS Record while no version
has been submitted, and are visible on the GET response.
Submission metadata (TRACES activity type, internal reference number, etc.) is not an input to create. The handler stamps placeholder values on v1; operators supply the real values before the version is filed with TRACES โ that input moves to a dedicated update / submit-time payload.
Filter keys use the dot-notation convention shared with
/locations/* (see schemas/locations.ts). assets.* keys live in
the same namespace whether the request is a querystring or JSON body.
{
"data": {
"type": "dds_record",
"attributes": {
"name": "Q2 2026 cocoa shipment", // optional, max 255 chars
"filter": {
"plot_id": { "in": ["uuid", ...] }, // or { "eq": "uuid" }; up to 1000 ids
"country_code": { "eq": "GH" },
"commodity": { "eq": "cocoa" },
"area_ha": { "gte": 0.5, "lte": 1000 },
"tag": { "in": ["pilot-2026"] }, // or { "eq": "name" }; tag NAMES
"facility_id": { "in": ["fac_123", ...] }, // or { "eq" }; facility.id (tile_ids on /locations)
"facility_name": { "contains": "mill" }, // string-condition; facility's NORMALIZED name
"supplier_id": { "eq": "uuid" }, // or { "in": ["uuid", ...] }; global supplier id
"supplier_external_id": { "in": ["CV-001"] }, // or { "eq" }; your per-org `supplierid`
"supplier_name": { "contains": "verde" }, // string-condition
"supplier_country": { "eq": "BR" }, // string-condition; same for supplier_city, supplier_region, supplier_postcode, supplier_address_line1, supplier_address_line2, supplier_statement
"assets.task_id": { "eq": "uuid" }, // or { "in": ["uuid", ...] }; โค 100 ids
"assets.filename": { "contains": "shipment" }, // string-condition
"assets.data.Company": { "eq": "Acme" }, // string-condition
"assets.data.Region": { "in": ["west", "central"] } // โค 10 assets.data.* keys
}
}
}
}
โถFacility & supplier filters
facility_* keys select every plot under a facility (facility โ
shed โ plot); facility_name matches the facility's NORMALIZED name.
supplier_* keys select every plot whose location is linked to a
matching supplier โ so a single
supplier_id gathers a supplier's plots across every facility and every
uploaded file into one record. supplier_id / supplier_external_id
take eq / in; the remaining supplier_* keys take a string-condition.
Multiple supplier_* keys AND against the same linked supplier. This is the
recommended way to build one DDS per supplier โ see
EUDR Compliance โ Process per supplier.
โถstring-condition
string-conditionUsed by facility_name, supplier_name (and the other text supplier_*
keys), assets.filename, and every assets.data.* entry:
{ "eq": "exact value" }
| { "in": ["v1", "v2"] } // โค 50 entries
| { "contains": "substr" } // case-insensitive ILIKE %substr%
| { "startsWith": "prefix" } // case-insensitive ILIKE prefix%
An empty filter: {} resolves to every plot the caller can see โ
potentially thousands across many tasks. In practice you'll always
narrow with at least one of country_code / commodity /
supplier_id / assets.task_id to keep the resulting record set
focused (a broad filter just produces more (country, commodity)
groups). Conditions AND together; unknown keys are rejected with 400.
โถAssessment cache policy
reuse_assessments_within_days controls how aggressively the worker
reuses previously-computed assessments when
resolving v1's plot assessments:
| Value | Behaviour |
|---|---|
| omitted | Default 30 days. Reuse any cached assessment whose sco2_run_at is within the last 30 days; re-run anything older. |
positive integer N | Reuse cached assessments computed within the last N days; re-run older or missing entries. Maximum 3650 (10 years). |
false | Force re-run. Ignore the cache and re-run the assessment for every plot in scope, regardless of when prior assessments ran. |
Pass false after a methodology bump (or any time the operator
wants a guaranteed-fresh signal); pass a smaller number (e.g. 7)
to require recent assessments without bypassing the cache
entirely.
โถWho / what each top-level block represents
Three blocks travel together on a DDS submission and they are often confused โ be explicit which entity each one refers to:
-
operatorโ the legal entity actually placing the product on the EU market (or the trader handling it). This is the party EUDR Article 9 names as accountable for the DDS; their name, address, EORI/VAT, and contact details end up on the TRACES envelope. ForREPRESENTATIVE_OPERATORfilings this same entity becomes the wirerepresentedOperator(Epoch files on its behalf); forOPERATORfilings the submitter identity is the authenticated TRACES account and no represented operator is sent. Falls back to the acting org's operator defaults when omitted. -
commodityโ the goods this DDS covers, not an entity. Carries the product identification fields TRACES expects on the submission envelope: HS code, product type, free-text description, quantity in kilograms, and species names (mandatory for timber and timber-derived products, optional otherwise). Snapshotted on the record's current version at create time; editable via Update DDS Record until the version has a submit-lifecycle event in the audit log.
โถoperator (optional โ falls back to org defaults)
operator (optional โ falls back to org defaults)The operator legal entity that will be named on the EUDR submission. Mirrors the inputs the TRACES SOAP envelope consumes. All string fields are trimmed server-side; pure-whitespace input is rejected.
| Field | Type | Length / format |
|---|---|---|
name | string | 1โ200 chars (trimmed) |
address_street | string | 1โ300 chars (trimmed) |
address_postal_code | string | 1โ80 chars (trimmed) |
address_city | string | 1โ200 chars (trimmed) |
address_full | string | null | optional single-line fallback; โค 250 chars |
country | string | ISO 3166-1 alpha-2; uppercased server-side |
email | string | RFC-5321 email format; โค 320 chars |
eori | string | 1โ40 chars; [A-Z0-9.\-/]+ (trimmed + upper-cased server-side) |
identifier_type | enum | "EORI" | "VAT" |
phone | string | 1โ32 chars; digits, spaces, parentheses, ., -, leading + |
โถidentifier_type must be EORI for a cross-border filing
identifier_type must be EORI for a cross-border filingTRACES rejects a VAT-identified operator on an IMPORT or EXPORT
DDS (EUDR Validation Rules v1.3 ยง3, "Activity type and identifier"). Only
a DOMESTIC filing may use VAT. A second rule pairs with it: an
operator established outside EU-27 + XI may only file IMPORT.
Neither rule is checked at create time โ this endpoint accepts either identifier, because the activity type is not part of the create body (see "Submission metadata" below). The combination is caught later:
- At edit โ Update DDS Record
returns
422when a patch creates the bad combination, with the offending fields anchored indata.details.issues. - At filing โ the
operator_identifier_invalidandactivity_not_allowed_for_operator_countrypre-submission blockers (see Submit DDS) stop the submission unconditionally, including for combinations that pre-date any edit.
This matters at create because v1 is stamped activity_type: "IMPORT"
(below). A record created with a VAT-identified operator โ including one
inherited from the org defaults โ is therefore born in the blocked state
and cannot be filed until either the identifier or the activity type
changes. Set identifier_type: "EORI" here, or plan to correct it via
PATCH before submitting. GET /regulations/eudr/audit-trail/:recordId
reports the pending blockers under meta.submission_blockers.
โถcommodity (optional)
commodity (optional)Product details for this submission. Optional at create โ omit it to start a record before the details are known, then set them via Update DDS Record before filing (the submit gate blocks an incomplete commodity). When the call auto-splits into multiple records the supplied block is ignored (it can't be right for >1 commodity). All free-text fields are trimmed; pure-whitespace input is rejected.
| Field | Type | Length / format |
|---|---|---|
product_type | string | 1โ200 chars, e.g. "COCOA_BEANS" |
product_description | string | 1โ1024 chars (trimmed) |
product_quantity_kg | number | > 0, โค 1e9; tonnes derived at submit time |
hs_code | string | ^[0-9]{2,8}$ โ 2โ8 digits, no separators (TRACES XSD hsHeadingType) โ e.g. 1801, 180100, 18010000 |
species_common_name | string | null | 1โ200 chars, or null |
species_scientific_name | string | null | 1โ200 chars, or null |
โถTop-level attributes
| Field | Type | Length / format |
|---|---|---|
name | string | omitted | Optional human-readable label; 1โ200 chars (trimmed) |
filter | object | See "Filter binding" below |
operator | object | omitted | Optional; falls back to org defaults (above) |
commodity | object | omitted | Optional (above) |
report_model | string | omitted | Defaults to "eudr_default_v1"; must be a registered model |
reuse_assessments_within_days | number | false | omitted | Assessment cache policy (below); default 30 |
report_data_fields | array | omitted | Per-plot projections of uploaded columns into the report; see DDS Report Data Fields. Absent โ none |
resolve_supplier_by_name | boolean | omitted | Default true. When a shed/plot has no registry supplier attached, resolve the supplier from the facility upload's normalised name; set false to disable that fallback. Frozen on the version and inherited on version cut. See Suppliers |
separate_by | ("facility" | "tag")[] | omitted | Accepted by the schema but not yet supported โ a non-empty value returns 400. Records auto-split by (country, commodity) only for now |
โถSubmission metadata (set on a follow-up flow)
The activity_type, operator_role, internal_reference_number,
geo_location_confidential, comment, associated_statements,
and country_of_activity fields are NOT accepted at create time โ
placeholders are stamped on v1 and operators populate the real
values via the submission-metadata edit flow before
Submit DDS. The stamped v1 values are:
| Field | v1 placeholder | Editable later via |
|---|---|---|
activity_type | "IMPORT" | Update DDS Record or the one-call Submit DDS |
operator_role | "REPRESENTATIVE_OPERATOR" | Neither โ see below |
country_of_activity | null (derive from operator.country) | Update DDS Record |
internal_reference_number | null (auto-generated at submit) | Update DDS Record |
geo_location_confidential | false | Update DDS Record |
comment | null | Update DDS Record |
associated_statements | [] | Neither |
The activity_type default is why a VAT-identified operator blocks
filing out of the box โ see "identifier_type must be EORI for a
cross-border filing" above. operator_role and associated_statements
have no post-create writer today: a PATCH carrying them returns 400,
and only a mode: "manual" filing flips the role (to OPERATOR).
When the operator_role is
REPRESENTATIVE_OPERATOR, the DDS carries a representedOperator
built from the record's own operator block (above), so TRACES can
attribute the filing to the entity Epoch submits on behalf of; for
OPERATOR the identity is the authenticated account and no
represented operator is sent.
country_of_activity is the EU country where the diligence
activity occurs (port of entry for IMPORT, port of exit for
EXPORT). It is optional even on cross-border activities โ when
omitted the submit flow uses operator.country. The override is
forbidden for DOMESTIC / TRADE (those activities are
mechanically operator-country). See
Submit DDS โ "Country fields on the TRACES
envelope" for the full per-activity table and the
EU-27 + XI allow-list.
โถFilter binding
| Key | Matches on |
|---|---|
plot_id | The plot's id |
country_code | The plot's country code |
commodity | The plot's commodity (case-insensitive, alias-aware โ see note below) |
area_ha | The plot's area, as a numeric range |
tag | A tag applied to the plot, scoped to your organization |
assets.task_id | The asset_task_id of the upload the plot belongs to |
assets.filename | The source filename of the upload the plot belongs to (string condition) |
assets.data.<path> | A dotted path into the plot's source upload row (the value at that path, with the given operator) |
โถCommodity aliases
The commodity filter is alias-aware and case-insensitive. Each value
is expanded to every equivalent spelling before matching, so a filter
matches plots stored under any synonym (and vice versa):
wood/paper/pulp/lumberโtimberpalmโpalm oilโoil palmbeef/leatherโcattle,soya/soybean/sojaโsoy,cacaoโcocoa
The resolved scope commodity is the canonical name (e.g. palm), and
alias-equivalent plots group together as a single commodity (so they land
in one record rather than being split apart).
โถSnapshot rules
-
Deforestation metric selection โ for each plot the server picks the latest deforestation-detection job where:
monitoring_start = '2020-12-31'(EUDR Article 2 cutoff), thenused_ccdc = true(CCDC/enhanced) wins overused_ccdc = false, then- tiebreaker is most recent
ingestion_date.
Plots with no EUDR-valid metric are stamped
plot_for_job_id = nulland surface incompleteness.missing_deforestation_metric. -
Assessment selection โ for each plot, every assessment attached to its location is pinned into the version's snapshot. Missing assessment types stay null.
-
Methodology versions are aggregated per assessment type by picking the modal
methodology_versionvalue across the pinned assessments; null when absent.
โถResponse (202) {#response-202}
Returns a dds_record_task envelope โ not the record(s). Poll it via
Get Audit Trail Task.
| Field | Type | Description |
|---|---|---|
data.id | uuid | The async task id |
data.attributes.operation | string | "create_record" |
data.attributes.status | enum | pending โ processing โ completed | failed |
data.attributes.result | object | null | On completed (see below) |
data.attributes.error_message | string | null | On failed: the reason |
On completed, result carries:
| Field | Type | Description |
|---|---|---|
result.records | array | Authoritative per-group list โ one entry per (country, commodity): { record_id, version_id, version_number, commodity, country_code, status, error_message? }. status is "created" or "failed" (per-group isolation) |
result.record_id / result.version_id / result.version_number | โ | Legacy single-record pointer at the first created group (backward-compatible). For the common single-group create this is the record; for a multi-group split, read result.records. The handler-minted id returned on the 202 is always present in result.records with its status |
Location header / links.self | string | The task status URL |
โถErrors
Synchronous (returned on this POST):
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Body failed Zod validation; operator was omitted and the org has no complete operator defaults; or a non-empty separate_by (not yet supported) |
| 401 | UNAUTHORIZED | Missing/invalid API key |
Asynchronous (surface on the task, see Get Audit Trail Task):
task status | error_message | When |
|---|---|---|
failed (whole task) | Filter matched no plots | The filter resolved zero plots visible to the caller (or every group failed) |
per-group failed in result.records | the group's underlying error | One (country, commodity) group failed (e.g. a transient assessment error); the other groups still created and the task completes |
A mixed commodity/country plot set is not an error โ it auto-splits into one record per group (see above).
Required scope:
eudr:write