> ## Documentation Index
> Fetch the complete documentation index at: https://code.dcycle.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Create Logistics Requests (v2)

> Create one or up to 5,000 logistics legs in one request; their distance and emissions are calculated by an ingest job

[← Logistics API](/api-reference/logistics/overview)

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](/api-reference/ingest-jobs/get): 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.

<Warning>
  **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.
</Warning>

<Warning>
  **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](/api-reference/changelog).
</Warning>

<RequestExample>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  curl -i -X POST "https://api.dcycle.io/v2/logistics/requests" \
    -H "x-api-key: ${DCYCLE_API_KEY}" \
    -H "x-organization-id: ${DCYCLE_ORG_ID}" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 7b1f9c2e-4d3a-4f8e-9a61-2c5d8e0f1a37" \
    -d '{
      "records": [
        {
          "client_row_id": "SHIP-001",
          "movement_id": "SHIP-001",
          "origin": "Madrid, Spain",
          "destination": "Barcelona, Spain",
          "origin_country": "ES",
          "load": 1000,
          "load_unit": "kg",
          "toc": "van_3_5_t_diesel",
          "shipment_date": "2026-10-01"
        }
      ]
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json 202 theme={"theme":{"light":"github-light","dark":"github-dark"}}
  {
    "id": "4f0b8b3e-6a41-4b7e-9d0c-2c1f3a5e8d10",
    "status": "processing",
    "entity_type": "logistics",
    "operation": "create",
    "source": "api_bulk",
    "counts": { "submitted": 1, "succeeded": 0, "failed": 0 },
    "chunks_total": 1,
    "chunks_done": 0,
    "created_at": "2026-10-15T08:00:00.000Z",
    "finished_at": null,
    "links": {
      "self": "/v2/ingest-jobs/4f0b8b3e-6a41-4b7e-9d0c-2c1f3a5e8d10",
      "items": "/v2/ingest-jobs/4f0b8b3e-6a41-4b7e-9d0c-2c1f3a5e8d10/items"
    }
  }
  ```

  ```json 422 theme={"theme":{"light":"github-light","dark":"github-dark"}}
  {
    "type": "https://code.dcycle.io/api-reference/errors#BULK_RECORDS_REJECTED",
    "title": "Unprocessable Entity",
    "status": 422,
    "code": "BULK_RECORDS_REJECTED",
    "detail": "1 of the submitted records are invalid; nothing was written.",
    "errors": [
      {
        "source_row_index": 0,
        "client_row_id": "SHIP-001",
        "error_code": "INVALID_LOAD",
        "error_params": { "detail": "Load cannot be negative or 0" }
      }
    ],
    "rejected": 1,
    "request_id": "b2248a31-0f5e-4a54-9f0a-3d1f2b6c7e90"
  }
  ```

  ```json 200 (dry_run=true) theme={"theme":{"light":"github-light","dark":"github-dark"}}
  {
    "dry_run": true,
    "submitted": 1,
    "valid": 1
  }
  ```
</ResponseExample>

## Request

### Headers

<ParamField header="x-api-key" type="string" required>
  Your API key. Jobs submitted with an API key notify `ingest_job.finished` to the
  [webhook endpoints](/api-reference/webhooks/overview) of the `x-organization-id` organization.
</ParamField>

<ParamField header="x-organization-id" type="string" required>
  UUID of the organization the legs belong to.
</ParamField>

<ParamField header="Content-Type" type="string" required>
  Must be `application/json`
</ParamField>

<ParamField header="Idempotency-Key" type="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.
</ParamField>

### Query Parameters

<ParamField query="dry_run" type="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.
</ParamField>

### Body Parameters

<ParamField body="records" type="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](#how-the-fields-are-read) has the
  details that change the result.

  <Expandable title="record fields">
    <ParamField body="toc" type="string">
      The transport operation category (vehicle type), as `toc` in [List TOCs](/api-reference/logistics/list-tocs),
      e.g. `van_3_5_t_diesel`. Required unless you send `category`.
    </ParamField>

    <ParamField body="category" type="string">
      `road`, `rail`, `maritime` or `air`. Required without `toc`: the leg then uses that category's generic average.
    </ParamField>

    <ParamField body="origin" type="string">
      Origin address (max 500 characters). With `destination`, needed when you do not send `distance_km`; a known
      `hub_id` supplies it instead.
    </ParamField>

    <ParamField body="destination" type="string">
      Destination address, or the name of an active hub of your organization (replaced by its address). Max 500
      characters. Needed when you do not send `distance_km`.
    </ParamField>

    <ParamField body="distance_km" type="number">
      Distance in km, greater than 0. Used as sent. When omitted, it is computed from `origin` and `destination`.
    </ParamField>

    <ParamField body="origin_country" type="string" default="GLO">
      ISO 3166-1 alpha-2 code of the origin country, or `GLO`. Selects the region's TOCs and emission factors.
    </ParamField>

    <ParamField body="load" type="number">
      The load, greater than 0, in `load_unit`. When omitted, the TOC's default load is used.
    </ParamField>

    <ParamField body="load_unit" type="string" default="kg">
      `kg`, `t`/`ton`, `pallet`/`pallets` (1,500 kg each), `teu` (10,000 kg) or `feu` (20,000 kg).
    </ParamField>

    <ParamField body="load_factor" type="number" default="1">
      In (0, 1]; multiplies the load.
    </ParamField>

    <ParamField body="shipment_date" type="date">
      `YYYY-MM-DD`, from 1970 to next year. Its year selects the emission factors. Default: the upload date (UTC).
    </ParamField>

    <ParamField body="cleaning" type="boolean" default="false">
      Whether the vehicle was cleaned; it increases the emissions.
    </ParamField>

    <ParamField body="subcontractor" type="boolean" default="true">
      Whether a subcontractor handled the leg.
    </ParamField>

    <ParamField body="subcontractor_type" type="string">
      `fis` (fully integrated) or `nis` (non-integrated) subcontractor, as in SQAS / ISO 14083. Only with
      `subcontractor: true`.
    </ParamField>

    <ParamField body="movement_id" type="string">
      Your identifier of the movement (max 255 characters). Part of the
      [business key](#legs-are-upserted-on-their-business-key): send one per leg.
    </ParamField>

    <ParamField body="movement_stretch" type="string">
      The leg's stretch within the movement (max 255 characters). Part of the business key.
    </ParamField>

    <ParamField body="movement_stage" type="string">
      The leg's stage within the movement (max 255 characters). Part of the business key.
    </ParamField>

    <ParamField body="client" type="string" default="default_client">
      Your customer for this leg (max 255 characters). Part of the business key.
    </ParamField>

    <ParamField body="package_key" type="string">
      Your identifier of the package the leg carries (max 255 characters); legs with the same `package_key` form one
      package. Requires `movement_id`. Part of the business key.
    </ParamField>

    <ParamField body="hub_id" type="string">
      The **name** of a hub of your organization, not its id (max 255 characters). See
      [How the fields are read](#how-the-fields-are-read).
    </ParamField>

    <ParamField body="vehicle_license_plate" type="string">
      Max 255 characters.
    </ParamField>

    <ParamField body="trailer_license_plate" type="string">
      Max 255 characters.
    </ParamField>

    <ParamField body="request_date" type="date">
      The date your customer placed the order. Stored for reference; not used to calculate.
    </ParamField>

    <ParamField body="client_row_id" type="string">
      Your key for the record (max 255 characters), echoed on its job item and in a `422`.
    </ParamField>

    <ParamField body="year" type="integer">
      Accepted but not used: the year of `shipment_date` selects the emission factors.
    </ParamField>

    <ParamField body="tkm" type="number">
      Accepted but recalculated as load in tonnes × distance; kept only if the leg's calculation fails.
    </ParamField>
  </Expandable>
</ParamField>

### 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](/api-reference/logistics/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.

<Warning>
  **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.
</Warning>

## 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.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "4f0b8b3e-6a41-4b7e-9d0c-2c1f3a5e8d10",
  "status": "processing",
  "entity_type": "logistics",
  "operation": "create",
  "source": "api_bulk",
  "counts": { "submitted": 1, "succeeded": 0, "failed": 0 },
  "chunks_total": 1,
  "chunks_done": 0,
  "created_at": "2026-10-15T08:00:00.000Z",
  "finished_at": null,
  "links": {
    "self": "/v2/ingest-jobs/4f0b8b3e-6a41-4b7e-9d0c-2c1f3a5e8d10",
    "items": "/v2/ingest-jobs/4f0b8b3e-6a41-4b7e-9d0c-2c1f3a5e8d10/items"
  }
}
```

