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

# Bulk deliveries

> Create up to 100 deliveries in one request and handle each result safely.

`POST /deliveries/bulk` creates many deliveries in one request. It is useful when your system collects orders over time and books them together, for example a morning batch from your shop or a list exported from a spreadsheet.

Each item you send is exactly the body you would send to `POST /deliveries`. If you can create one delivery, you already know how to build a bulk request.

<Note>
  Use single `POST /deliveries` when you book each order as it arrives. Use bulk when you already have a list of 2–100 deliveries ready to send at the same time.
</Note>

## Send a batch

Wrap your deliveries in a `deliveries` array. Send 1–100 items. Unknown fields are rejected, just like the single create.

```bash theme={null}
curl --request POST "$HAULSTOW_BASE_URL/deliveries/bulk" \
  --header "Authorization: Bearer $HAULSTOW_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: morning-batch-2026-08-20-v1" \
  --data '{
    "deliveries": [
      {
        "external_id": "shop-order-1842",
        "recipient": {
          "name": "Ama Mensah",
          "phone_number": "+233245412312",
          "address": {
            "address": "15 Independence Avenue, Accra",
            "latitude": 5.5600,
            "longitude": -0.2057
          }
        },
        "pickup": {
          "name": "Osu Store",
          "contact": "+233200000001",
          "address": "Oxford Street, Osu, Accra",
          "latitude": 5.5560,
          "longitude": -0.1820
        },
        "payment": {"type": "cod", "goods_value": 185},
        "vehicle_type": "bike"
      },
      {
        "external_id": "shop-order-1843",
        "recipient_id": "rcp_3f1a821cdb58498cbb52f4706545a089",
        "recipient_address_id": "adr_78de60204f574a64be88ba9bd94a049f",
        "pickup": {
          "name": "Osu Store",
          "contact": "+233200000001",
          "address": "Oxford Street, Osu, Accra",
          "latitude": 5.5560,
          "longitude": -0.1820
        },
        "payment": {"type": "delivery_only"},
        "vehicle_type": "bike"
      }
    ]
  }'
```

The request needs the `deliveries:write` scope and an `Idempotency-Key`, the same as a single create. The environment still comes from the base URL and key, so the same request works on sandbox and live.

A bulk call counts as **one request** against your per-key and per-business rate limits, however many items it contains.

## The two phases

HaulStow handles a bulk request in two steps.

### 1. Validation: all or nothing

First, every item is checked with the same rules as `POST /deliveries`. HaulStow also checks that no two items share an `external_id`.

If **any** item is invalid, **nothing** is created. You receive `422 BULK_VALIDATION_FAILED` with one entry per problem:

```json theme={null}
{
  "success": false,
  "statusCode": 422,
  "message": "2 of 4 deliveries are invalid",
  "error": {
    "code": "BULK_VALIDATION_FAILED",
    "details": {
      "errors": [
        {
          "index": 1,
          "code": "COD_GOODS_VALUE_REQUIRED",
          "message": "Goods value is required for COD deliveries"
        },
        {
          "index": 3,
          "code": "DUPLICATE_EXTERNAL_ID",
          "message": "external_id is repeated within this request"
        }
      ]
    }
  }
}
```

`index` is the item's position in your `deliveries` array, starting at 0. Fix the listed items and send the corrected batch with a new `Idempotency-Key`.

An empty array, or more than 100 items, returns `422 VALIDATION_ERROR` instead.

### 2. Creation: one item at a time

When every item passes validation, HaulStow creates them one at a time, in the order you sent them. Each item becomes an independent delivery with its own `dlv_...` ID and its own `delivery.created` [webhook](/webhooks).

An item can still fail at this stage. For example, a live quote may be unavailable, or its `external_id` may already belong to a delivery from an earlier request. That item is reported as failed and HaulStow carries on with the rest.

## Read the results

`data.results` contains one entry for every item you sent, in the same order. Each entry has the item's `index` and a `status` of `created` or `failed`.

