Submit DDS

Single endpoint, three modes β€” discriminated on data.attributes.mode:

ModeBehaviourResponse
submit (default)fm-api files asynchronously via a dds_record_task. The task plans every TRACES statement, checks each against TRACES acceptance (below) before anything is filed, then files them. Poll the task's self link to watch pending β†’ processing β†’ completed | failed.202 Accepted + task envelope
validateAcceptance dry run as a task: every statement checked, nothing filed202 Accepted + task envelope
manualOperator filed via the EU portal directly; fm-api just records the result. No SOAP call.200 OK + submission envelope

All modes 404 when the record / current version isn't visible. validate and manual 409 when the version is already submitted or mid-submission.

submit is idempotent. A repeated submit answers from the version's state:

VersionResponse
filed200 dds_submit_status with statements[]; needs_new_version when TRACES rejected a statement
a submit task is queued or running202 that task, meta.submission.in_progress: true
a split stopped part-way202 a new task, meta.submission.resumed: true; only unfiled statements are sent
partly filed before resumable submits existed409 until reconciled by hand
another dds_record_task (e.g. create-version) is running409

Every split statement is planned, with its internal reference (…-0001, …-0002), plots and quantities, before the first one is sent. A statement whose TRACES response was lost is looked up by that reference before it is ever sent again.

Live TRACES acceptance pre-flight (EPO-1993)

The local pre-submission gate only approximates TRACES. The authoritative check is a live round-trip of the byte-identical filing envelope against the EU TRACES acceptance environment (never production), which catches opaque server-side rejections the local rules can't predict.

mode: "validate" β€” a dry run as a task. Accepts the same editable blocks as submit and runs the same local gate, then returns 202 with a dds_record_task (operation validate). The task plans every TRACES statement the filing would need, checks each against acceptance (filed under a PF-… reference and withdrawn again), and files nothing. It does not lock the version. When it completes, result.traces_preflight holds the verdict:

{ "status": "accepted", "checked_at": "2026-10-06T…", "statement_count": 2, "errors": [] }
  • accepted β€” every statement passed acceptance; a submit should file.
  • rejected β€” a business-rules fault; errors[] carry the TRACES code and field detail.
  • unverified β€” acceptance could not give an authoritative answer. Never a silent green.

One DDS task runs per record: a repeated validate returns the running validation; a validate during a submit (or the reverse) is a 409.

Enforcement on mode: "submit" β€” the submit request no longer runs the check; it returns 202 and the submit task runs it. The task plans every TRACES statement the filing needs and checks each one against acceptance (filed under a separate PF-… internal reference and withdrawn again). It files nothing to the real target unless every statement is accepted. A rejection, or an acceptance environment that cannot answer, fails the task with nothing filed; the task's error_message and traces_fault say which statement and why. The only case the check cannot cover is the real target rejecting a statement acceptance accepted: the task then fails with fm-api:FilingRejectedAfterPreflight, the version stays locked, and a later submit files the rest.

The last verdict is surfaced read-only on Get Audit Record Summary as meta.submission.submit.traces_preflight, and is refreshed proactively by a Update DDS Record that leaves the record submit-ready (best-effort β€” never fails the PATCH).

Submit mode (default)

Body is optional β€” bare POST with no body defaults to mode: submit. The 202 response carries a dds_record_task with operation: "submit" and status: "pending". Poll GET /dds/:recordId/tasks/:taskId to watch progress.

One-call submit β€” finalise every TRACES field on the submit request

Submit mode optionally carries the same editable blocks as Update DDS Record β€” name, operator, commodity β€” plus the flat submission-metadata fields, so a single call can complete every TRACES-required input and file. Each provided field is persisted onto the current version (merge-patch β€” absent leaves it) before the pre-flight gates run, so the gates and the TRACES filing see exactly what you're filing. You don't need a separate PATCH first.

