Skip to main content
POST
← Logistics API Creates one or up to 5,000 logistics legs (shipments) in one request. The legs are written at once and their distance and emissions are calculated asynchronously by an ingest job: the answer is the job (202, processing), never the legs.
  • All or nothing. If any record is invalid the answer is 422 BULK_RECORDS_REJECTED, listing every invalid record, and nothing is written: no leg, package or hub.
  • Idempotent. Idempotency-Key is required: retrying with the same key and body returns the original job.
  • Per-leg results. A leg whose distance or emissions could not be calculated is a failed item of the job, with its reason (error_code), and stays stored without emissions.
Beta. This endpoint is in beta: the contract may still change before general availability. Pin your integration to the fields documented here and handle unknown fields in responses gracefully. Feedback is welcome through your Dcycle contact.
Changed on 2026-10-16. This route used to be POST /v2/logistics/requests/bulk; only the path changed. One route now creates one or many legs, as POST /v2/wastes and POST /v2/facilities do. See the changelog.

Request

Headers

string
required
Your API key. Jobs submitted with an API key notify ingest_job.finished to the webhook endpoints of the x-organization-id organization.
string
required
UUID of the organization the legs belong to.
string
required
Must be application/json
string
required
1 to 255 characters. Send a unique value per batch (a UUID works well). Retrying with the same key and body returns the original job instead of writing the legs again; the same key with another body is 422 IDEMPOTENCY_KEY_REUSED, and while the first request is still being processed 409 IDEMPOTENCY_KEY_IN_PROGRESS. The key is unique across all the v2 bulk requests of your organization, not per route, and it does not expire.

Query Parameters

boolean
default:"false"
Validate only: the same checks and 422, but nothing is written and no job is created; a valid batch answers 200 with {"dry_run": true, "submitted": n, "valid": n}. The Idempotency-Key is still required but not recorded, so you can send the real request with the same key afterwards.

Body Parameters

array[object]
required
The legs to create, 1 to 5,000. Fields a record does not define are ignored without an error, so a misspelled field name (e.g. distance instead of distance_km) is lost. How the fields are read has the details that change the result.

How the fields are read

Several mistakes are accepted with 202 and calculated with a value you did not intend. Check these before you integrate:
  • toc: copy the toc of List TOCs exactly, e.g. van_3_5_t_diesel. It is matched loosely (case, spaces and dots are ignored, and a TOC whose vehicle and type both appear in the text matches), so a near miss can resolve to another TOC. It is looked up in the region of origin_country (GLO when omitted), then in GLO, then in EU. If it matches no TOC and you sent category, the leg is calculated with that category’s generic average, without an error. Send category alone only when you want that average.
  • load_unit: kg (default), t or ton, pallet or pallets (1,500 kg each), teu (10,000 kg) and feu (20,000 kg), in any case. Any other value is read as kilograms, without an error.
  • load: optional; without it, the TOC’s default load is used. load_factor multiplies the load, the default load included. The load is stored in kg with two decimals.
  • distance_km: used as sent. When you omit it, the distance is computed from origin and destination and multiplied by a distance adjustment factor: 0.95 for road, 0.85 for maritime, 1 for rail and air. A distance you send is not adjusted.
  • origin and destination: a leg without distance_km needs both (an existing hub_id supplies the origin); a record missing either is rejected with ADDRESS_REQUIRED. A destination equal to the name of an active hub of your organization is replaced by the hub’s address. origin is not read as a hub name here; use hub_id.
  • hub_id: a hub name, not its id. If it names an active hub of your organization, the leg’s origin is that hub’s address and the origin you send is ignored. Otherwise an active hub is created with this record’s origin as its address, and later records of the batch with the same hub_id use that hub and its address. An inactive hub with that name is not reused: a new active one is created.
  • shipment_date: optional; without it the leg is dated at the upload time (UTC).
  • year and tkm: not used to calculate the leg, which takes the year of shipment_date instead of year and recalculates tkm as load in tonnes × distance. A tkm you send stays on the leg only if its calculation fails.

Legs are upserted on their business key

The business key of a leg is, within your organization, movement_id, movement_stretch, movement_stage, client (default_client when omitted) and package_key. A key field you omit counts as empty.
  • Sending a leg again in a later batch replaces it: every field takes the value you send or its default (an omitted shipment_date becomes the upload time, an omitted hub_id unlinks the hub), its emissions are cleared, and it is calculated again. Its id is kept. Changing a key field, such as package_key, creates a new leg and leaves the old one.
  • Two records of the same batch with the same key are rejected (DUPLICATE_RECORD).
  • Legs created by any other route (the app, file uploads, the v1 endpoints) share the key: a leg sent here replaces the leg with the same key, wherever it came from.
Send a unique movement_id per leg. Legs without movement_id that share client, movement_stretch and movement_stage are the same leg: in one batch the second and later ones are rejected with DUPLICATE_RECORD, and with them the whole batch; in a later batch each one replaces the previous one.

Response

202 Accepted