```json theme={null}
{
  "success": true,
  "statusCode": 207,
  "message": "Some deliveries failed",
  "data": {
    "created": 1,
    "failed": 1,
    "results": [
      {
        "index": 0,
        "status": "created",
        "delivery": {
          "id": "dlv_3f1a821cdb58498cbb52f4706545a089",
          "external_id": "shop-order-1842",
          "status": "booked"
        }
      },
      {
        "index": 1,
        "status": "failed",
        "error": {
          "code": "EXTERNAL_ID_CONFLICT",
          "message": "External ID is already in use"
        }
      }
    ]
  }
}
```

| HTTP status | Meaning |
| - | - |
| `201` | Every item was created. |
| `207` | At least one item failed at creation. The others were created. This includes the case where every item failed. |
| `422` | Validation failed. Nothing was created. |

A created result contains the full delivery object, the same one `POST /deliveries` returns. A failed result contains an `error` with a stable `code` and a human-readable `message`. The codes are the same ones the single create uses; see [common errors](/errors).

<Warning>
  Do not treat `207` as a failed request. Some of your deliveries were created. Save every `created` result before you handle the failures.
</Warning>

## Retry failed items safely

HaulStow stores the complete bulk response under your `Idempotency-Key`, including the failed items. If you retry with the same key and body, you receive that same response again with `Idempotent-Replayed: true`. The failed items are **not** attempted again.

This keeps a network retry safe: if your connection drops, resend the exact same request with the same key and you get the original result, with no duplicate deliveries.

To retry only the items that failed:

1. Collect the failed entries from `data.results` and look up each item by its `index`.
2. Decide whether each failure can be retried. `QUOTE_UNAVAILABLE` can be retried later; `EXTERNAL_ID_CONFLICT` means that delivery already exists, so fetch it with `GET /deliveries?external_id=...` instead.
3. Send a **new** bulk request containing just the retryable items, with a **new** `Idempotency-Key`.

Put an `external_id` on every item. It lets you match each result to your own order, find a delivery later, and stops a retry from creating a second delivery for the same order.

## Try it in the sandbox

Bulk creation works the same way on the sandbox host with a test key. Each created sandbox delivery starts at `booked`. Move it forward with the [transition helper](/sandbox#simulate-status-changes), one delivery and one step at a time.

## Upload a CSV in the developer portal

You can also create a batch without writing code. Open the **Deliveries** page for your application in the developer portal and upload a CSV file.

* Choose which application the deliveries belong to.
* Use the portal's test/live switch to pick the environment. Test creates simulated sandbox deliveries; live creates real ones.
* Include up to 100 rows. The same two phases apply: if any row is invalid, nothing is created.

The first row must contain these column names:

| Column | Required | Meaning |
| - | - | - |
| `external_id` | No | Your own order ID. Must be unique across the file. |
| `pickup_name` | Yes | Pickup location or business name. |
| `pickup_contact` | Yes | Pickup contact phone number. |
| `pickup_address` | Yes | Pickup address text. |
| `pickup_latitude` | Yes | Pickup latitude. |
| `pickup_longitude` | Yes | Pickup longitude. |
| `recipient_id` | With `recipient_address_id` | An existing `rcp_...` recipient ID. |
| `recipient_address_id` | With `recipient_id` | An existing `adr_...` address ID. |
| `recipient_name` | No | Recipient's name, for an inline recipient. |
| `recipient_phone` | Inline recipient | Recipient's phone number in international format, such as `+233245412312`. |
| `dropoff_address` | Inline recipient | Drop-off address text. |
| `dropoff_latitude` | Inline recipient | Drop-off latitude. |
| `dropoff_longitude` | Inline recipient | Drop-off longitude. |
| `municipality` | No | Drop-off municipality, for an inline recipient. |
| `address_label` | No | A label for an inline drop-off address, such as `Home`. |
| `delivery_notes` | No | Notes for finding an inline drop-off address. |
| `payment_type` | Yes | `cod`, `delivery_only`, or `client_billed`. |
| `goods_value` | For `cod` | Positive goods value to collect. Leave empty for other payment types. |
| `vehicle_type` | Yes | `bike` or `minivan`. |
| `delivery_instructions` | No | Instructions for this delivery. |

Each row uses **either** `recipient_id` and `recipient_address_id` **or** the inline recipient columns, never both. This is the same rule as the API's two recipient shapes.


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