FieldNotes
nameRecord name (nullable)
operatorOperator block (partial) β€” same shape as create/PATCH
commodityCommodity block (partial), incl. the goodsMeasure supplementary-unit pair
activity_typeDOMESTIC | IMPORT | EXPORT (flat)
country_of_activityEU-27 + XI only (else 400); must be null for DOMESTIC (else 422); port of entry/exit for IMPORT/EXPORT (falls back to operator.country when null)
internal_reference_numberCaller's own reference; ≀ 35 chars. Optional β€” when unset (here and on the record) the submit flow auto-generates a stable fallback (E-<version-uuid>) so the filing stays retrievable. Can also be staged earlier via Update DDS Record.
geo_location_confidentialBoolean
comment≀ 1024 chars; null clears

The submission fields are flat and validated strict: the identity fields (operator_role, associated_statements) and any unknown top-level key are rejected with 400 rather than silently dropped. Manual mode does not take these fields.

When the task lands completed, attributes.result carries the TRACES identifiers:

"result": {
  "version_id": "<uuid>",
  "version_number": 2,
  "traces_reference_number": "DLE25/12345",
  "traces_verification_number": "ABC123",
  "traces_dds_uuid": "<uuid|null>",
  "submitted_at": "..."
}

On TRACES fault the task lands failed with error_message set; result stays null. The version's submittingAt claim is released by the worker so the operator can POST again without manual intervention.

Manual mode

Body fields:

FieldRequiredNotes
modeyesLiteral "manual"
reference_numberyesTRACES reference number
verification_numberyesTRACES verification number
dds_uuidnoUUID, when TRACES issued one
submitted_atnoISO 8601 timestamp; defaults to server now()

Records the result inline (no SOAP), emits a version.marked_submitted event in the audit log (distinct from version.submitted so the trail tells you whether TRACES was actually hit by fm-api or filed externally), and returns 200 with the submission envelope.

Pre-submission gate (Β§9 step 2 + step 3)

Before claiming the version, the handler runs the pre-submission gate. Any blocker β†’ 422 PRE_SUBMISSION_BLOCKED with the failing checks in details.blockers[]:

This is the same findPreSubmissionBlockers gate that the audit-trail record summary surfaces per filing mode in meta.submission.submit.blockers / meta.submission.submit.can_submit (and the manual counterpart), so the summary predicts the submit outcome. The operator / country checks that used to be separate synchronous 400s are now uniform blockers here (all 422 PRE_SUBMISSION_BLOCKED):

Coffee policy note: TRACES acceptance accepts bare 0901 (IMPORT and EXPORT re-probed on 2026-10-19), and Epoch now accepts the bare heading alongside its five subheadings. Production has not been verified. The examples show current fm-api behaviour, including blocker messages for codes that remain unsupported.

