Submit DDS
Single endpoint, three modes β discriminated on data.attributes.mode:
| Mode | Behaviour | Response |
|---|---|---|
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 |
validate | Acceptance dry run as a task: every statement checked, nothing filed | 202 Accepted + task envelope |
manual | Operator 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:
| Version | Response |
|---|---|
| filed | 200 dds_submit_status with statements[]; needs_new_version when TRACES rejected a statement |
| a submit task is queued or running | 202 that task, meta.submission.in_progress: true |
| a split stopped part-way | 202 a new task, meta.submission.resumed: true; only unfiled statements are sent |
| partly filed before resumable submits existed | 409 until reconciled by hand |
another dds_record_task (e.g. create-version) is running | 409 |
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.
| Field | Notes |
|---|---|
name | Record name (nullable) |
operator | Operator block (partial) β same shape as create/PATCH |
commodity | Commodity block (partial), incl. the goodsMeasure supplementary-unit pair |
activity_type | DOMESTIC | IMPORT | EXPORT (flat) |
country_of_activity | EU-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_number | Caller'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_confidential | Boolean |
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:
| Field | Required | Notes |
|---|---|---|
mode | yes | Literal "manual" |
reference_number | yes | TRACES reference number |
verification_number | yes | TRACES verification number |
dds_uuid | no | UUID, when TRACES issued one |
submitted_at | no | ISO 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 type | Fires when |
|---|---|
plots_missing_screening | Included plots without a pinned deforestation metric (have not passed Β§6 full-resolution screening). |
unmitigated_article10_risk | Included plots with an active Article 10.2(h) sanctions match (Β§7.1 stop-the-line). |
engagement_threads_open | Supplier-engagement thread still open (reserved β GAP-5 not implemented). |
operator_incomplete | The resolved operator (override, else org defaults) is missing TRACES-mandatory fields. Both modes. |
country_of_activity_invalid | The resolved country of activity (submission override for IMPORT/EXPORT, else operator country) is outside EU-27 + XI. Both modes. |
operator_country_invalid | The 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_invalid | GAP-25: IMPORT/EXPORT with a VAT-identified operator β TRACES requires EORI on cross-border filings. Both modes. |
activity_not_allowed_for_operator_country | GAP-26: an operator outside EU-27 + XI may only file an IMPORT DDS. Both modes. |
invalid_producer_country | The 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_long | Commodity block incomplete / timber species missing / HS out of Annex I scope / description over 150 chars. |
hs_code_too_broad | hs_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_traces | fm-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_mismatch | IMPORT/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_unresolved | A 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_dds | The Β§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_missing | mode: "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_verification | EPO-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_type | countryOfActivity | borderCrossCountry |
|---|---|---|
DOMESTIC | operator.country (must be EU) | omitted β no cross-border movement |
TRADE | operator.country (must be EU) | omitted β intra-EU placement |
IMPORT | submission.country_of_activity override β else operator.country | same as countryOfActivity (EU port of entry) |
EXPORT | submission.country_of_activity override β else operator.country | same 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
submission.country_of_activity overrideProvide 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 aDOMESTICmerged activity type is rejected422 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 derivesborderCrossCountry === countryOfActivityon IMPORT/EXPORT (or omits it on DOMESTIC/TRADE), so an out-of-set value is always caught by thecountryOfActivitycheck first. Thefm-api:InvalidBorderCrossCountrydiscriminator 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)
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Body 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. |
| 401 | UNAUTHORIZED | Missing/invalid API key |
| 404 | NOT_FOUND | Record / current version isn't visible |
| 409 | CONFLICT | validate/manual on a submitted or mid-submission version; any mode while another dds_record_task runs; a version partly filed before resumable submits existed |
| 422 | VALIDATION_ERROR | A 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 |
| 422 | PRE_SUBMISSION_BLOCKED | One 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