The job, with Location: /v2/ingest-jobs/{id}. A retry with the same Idempotency-Key and body answers 202 with the same job and Location, showing its current status and counts.
uuid
Ingest job id. Use it with GET /v2/ingest-jobs/{job_id}.
string
processing right after the request. See Job status.
string
Always logistics for this endpoint.
string
Always create.
string
api_bulk when sent with an API key; app when sent by a person signed in to Dcycle.
object
submitted: legs in the request, all of them stored. succeeded: legs calculated. failed: legs whose calculation failed; the job’s items say which ones and why.
integer
Number of chunks the legs are calculated in.
integer
Chunks already calculated. chunks_done / chunks_total is the job’s progress.
datetime
When the job was created (UTC).
datetime | null
When the last chunk finished (UTC), null while the job is processing.
self: the job. items: its per-leg outcomes.

Job status

Poll GET /v2/ingest-jobs/{id} or subscribe to ingest_job.finished. GET /v2/ingest-jobs/{id}/items maps each record (source_row_index, client_row_id) to its leg (entity_id) and its result.
If chunks_done stops moving, see A job that does not finish. Resending the batch with a new Idempotency-Key starts a second job and leaves the first one processing.

Why a leg failed to calculate

A failed item’s error_code says why, and error_params.message explains it. The leg stays stored without emissions (and without a distance, unless you sent one); the reason is only on the job item. Fix the cause and send the leg again: with the same business key it is updated in place. An address the distance engine cannot geocode, or one made only of spaces or punctuation, comes as DISTANCE_CALCULATION_ERROR; error_params.message says what failed. A road or rail leg whose two addresses are the same place is not an error: it is calculated as 3 km (2.85 km for road, after the adjustment factor).

The leg changed while the job was running (rare)

The legs are calculated after the 202, reading each leg as it is at that moment. If you send a leg again while an earlier job that contains it is still processing, both jobs calculate it with the data it has when their chunk runs, and both report it as succeeded. Wait for the first job to finish before you send a leg again.

Errors

Errors follow RFC 9457 problem details (Content-Type: application/problem+json): a stable code, a human detail, errors when there is a list of problems, and the request_id to quote to support.

422 BULK_RECORDS_REJECTED — invalid records

Nothing was written. rejected is how many records are invalid and errors lists them (up to 100) in submission order, with source_row_index (starting at 0), your client_row_id and a stable error_code. Each record carries one error_code: the first rule it breaks. A record with two problems shows the second one only after you fix the first, so validate again with ?dry_run=true before sending the batch. (SCHEMA_INVALID is the exception inside a record: its error_params.errors lists the record’s schema problems, up to 10.)

422 IDEMPOTENCY_KEY_REUSED

The Idempotency-Key was already used in this organization with a different body. Nothing is written. Send a new key for a new batch; to retry the original batch, resend exactly the same body. The order of the fields does not matter, but the values must be identical: 1000 and 1000.0, or null and an omitted field, are a different body.

409 IDEMPOTENCY_KEY_IN_PROGRESS

Another request with the same Idempotency-Key was still being processed, typically your own retry after a timeout. This request wrote no leg and created no job (packages or hubs it named may have been created; later requests reuse them). Wait the Retry-After seconds and retry with the same key and body: you get the job of the request that went through. See IDEMPOTENCY_KEY_IN_PROGRESS.

422 REQUEST_VALIDATION_FAILED — malformed request

The request itself is invalid: the Idempotency-Key header is missing, empty or over 255 characters; the body is not JSON; or records is missing, empty or has more than 5,000 entries. errors lists each problem with where it is (loc). See Errors.

401, 400, 404 and 403: key and organization

Before the records are looked at, the API key and the organization are checked. Each code has its section in Errors:
  • 401: no API key (AUTH_REQUIRED, CREDENTIALS_REQUIRED), a wrong or revoked one (INVALID_API_KEY), a key of an organization that is neither this one nor one of its parents (API_KEY_ORG_MISMATCH) or one too old to have an owner (API_KEY_NO_OWNER).
  • 400 ORGANIZATION_ID_REQUIRED: no x-organization-id; 404 ORGANIZATION_NOT_FOUND: no organization has that id.
  • 403 LOGGED_USER_NOT_MEMBER: the key’s user is not a member of the organization in x-organization-id.

429 TOO_MANY_REQUESTS

More than 10 requests in flight at once with your API key on this endpoint. The request was not processed. Wait the Retry-After seconds (10, rising to 20 if you keep hitting the limit) and retry.

Examples

Create a batch and wait for the calculation

Python

Retries and idempotency

  • Network error or timeout: retry with the same Idempotency-Key and the same body. If the first attempt was accepted you get its job back, and nothing is written twice.
  • 409 IDEMPOTENCY_KEY_IN_PROGRESS: the first request with that key is still being processed. Wait Retry-After seconds and retry with the same key and body.
  • 422 BULK_RECORDS_REJECTED: nothing was written. Fix the records and send the batch again; the same key works, since the first request created no job.
  • 422 IDEMPOTENCY_KEY_REUSED: the key already belongs to another batch. Use a new key, or resend the original body unchanged.
  • 429: too many requests in flight for your API key on this endpoint (unlike the Idempotency-Key, the limit is per key, not per organization). Wait Retry-After seconds; the request was not processed.
A new batch needs a new key.

Limits

Requests over the cap get HTTP 429 with a Retry-After header. The cap is on requests in flight at once, not on requests per second — see Rate Limits.

Get Ingest Job

Follow the calculation

List Ingest Job Items

Per-leg outcomes, including calculation failures

List TOCs

The toc values to send

List Requests

Retrieve requests with filtering and pagination