Blocker typeFires when
plots_missing_screeningIncluded plots without a pinned deforestation metric (have not passed Β§6 full-resolution screening).
unmitigated_article10_riskIncluded plots with an active Article 10.2(h) sanctions match (Β§7.1 stop-the-line).
engagement_threads_openSupplier-engagement thread still open (reserved β€” GAP-5 not implemented).
operator_incompleteThe resolved operator (override, else org defaults) is missing TRACES-mandatory fields. Both modes.
country_of_activity_invalidThe resolved country of activity (submission override for IMPORT/EXPORT, else operator country) is outside EU-27 + XI. Both modes.
operator_country_invalidThe operator's own country is not a recognised ISO 3166-1 alpha-2 code (e.g. ZZ). Only fires when it isn't the resolved country of activity (that case is country_of_activity_invalid), so a bogus operator country on an IMPORT/EXPORT with a valid override still blocks. Both modes.
operator_identifier_invalidGAP-25: IMPORT/EXPORT with a VAT-identified operator β€” TRACES requires EORI on cross-border filings. Both modes.
activity_not_allowed_for_operator_countryGAP-26: an operator outside EU-27 + XI may only file an IMPORT DDS. Both modes.
invalid_producer_countryThe record's country of production (country_code, derived from the plots and carried on every producer) is not a well-formed ISO alpha-2 code. Both modes.
commodity_missing / species_missing / out_of_scope_hs_code / commodity_description_too_longCommodity block incomplete / timber species missing / HS out of Annex I scope / description over 150 chars.
hs_code_too_broadhs_code is in scope but broader than its Annex I entry and has fewer than 6 digits (FAQ 7.24); waived by an exemption claim. Message: hs_code <code> is broader than its EUDR Annex I entry; declare it to the Annex I digit level (at most 6 digits) via PATCH /regulations/eudr/audit-trail/:recordId before submitting. Detail field: hs_code. Both modes.
hs_code_not_accepted_by_tracesfm-api’s reviewed acceptance policy does not accept the hs_code, and no shorter form of it is accepted (e.g. rubber 401290). accepted_hs_codes lists the codes to declare instead; empty when the product can't be filed through TRACES today. Not waived by an exemption claim. Both modes.
supplementary_unit_required / supplementary_unit_qualifier_mismatchIMPORT/EXPORT on a heading where TRACES requires a supplementary unit (MTQ on 4403, 4406, 4408, 4410–4413; NAR on 4011, 4013; KSD on 4701, 4702, 4704, 4705), and none is stored / the stored unit is the wrong one. Not waived by an exemption claim. Both modes.
species_name_unresolvedA timber commodity's declared species_scientific_name is not an accepted binomial β€” a malformed non-binomial (incl. rank placeholders like Quercus sp), a known synonym, or a resolvable name carrying tokens beyond the binomial (an author authority such as Quercus robur L., an infraspecific tail, or a second species joined by a word), since TRACES receives the declared string verbatim β€” resolved against the curated GBIF timber snapshot. Plausible-but-unlisted names and hybrids pass in v1. Fires before TRACES is contacted. Both modes.
plots_geometry_invalid / plots_missing_geometry / plots_unresolved_verdict / plots_non_compliant / too_many_producers / self_referencing_ddsThe Β§10/Β§15/Β§16 plot + producer rules. plots_non_compliant fires only when every plot is non-compliant (a mix is auto-excluded, see below).
authorization_missingmode: "submit" only. Β§9 step 3 requires the operator to have recorded an Authorisation for the current version with the current text_version. Manual mode bypasses this check.
attachment_awaiting_verificationEPO-1448 Β§3.8: operator-evidence attachments on the version still awaiting verification. No-op today (attachments default to approved). Both modes.

Example body for the authorization_missing case:

{
  "data": {
    "error": "DDS submission blocked by pre-flight checks",
    "code": "PRE_SUBMISSION_BLOCKED",
    "tracking_id": "...",
    "details": {
      "blockers": [
        {
          "type": "authorization_missing",
          "text_version": "v2",
          "message": "Β§9 step 3 requires the operator to authorise Epoch to submit on their behalf; POST /authorisations with the current text_version before submitting"
        }
      ]
    }
  }
}

To clear it: POST to Authorisations with { confirmed: true, text_version: "v2" } (plus an optional signee name), then retry the submit.

Compliant-only default (non-compliant plots auto-excluded)

A DDS submission files only the compliant supply base. Before the gate above runs, the handler automatically excludes every included plot whose effective compliance verdict is non_compliant (an alert-derived non-compliance, or an operator override to non_compliant). Those plots drop out of the filing rather than blocking it β€” so plots_non_compliant is not a submit blocker. Each auto-exclusion is recorded in the audit log (config.exclusion_added, source: "system") and the excluded plots remain visible in the record's excluded set and the audit-trail PDF appendix.

An operator override to compliant (via a plot justification) keeps that plot in the filing. If auto-exclusion leaves zero compliant plots, the submit is rejected with 422 PRE_SUBMISSION_BLOCKED ("No compliant plots to file …") β€” override a plot to compliant, or add compliant plots, then retry.

The editable submission metadata (activity type, country of activity, internal reference number, geolocation-confidential, comment) is finalised on this request (see "One-call submit" above). The write-once identity field β€” operator role β€” is set at create; this endpoint also checks (mode: "submit") that an Authorisation exists.

Country fields on the TRACES envelope

TRACES requires countryOfActivity (and, for cross-border movements, borderCrossCountry) on every DDS. Both must be one of EU-27 or XI (Northern Ireland under the Windsor Framework); any other value is rejected before the SOAP call is made.

The submit flow derives the two fields from the version's submission metadata per the EUDR Validation Rules v1.3:

activity_typecountryOfActivityborderCrossCountry
DOMESTICoperator.country (must be EU)omitted β€” no cross-border movement
TRADEoperator.country (must be EU)omitted β€” intra-EU placement
IMPORTsubmission.country_of_activity override β†’ else operator.countrysame as countryOfActivity (EU port of entry)
EXPORTsubmission.country_of_activity override β†’ else operator.countrysame as countryOfActivity (EU port of exit)

The producer/plot country (the record's top-level country_code β€” e.g. GH for Ghanaian cocoa) is never the activity country; that's the country of production, not of the EU diligence activity.

submission.country_of_activity override

Provide it on the submit request (or set it earlier at create). Optional:

  • IMPORT / EXPORT: use when the EU port of entry/exit differs from the operator's HQ country β€” e.g. a German operator importing through Rotterdam sets country_of_activity: "NL". When null, the operator's own country is used.
  • DOMESTIC: must be null. A non-null value with a DOMESTIC merged activity type is rejected 422 VALIDATION_ERROR.

Allowed values: ISO 3166-1 alpha-2, EU-27 + XI (a non-EU value is rejected 400 at input; a resolved non-EU country surfaces as the country_of_activity_invalid pre-submission blocker).

Faults from the envelope guard (async β€” polled, not returned synchronously)

These are not HTTP error responses. The submit endpoint itself still returns 202 Accepted with a dds_record_task envelope; the country guard fires inside the worker task. To discover the failure, poll GET /dds/:recordId/tasks/:taskId until status === "failed" and read error_message. The full structured fault is also stamped on the task's job_parameters under traces_fault.

Triggered when derivation produces a non-EU value (e.g. a DOMESTIC filing with operator country GB, or an IMPORT with operator country US and no override) β€” the submit task fails with a structured TracesFault rather than round-tripping through TRACES. The job_parameters.traces_fault payload looks like:

{
  "code": "fm-api:InvalidCountryOfActivity",
  "message": "countryOfActivity US is not in the TRACES-accepted set (EU-27 + XI)",
  "details": [
    {
      "field": "countryOfActivity",
      "message": "Country US is not accepted by TRACES; must be one of EU-27 or XI (Northern Ireland)"
    }
  ]
}

…and the polled task envelope's attributes.error_message is set to "fm-api:InvalidCountryOfActivity: countryOfActivity US is not in the TRACES-accepted set (EU-27 + XI)".

Note on fm-api:InvalidBorderCrossCountry: the envelope builder derives borderCrossCountry === countryOfActivity on IMPORT/EXPORT (or omits it on DOMESTIC/TRADE), so an out-of-set value is always caught by the countryOfActivity check first. The fm-api:InvalidBorderCrossCountry discriminator only appears on the legacy SOAP-side error path; if you see it, you are reading a fault from the Python submission service and not from the fm-api envelope builder.

To recover: fix the underlying operator.country or set a valid EU submission.country_of_activity, then resubmit (the worker releases the version's submission claim on fault, so the next POST /submit is allowed without manual intervention).

Errors (synchronous HTTP responses)

StatusCodeWhen
400BAD_REQUESTBody failed Zod validation β€” e.g. an unknown/identity submission key, country_of_activity outside EU-27+XI, an unsupported supplementary_unit_qualifier; manual mode requires reference_number + verification_number and rejects the submit-only fields.
401UNAUTHORIZEDMissing/invalid API key
404NOT_FOUNDRecord / current version isn't visible
409CONFLICTvalidate/manual on a submitted or mid-submission version; any mode while another dds_record_task runs; a version partly filed before resumable submits existed
422VALIDATION_ERRORA one-call patch field failed merged-state validation β€” the supplementary-unit pair was split, or country_of_activity set for a DOMESTIC activity, or the operator override couldn't resolve completely
422PRE_SUBMISSION_BLOCKEDOne or more pre-submission blockers (incl. operator_incomplete / country_of_activity_invalid / invalid_producer_country); details.blockers[] lists them. A TRACES acceptance rejection is no longer a 422 here: it fails the submit task.

Required scope: eudr:write