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 returns 400 naming 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 returns 422 with a commodity_missing pre-submission blocker. NOTE: when a call auto-splits into multiple records (across commodity, country, or supplier), a single supplied commodity block โ€” 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-empty separate_by returns 400. 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

Used 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:

ValueBehaviour
omittedDefault 30 days. Reuse any cached assessment whose sco2_run_at is within the last 30 days; re-run anything older.
positive integer NReuse cached assessments computed within the last N days; re-run older or missing entries. Maximum 3650 (10 years).
falseForce 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. For REPRESENTATIVE_OPERATOR filings this same entity becomes the wire representedOperator (Epoch files on its behalf); for OPERATOR filings 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)

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.

FieldTypeLength / format
namestring1โ€“200 chars (trimmed)
address_streetstring1โ€“300 chars (trimmed)
address_postal_codestring1โ€“80 chars (trimmed)
address_citystring1โ€“200 chars (trimmed)
address_fullstring | nulloptional single-line fallback; โ‰ค 250 chars
countrystringISO 3166-1 alpha-2; uppercased server-side
emailstringRFC-5321 email format; โ‰ค 320 chars
eoristring1โ€“40 chars; [A-Z0-9.\-/]+ (trimmed + upper-cased server-side)
identifier_typeenum"EORI" | "VAT"
phonestring1โ€“32 chars; digits, spaces, parentheses, ., -, leading +

identifier_type must be EORI for a cross-border filing

TRACES 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 422 when a patch creates the bad combination, with the offending fields anchored in data.details.issues.
  • At filing โ€” the operator_identifier_invalid and activity_not_allowed_for_operator_country pre-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)

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.

FieldTypeLength / format
product_typestring1โ€“200 chars, e.g. "COCOA_BEANS"
product_descriptionstring1โ€“1024 chars (trimmed)
product_quantity_kgnumber> 0, โ‰ค 1e9; tonnes derived at submit time
hs_codestring^[0-9]{2,8}$ โ€” 2โ€“8 digits, no separators (TRACES XSD hsHeadingType) โ€” e.g. 1801, 180100, 18010000
species_common_namestring | null1โ€“200 chars, or null
species_scientific_namestring | null1โ€“200 chars, or null

Top-level attributes

FieldTypeLength / format
namestring | omittedOptional human-readable label; 1โ€“200 chars (trimmed)
filterobjectSee "Filter binding" below
operatorobject | omittedOptional; falls back to org defaults (above)
commodityobject | omittedOptional (above)
report_modelstring | omittedDefaults to "eudr_default_v1"; must be a registered model
reuse_assessments_within_daysnumber | false | omittedAssessment cache policy (below); default 30
report_data_fieldsarray | omittedPer-plot projections of uploaded columns into the report; see DDS Report Data Fields. Absent โ†’ none
resolve_supplier_by_nameboolean | omittedDefault 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")[] | omittedAccepted 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:

Fieldv1 placeholderEditable later via
activity_type"IMPORT"Update DDS Record or the one-call Submit DDS
operator_role"REPRESENTATIVE_OPERATOR"Neither โ€” see below
country_of_activitynull (derive from operator.country)Update DDS Record
internal_reference_numbernull (auto-generated at submit)Update DDS Record
geo_location_confidentialfalseUpdate DDS Record
commentnullUpdate 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

KeyMatches on
plot_idThe plot's id
country_codeThe plot's country code
commodityThe plot's commodity (case-insensitive, alias-aware โ€” see note below)
area_haThe plot's area, as a numeric range
tagA tag applied to the plot, scoped to your organization
assets.task_idThe asset_task_id of the upload the plot belongs to
assets.filenameThe 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 โ‡„ timber
  • palm โ‡„ palm oil โ‡„ oil palm
  • beef / 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:

    1. monitoring_start = '2020-12-31' (EUDR Article 2 cutoff), then
    2. used_ccdc = true (CCDC/enhanced) wins over used_ccdc = false, then
    3. tiebreaker is most recent ingestion_date.

    Plots with no EUDR-valid metric are stamped plot_for_job_id = null and surface in completeness.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_version value 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.

FieldTypeDescription
data.iduuidThe async task id
data.attributes.operationstring"create_record"
data.attributes.statusenumpending โ†’ processing โ†’ completed | failed
data.attributes.resultobject | nullOn completed (see below)
data.attributes.error_messagestring | nullOn failed: the reason

On completed, result carries:

FieldTypeDescription
result.recordsarrayAuthoritative 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.selfstringThe task status URL

Errors

Synchronous (returned on this POST):

StatusCodeWhen
400BAD_REQUESTBody failed Zod validation; operator was omitted and the org has no complete operator defaults; or a non-empty separate_by (not yet supported)
401UNAUTHORIZEDMissing/invalid API key

Asynchronous (surface on the task, see Get Audit Trail Task):

task statuserror_messageWhen
failed (whole task)Filter matched no plotsThe filter resolved zero plots visible to the caller (or every group failed)
per-group failed in result.recordsthe group's underlying errorOne (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