<ResponseField name="id" type="uuid">
  Ingest job id. Use it with [`GET /v2/ingest-jobs/{job_id}`](/api-reference/ingest-jobs/get).
</ResponseField>

<ResponseField name="status" type="string">
  `processing` right after the request. See [Job status](#job-status).
</ResponseField>

<ResponseField name="entity_type" type="string">
  Always `logistics` for this endpoint.
</ResponseField>

<ResponseField name="operation" type="string">
  Always `create`.
</ResponseField>

<ResponseField name="source" type="string">
  `api_bulk` when sent with an API key; `app` when sent by a person signed in to Dcycle.
</ResponseField>

<ResponseField name="counts" type="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.
</ResponseField>

<ResponseField name="chunks_total" type="integer">
  Number of chunks the legs are calculated in.
</ResponseField>

<ResponseField name="chunks_done" type="integer">
  Chunks already calculated. `chunks_done / chunks_total` is the job's progress.
</ResponseField>

<ResponseField name="created_at" type="datetime">
  When the job was created (UTC).
</ResponseField>

<ResponseField name="finished_at" type="datetime | null">
  When the last chunk finished (UTC), `null` while the job is processing.
</ResponseField>

<ResponseField name="links" type="object">
  `self`: the job. `items`: its per-leg outcomes.
</ResponseField>

### Job status

Poll [`GET /v2/ingest-jobs/{id}`](/api-reference/ingest-jobs/get) or subscribe to `ingest_job.finished`.
[`GET /v2/ingest-jobs/{id}/items`](/api-reference/ingest-jobs/list-items) maps each record (`source_row_index`,
`client_row_id`) to its leg (`entity_id`) and its result.

| `status` | Meaning |
| - | - |
| `processing` | The legs are stored; their distance and emissions are being calculated. |
| `completed` | Every leg was calculated. |
| `completed_with_errors` | Every chunk ran, and at least one leg (possibly all of them) failed to calculate (`counts.failed > 0`). |

<Warning>
  If `chunks_done` stops moving, see [A job that does not finish](/api-reference/ingest-jobs/get#a-job-that-does-not-finish).
  Resending the batch with a new `Idempotency-Key` starts a second job and leaves the first one `processing`.
</Warning>

### 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.

| `error_code` | Cause | Fix |
| - | - | - |
| `DISTANCE_CALCULATION_ERROR` | An address could not be located (or is blank), or there is no route between `origin` and `destination` | Send a more precise address, or `distance_km` |
| `SEA_CROSSING_REQUIRES_MARITIME_MODE` | Origin and destination are separated by sea, so the leg cannot go by road or rail | Declare a maritime `toc`/`category`, or split the leg |
| `NO_DISTANCE` | The route gave 0 km (e.g. between two different places, or an `air` or `maritime` leg whose two addresses are the same place) | Send `distance_km`; contact support if the addresses are correct |
| `NO_LOAD` | The load converted to kilograms is below 0.005 kg, so it is stored as 0 | Send the real load |

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.

| `error_code` | Cause | Fix |
| - | - | - |
| `NO_TOC` | The leg's TOC was retired from the catalogue before the leg was calculated | Send it again with a current `toc` |
| `NOT_FOUND` | The leg was deleted before it was calculated | Send it again if you still need it |

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](/api-reference/errors) (`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.)

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "type": "https://code.dcycle.io/api-reference/errors#BULK_RECORDS_REJECTED",
  "title": "Unprocessable Entity",
  "status": 422,
  "code": "BULK_RECORDS_REJECTED",
  "detail": "3 of the submitted records are invalid; nothing was written.",
  "errors": [
    {"source_row_index": 1, "client_row_id": "row-2", "error_code": "INVALID_LOAD",
     "error_params": {"detail": "Load cannot be negative or 0"}},
    {"source_row_index": 2, "client_row_id": "row-3", "error_code": "SCHEMA_INVALID",
     "error_params": {"errors": [{"loc": ["category"], "type": "value_error", "msg": "Value error, Invalid category"}]}},
    {"source_row_index": 3, "client_row_id": "row-4", "error_code": "DUPLICATE_RECORD",
     "error_params": {"duplicate_of": 0}}
  ],
  "rejected": 3,
  "request_id": "b2248a31-0f5e-4a54-9f0a-3d1f2b6c7e90"
}
```

| `error_code` | Cause | Fix |
| - | - | - |
| `SCHEMA_INVALID` | The record does not match the schema: a wrong type; `category` other than `road`, `rail`, `maritime` or `air` (lowercase), or `null` without a `toc`; `origin_country` not ISO 3166-1 alpha-2 or `GLO`; `load_factor` outside (0, 1]; `package_key` without `movement_id`; `subcontractor_type` other than `fis`/`nis`, or with `subcontractor: false`; `movement_id`, `movement_stretch`, `movement_stage`, `client`, `package_key`, `hub_id` or a license plate over 255 characters or with a NUL character; `client_row_id` over 255 characters; or the record is not a JSON object. `error_params.errors` lists each problem (`loc`, `type`, `msg`) | Fix the fields listed |
| `INVALID_LOAD` | `load` is 0 or negative (`error_params.detail`) | Send a positive `load`, or omit it to use the TOC's default |
| `INVALID_DISTANCE` | `distance_km` is 0 or negative (`error_params.detail`) | Send a positive `distance_km`, or omit it to have it computed |
| `ACTIVITY_DATE_OUT_OF_RANGE` | The year of `shipment_date` is before 1970 or after next year (`error_params.detail`) | Fix the date |
| `ADDRESS_REQUIRED` | No `distance_km`, and `origin` or `destination` is missing or blank (with `hub_id`, the hub has no address), so the distance could not be computed (`error_params.fields`: the missing ones) | Send both addresses, or `distance_km` |
| `ADDRESS_TOO_LONG` | `origin` or `destination` (the hub's address, when a hub replaces it) is over 500 characters (`error_params`: `field`, `length`, `max`) | Send the address alone |
| `TOC_NOT_FOUND` | No TOC matches `toc`, and no generic average of `category` exists, in the region of `origin_country`, in `GLO` or in `EU`; also when neither `toc` nor `category` is sent (`error_params`: `toc`, `category`, `region`) | Use a TOC from [List TOCs](/api-reference/logistics/list-tocs) |
| `LOAD_NOT_AVAILABLE` | No `load`, and the TOC has no default load (`error_params`: `toc`, `category`) | Send `load` |
| `DUPLICATE_RECORD` | Another record of the batch has the same [business key](#legs-are-upserted-on-their-business-key) (`error_params.duplicate_of`: its index) | Send each leg once, each with its own `movement_id` |

### 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.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "type": "https://code.dcycle.io/api-reference/errors#IDEMPOTENCY_KEY_REUSED",
  "title": "Unprocessable Entity",
  "status": 422,
  "code": "IDEMPOTENCY_KEY_REUSED",
  "detail": "This Idempotency-Key was already used with a different request body. Use a new key per batch.",
  "request_id": "b2248a31-0f5e-4a54-9f0a-3d1f2b6c7e90"
}
```

### 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](/api-reference/errors#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](/api-reference/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](/api-reference/errors#codes):

* `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 Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import os
import time
import uuid

import requests

BASE = "https://api.dcycle.io"
HEADERS = {
    "x-api-key": os.environ["DCYCLE_API_KEY"],
    "x-organization-id": os.environ["DCYCLE_ORG_ID"],
}
records = [
    {
        "client_row_id": "SHIP-001",
        "movement_id": "SHIP-001",  # unique per leg: it is part of the leg's business key
        "origin": "Madrid, Spain",
        "destination": "Barcelona, Spain",
        "origin_country": "ES",
        "load": 1000,
        "load_unit": "kg",
        "toc": "van_3_5_t_diesel",  # copied from GET /v1/logistics/tocs
        "shipment_date": "2026-10-01",
    }
]

# One key per batch: reuse it only to retry this same batch.
response = requests.post(
    f"{BASE}/v2/logistics/requests",
    headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
    json={"records": records},
    timeout=120,
)
if response.status_code == 422 and response.json().get("code") == "BULK_RECORDS_REJECTED":
    for rejection in response.json()["errors"]:
        print(rejection["source_row_index"], rejection["client_row_id"], rejection["error_code"],
              rejection["error_params"])
    raise SystemExit("Fix the listed records and send the whole batch again")
response.raise_for_status()

job_url = BASE + response.headers["Location"]
deadline = time.monotonic() + 30 * 60
while True:
    job_response = requests.get(job_url, headers=HEADERS, timeout=30)
    job_response.raise_for_status()
    job = job_response.json()
    if job["status"] != "processing":
        break
    if time.monotonic() > deadline:
        raise SystemExit(f"Job {job['id']} is still processing after 30 minutes: contact support with its id")
    time.sleep(10)

page = 1
while job["status"] == "completed_with_errors":  # read every page of failures
    failed = requests.get(
        f"{job_url}/items",
        headers=HEADERS,
        params={"status": "failed", "size": 500, "page": page},
        timeout=30,
    ).json()
    for item in failed["items"]:
        print("not calculated:", item["client_row_id"], item["error_code"], item["error_params"])
    if page * failed["size"] >= failed["total"]:
        break
    page += 1
```

## 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

| Limit | Value |
| - | - |
| Records per request | 1 to 5,000 |
| Maximum concurrent requests | 10 per API key, per endpoint |

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](/api-reference/rate-limits).

## Related Endpoints

<CardGroup cols={2}>
  <Card title="Get Ingest Job" icon="clock" href="/api-reference/ingest-jobs/get">
    Follow the calculation
  </Card>

  <Card title="List Ingest Job Items" icon="list-check" href="/api-reference/ingest-jobs/list-items">
    Per-leg outcomes, including calculation failures
  </Card>

  <Card title="List TOCs" icon="truck" href="/api-reference/logistics/list-tocs">
    The `toc` values to send
  </Card>

  <Card title="List Requests" icon="list" href="/api-reference/logistics/list-requests">
    Retrieve requests with filtering and pagination
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.