Skip to content

Bulk shipping with batches

This tutorial imports a large set of orders as shipments in one call, polls the batch until it finishes, handles the items that failed, and pulls a CSV of the results. Reference pages: Batches, Exports, Shipments.

Batches vs sequential POST /shipments

Sequential POST /shipmentsPOST /batches
ResponseSynchronous 201 with the created shipment (or an error) per requestImmediate 202 with a batch to poll; shipments are created by queue workers
VolumeFine for a handful of shipmentsUp to 2000 items per request, processed in parallel chunks
Failure isolationOne request = one resultA malformed item becomes a failed item; it never rejects the rest of the batch
LicensesOne license per requestA default license for the batch, plus an optional per-item license_key override

Each batch item is created through the same guarded path as a single POST /shipments (same validation rules, same duplicate-protection lock, same carrier translators), so behavior per item is identical — you just get the results asynchronously.

Use a batch when you are importing a CSV, syncing a night's orders, or migrating a store. Use single creates when the caller needs the tracking code in the same HTTP round-trip.

Step 1 — Create the batch

POST /batches accepts either envelope shape — {"shipments": [ ... ]} or a bare top-level JSON array (it is normalized to shipments internally). Each item is a full shipment body — the same {ship_data, order} payload as POST /shipments — and may carry its own license_key to target a different license you own (it must belong to your account, otherwise that item fails with forbidden; items without one use the batch's default license).

bash
curl --location 'https://app.shipos.co.il/api/v2/batches' \
--header 'X-Client-Id: {client_id}' \
--header 'X-Client-Secret: {client_secret}' \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data '{
  "license_key": "{license_key}",
  "shipments": [
    {
      "ship_data": {
        "contact_name": "Dana Levi",
        "contact_phone": "0521234567",
        "street": "Herzl",
        "number": "10",
        "city": "Tel Aviv",
        "type": "1",
        "return": "1",
        "packages": 1
      },
      "order": {
        "id": "1042",
        "number": "1042",
        "shipping": { "first_name": "Dana", "last_name": "Levi", "phone": "0521234567", "city": "Tel Aviv" }
      }
    },
    {
      "license_key": "{other_license_key}",
      "ship_data": {
        "contact_name": "Yossi Cohen",
        "contact_phone": "0549876543",
        "street": "Weizmann",
        "number": "14",
        "city": "Kfar Saba",
        "type": "1",
        "return": "1",
        "packages": 2
      },
      "order": {
        "id": "1043",
        "number": "1043",
        "shipping": { "first_name": "Yossi", "last_name": "Cohen", "phone": "0549876543", "city": "Kfar Saba" }
      }
    }
  ]
}'
js
// Node.js 18+ / browsers — no dependencies
const res = await fetch('https://app.shipos.co.il/api/v2/batches', {
  method: 'POST',
  headers: {
    'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
    'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    license_key: licenseKey,
    shipments: shipmentPayloads, // up to 2000 {ship_data, order} items
  }),
})

const { data: batch } = await res.json() // res.status === 202
const batchId = batch.id // poll this UUID
php
<?php
// composer require guzzlehttp/guzzle

$client = new \GuzzleHttp\Client(['base_uri' => 'https://app.shipos.co.il/api/v2/']);

$response = $client->post('batches', [
    'headers' => [
        'X-Client-Id' => getenv('SHIPOS_CLIENT_ID'),
        'X-Client-Secret' => getenv('SHIPOS_CLIENT_SECRET'),
        'Accept' => 'application/json',
    ],
    'json' => [
        'license_key' => $licenseKey,
        'shipments' => $shipmentPayloads, // up to 2000 {ship_data, order} items
    ],
]);

$batch = json_decode((string) $response->getBody(), true)['data'];
$batchId = $batch['id']; // poll this UUID
php
<?php

use Illuminate\Support\Facades\Http;

$batch = Http::withHeaders([
    'X-Client-Id' => config('services.shipos.client_id'),
    'X-Client-Secret' => config('services.shipos.client_secret'),
])
    ->acceptJson()
    ->post('https://app.shipos.co.il/api/v2/batches', [
        'license_key' => $licenseKey,
        'shipments' => $shipmentPayloads, // up to 2000 {ship_data, order} items
    ])
    ->throw()
    ->json('data');

$batchId = $batch['id']; // poll this UUID
python
# pip install httpx
import os

import httpx

response = httpx.post(
    "https://app.shipos.co.il/api/v2/batches",
    headers={
        "X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
        "X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
        "Accept": "application/json",
    },
    json={
        "license_key": license_key,
        "shipments": shipment_payloads,  # up to 2000 {ship_data, order} items
    },
)
response.raise_for_status()

batch = response.json()["data"]  # response.status_code == 202
batch_id = batch["id"]  # poll this UUID
go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	body, _ := json.Marshal(map[string]any{
		"license_key": os.Getenv("SHIPOS_LICENSE_KEY"),
		"shipments":   shipmentPayloads, // up to 2000 {ship_data, order} items
	})

	req, _ := http.NewRequest("POST", "https://app.shipos.co.il/api/v2/batches", bytes.NewReader(body))
	req.Header.Set("X-Client-Id", os.Getenv("SHIPOS_CLIENT_ID"))
	req.Header.Set("X-Client-Secret", os.Getenv("SHIPOS_CLIENT_SECRET"))
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var payload struct {
		Data struct {
			ID string `json:"id"`
		} `json:"data"`
	}
	json.NewDecoder(res.Body).Decode(&payload)

	fmt.Println("poll batch", payload.Data.ID) // res.StatusCode == 202
}
java
// Java 17+ — java.net.http, no dependencies (serialize items with Jackson/Gson)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class CreateBatch {
    public static void main(String[] args) throws Exception {
        String shipments = shipmentPayloadsAsJson(); // up to 2000 {ship_data, order} items
        String body = "{\"license_key\":\"" + System.getenv("SHIPOS_LICENSE_KEY")
            + "\",\"shipments\":" + shipments + "}";

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://app.shipos.co.il/api/v2/batches"))
            .header("X-Client-Id", System.getenv("SHIPOS_CLIENT_ID"))
            .header("X-Client-Secret", System.getenv("SHIPOS_CLIENT_SECRET"))
            .header("Accept", "application/json")
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println(response.body()); // 202 {"data":{"id":"<uuid>", ...}}
    }
}
csharp
// .NET 8+ — System.Net.Http.Json
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://app.shipos.co.il/api/v2/"),
};
http.DefaultRequestHeaders.Add("X-Client-Id",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_ID"));
http.DefaultRequestHeaders.Add("X-Client-Secret",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_SECRET"));

var response = await http.PostAsJsonAsync("batches", new
{
    license_key = Environment.GetEnvironmentVariable("SHIPOS_LICENSE_KEY"),
    shipments = shipmentPayloads, // up to 2000 {ship_data, order} items
});
response.EnsureSuccessStatusCode(); // 202

var payload = await response.Content.ReadFromJsonAsync<JsonDocument>();
var batchId = payload!.RootElement.GetProperty("data").GetProperty("id").GetString();
Console.WriteLine($"poll batch {batchId}");
ruby
require "net/http"
require "json"

uri = URI("https://app.shipos.co.il/api/v2/batches")
request = Net::HTTP::Post.new(uri)
request["X-Client-Id"] = ENV.fetch("SHIPOS_CLIENT_ID")
request["X-Client-Secret"] = ENV.fetch("SHIPOS_CLIENT_SECRET")
request["Accept"] = "application/json"
request["Content-Type"] = "application/json"
request.body = JSON.dump(
  license_key: ENV.fetch("SHIPOS_LICENSE_KEY"),
  shipments: shipment_payloads, # up to 2000 {ship_data, order} items
)

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

raise "ShipOS error: #{response.body}" unless response.is_a?(Net::HTTPSuccess)

batch = JSON.parse(response.body).fetch("data") # 202
batch_id = batch["id"] # poll this UUID
rust
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let shipment_payloads = json!([/* up to 2000 {ship_data, order} items */]);

    let payload: Value = reqwest::Client::new()
        .post("https://app.shipos.co.il/api/v2/batches")
        .header("X-Client-Id", std::env::var("SHIPOS_CLIENT_ID")?)
        .header("X-Client-Secret", std::env::var("SHIPOS_CLIENT_SECRET")?)
        .header("Accept", "application/json")
        .json(&json!({
            "license_key": std::env::var("SHIPOS_LICENSE_KEY")?,
            "shipments": shipment_payloads,
        }))
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    println!("poll batch {}", payload["data"]["id"]); // 202
    Ok(())
}

Response 202 Accepted — nothing is created yet:

json
{
  "data": {
    "id": "9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55",
    "status": "queued",
    "summary": {
      "total": 2,
      "created": 0,
      "failed": 0,
      "pending": 2
    },
    "created_at": "2026-07-29T08:00:00.000000Z",
    "updated_at": "2026-07-29T08:00:00.000000Z"
  }
}

Item validation is deferred

The create call only validates the envelope (1–2000 items). Each item's body is validated during processing — an invalid item shows up later as a failed item with code: "validation_failed", not as a 422 on the create call.

Step 2 — Poll the batch

Poll GET /batches/{uuid} until status is completed. Statuses:

StatusMeaning
queuedCreated; no item resolved yet.
processingSome items resolved, others still pending.
completedEvery item is either created or failed.
bash
curl --location 'https://app.shipos.co.il/api/v2/batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55?license_key={license_key}' \
--header 'X-Client-Id: {client_id}' \
--header 'X-Client-Secret: {client_secret}' \
--header 'Accept: application/json'
js
// Node.js 18+ / browsers — no dependencies
const batchId = '9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55'
const url = `https://app.shipos.co.il/api/v2/batches/${batchId}?license_key=${licenseKey}`
const headers = {
  'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
  'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
  Accept: 'application/json',
}

let batch
do {
  await new Promise((resolve) => setTimeout(resolve, 3000))

  const res = await fetch(url, { headers })
  batch = (await res.json()).data
  const { total, created, failed } = batch.summary
  console.log(`${created + failed}/${total}`)
} while (batch.status !== 'completed')
php
<?php
// composer require guzzlehttp/guzzle

$client = new \GuzzleHttp\Client([
    'base_uri' => 'https://app.shipos.co.il/api/v2/',
    'headers' => [
        'X-Client-Id' => getenv('SHIPOS_CLIENT_ID'),
        'X-Client-Secret' => getenv('SHIPOS_CLIENT_SECRET'),
        'Accept' => 'application/json',
    ],
]);

$batchId = '9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55';

do {
    sleep(3);

    $batch = json_decode((string) $client->get("batches/{$batchId}", [
        'query' => ['license_key' => $licenseKey],
    ])->getBody(), true)['data'];

    $summary = $batch['summary'];
    echo $summary['created'] + $summary['failed'], '/', $summary['total'], PHP_EOL;
} while ($batch['status'] !== 'completed');
php
<?php

use Illuminate\Support\Facades\Http;

$batchId = '9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55';

do {
    sleep(3);

    $batch = Http::withHeaders([
        'X-Client-Id' => config('services.shipos.client_id'),
        'X-Client-Secret' => config('services.shipos.client_secret'),
    ])
        ->acceptJson()
        ->get("https://app.shipos.co.il/api/v2/batches/{$batchId}", [
            'license_key' => $licenseKey,
        ])
        ->throw()
        ->json('data');

    logger()->info($batch['status'], $batch['summary']);
} while ($batch['status'] !== 'completed');
python
# pip install httpx
import os
import time

import httpx

batch_id = "9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55"
client = httpx.Client(
    base_url="https://app.shipos.co.il/api/v2",
    headers={
        "X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
        "X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
        "Accept": "application/json",
    },
)

while True:
    time.sleep(3)

    response = client.get(f"/batches/{batch_id}", params={"license_key": license_key})
    response.raise_for_status()
    batch = response.json()["data"]

    summary = batch["summary"]
    print(f"{summary['created'] + summary['failed']}/{summary['total']}")
    if batch["status"] == "completed":
        break
go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

type batchResponse struct {
	Data struct {
		Status  string         `json:"status"`
		Summary map[string]int `json:"summary"`
	} `json:"data"`
}

func main() {
	url := "https://app.shipos.co.il/api/v2/batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55" +
		"?license_key=" + os.Getenv("SHIPOS_LICENSE_KEY")

	for {
		time.Sleep(3 * time.Second)

		req, _ := http.NewRequest("GET", url, nil)
		req.Header.Set("X-Client-Id", os.Getenv("SHIPOS_CLIENT_ID"))
		req.Header.Set("X-Client-Secret", os.Getenv("SHIPOS_CLIENT_SECRET"))
		req.Header.Set("Accept", "application/json")

		res, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}

		var batch batchResponse
		json.NewDecoder(res.Body).Decode(&batch)
		res.Body.Close()

		s := batch.Data.Summary
		fmt.Printf("%d/%d\n", s["created"]+s["failed"], s["total"])
		if batch.Data.Status == "completed" {
			return
		}
	}
}
java
// Java 17+ — java.net.http, no dependencies (parse with Jackson/Gson)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class PollBatch {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://app.shipos.co.il/api/v2/batches/"
                + "9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55?license_key="
                + System.getenv("SHIPOS_LICENSE_KEY")))
            .header("X-Client-Id", System.getenv("SHIPOS_CLIENT_ID"))
            .header("X-Client-Secret", System.getenv("SHIPOS_CLIENT_SECRET"))
            .header("Accept", "application/json")
            .build();

        String body;
        do {
            Thread.sleep(3_000);
            body = client.send(request, HttpResponse.BodyHandlers.ofString()).body();
            System.out.println(body); // {"data":{"status":"...","summary":{...}}}
        } while (!body.contains("\"status\":\"completed\""));
    }
}
csharp
// .NET 8+ — System.Net.Http.Json
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://app.shipos.co.il/api/v2/"),
};
http.DefaultRequestHeaders.Add("X-Client-Id",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_ID"));
http.DefaultRequestHeaders.Add("X-Client-Secret",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_SECRET"));

var licenseKey = Environment.GetEnvironmentVariable("SHIPOS_LICENSE_KEY");
var url = $"batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55?license_key={licenseKey}";

JsonElement batch;
do
{
    await Task.Delay(3_000);

    var payload = await http.GetFromJsonAsync<JsonDocument>(url);
    batch = payload!.RootElement.GetProperty("data");

    var summary = batch.GetProperty("summary");
    Console.WriteLine($"{summary.GetProperty("created").GetInt32()
        + summary.GetProperty("failed").GetInt32()}/{summary.GetProperty("total").GetInt32()}");
} while (batch.GetProperty("status").GetString() != "completed");
ruby
require "net/http"
require "json"

uri = URI("https://app.shipos.co.il/api/v2/batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55")
uri.query = URI.encode_www_form(license_key: ENV.fetch("SHIPOS_LICENSE_KEY"))

request = Net::HTTP::Get.new(uri)
request["X-Client-Id"] = ENV.fetch("SHIPOS_CLIENT_ID")
request["X-Client-Secret"] = ENV.fetch("SHIPOS_CLIENT_SECRET")
request["Accept"] = "application/json"

loop do
  sleep 3

  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(request)
  end
  batch = JSON.parse(response.body).fetch("data")

  summary = batch["summary"]
  puts "#{summary["created"] + summary["failed"]}/#{summary["total"]}"
  break if batch["status"] == "completed"
end
rust
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
use serde_json::Value;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();
    let url = "https://app.shipos.co.il/api/v2/batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55";

    loop {
        tokio::time::sleep(Duration::from_secs(3)).await;

        let payload: Value = client
            .get(url)
            .query(&[("license_key", std::env::var("SHIPOS_LICENSE_KEY")?)])
            .header("X-Client-Id", std::env::var("SHIPOS_CLIENT_ID")?)
            .header("X-Client-Secret", std::env::var("SHIPOS_CLIENT_SECRET")?)
            .header("Accept", "application/json")
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;

        let batch = &payload["data"];
        println!("{} {}", batch["status"], batch["summary"]);
        if batch["status"] == "completed" {
            return Ok(());
        }
    }
}
json
{
  "data": {
    "id": "9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55",
    "status": "completed",
    "summary": {
      "total": 2,
      "created": 1,
      "failed": 1,
      "pending": 0
    },
    "created_at": "2026-07-29T08:00:00.000000Z",
    "updated_at": "2026-07-29T08:00:06.000000Z"
  }
}

The summary counters update live, so you can show progress (created + failed out of total) while it runs. A simple poll loop with a few seconds between requests is enough; batches are processed in parallel 50-item chunks across queue workers.

Step 3 — Inspect the items

GET /batches/{uuid}/items pages through the per-item results (cursor pagination, per_page default 50, max 100), in the order you submitted them (position is the zero-based index). Item statuses are pending, created, or failed.

bash
curl --location 'https://app.shipos.co.il/api/v2/batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55/items?license_key={license_key}&per_page=100' \
--header 'X-Client-Id: {client_id}' \
--header 'X-Client-Secret: {client_secret}' \
--header 'Accept: application/json'
js
// Node.js 18+ / browsers — follows the cursor until the last page
const batchId = '9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55'
const headers = {
  'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
  'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
  Accept: 'application/json',
}

let url = `https://app.shipos.co.il/api/v2/batches/${batchId}/items?license_key=${licenseKey}&per_page=100`
const failedItems = []

while (url) {
  const res = await fetch(url, { headers })
  const { data, links } = await res.json()

  failedItems.push(...data.filter((item) => item.status === 'failed'))
  url = links.next
}

console.log(`${failedItems.length} failed items`)
php
<?php
// composer require guzzlehttp/guzzle

$client = new \GuzzleHttp\Client([
    'base_uri' => 'https://app.shipos.co.il/api/v2/',
    'headers' => [
        'X-Client-Id' => getenv('SHIPOS_CLIENT_ID'),
        'X-Client-Secret' => getenv('SHIPOS_CLIENT_SECRET'),
        'Accept' => 'application/json',
    ],
]);

$batchId = '9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55';
$query = ['license_key' => $licenseKey, 'per_page' => 100];
$failedItems = [];

do {
    $page = json_decode((string) $client->get("batches/{$batchId}/items", [
        'query' => $query,
    ])->getBody(), true);

    foreach ($page['data'] as $item) {
        if ($item['status'] === 'failed') {
            $failedItems[] = $item;
        }
    }

    $query['cursor'] = $page['meta']['next_cursor'];
} while ($query['cursor'] !== null);

echo count($failedItems), ' failed items', PHP_EOL;
php
<?php

use Illuminate\Support\Facades\Http;

$batchId = '9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55';
$query = ['license_key' => $licenseKey, 'per_page' => 100];
$failedItems = [];

do {
    $page = Http::withHeaders([
        'X-Client-Id' => config('services.shipos.client_id'),
        'X-Client-Secret' => config('services.shipos.client_secret'),
    ])
        ->acceptJson()
        ->get("https://app.shipos.co.il/api/v2/batches/{$batchId}/items", $query)
        ->throw()
        ->json();

    $failedItems = array_merge(
        $failedItems,
        array_filter($page['data'], fn (array $item): bool => $item['status'] === 'failed'),
    );

    $query['cursor'] = $page['meta']['next_cursor'];
} while ($query['cursor'] !== null);

logger()->info(count($failedItems).' failed items');
python
# pip install httpx
import os

import httpx

batch_id = "9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55"
client = httpx.Client(
    base_url="https://app.shipos.co.il/api/v2",
    headers={
        "X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
        "X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
        "Accept": "application/json",
    },
)

params = {"license_key": license_key, "per_page": 100}
failed_items = []

while True:
    page = client.get(f"/batches/{batch_id}/items", params=params).raise_for_status().json()
    failed_items += [item for item in page["data"] if item["status"] == "failed"]

    cursor = page["meta"]["next_cursor"]
    if cursor is None:
        break
    params["cursor"] = cursor

print(len(failed_items), "failed items")
go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

type itemsPage struct {
	Data []struct {
		Position   int             `json:"position"`
		Status     string          `json:"status"`
		ShipmentID *string         `json:"shipment_id"`
		Error      json.RawMessage `json:"error"`
	} `json:"data"`
	Meta struct {
		NextCursor *string `json:"next_cursor"`
	} `json:"meta"`
}

func main() {
	// Repeat with &cursor=<meta.next_cursor> until next_cursor is null.
	url := "https://app.shipos.co.il/api/v2/batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55/items" +
		"?license_key=" + os.Getenv("SHIPOS_LICENSE_KEY") + "&per_page=100"

	req, _ := http.NewRequest("GET", url, nil)
	req.Header.Set("X-Client-Id", os.Getenv("SHIPOS_CLIENT_ID"))
	req.Header.Set("X-Client-Secret", os.Getenv("SHIPOS_CLIENT_SECRET"))
	req.Header.Set("Accept", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var page itemsPage
	json.NewDecoder(res.Body).Decode(&page)

	for _, item := range page.Data {
		if item.Status == "failed" {
			fmt.Printf("item %d failed: %s\n", item.Position, item.Error)
		}
	}
}
java
// Java 17+ — java.net.http (parse with Jackson/Gson, follow meta.next_cursor)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class BatchItems {
    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://app.shipos.co.il/api/v2/batches/"
                + "9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55/items?license_key="
                + System.getenv("SHIPOS_LICENSE_KEY") + "&per_page=100"))
            .header("X-Client-Id", System.getenv("SHIPOS_CLIENT_ID"))
            .header("X-Client-Secret", System.getenv("SHIPOS_CLIENT_SECRET"))
            .header("Accept", "application/json")
            .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        // {"data":[{"position":0,"status":"created","shipment_id":"..."}, ...],"meta":{...}}
        System.out.println(response.body());
    }
}
csharp
// .NET 8+ — System.Net.Http.Json
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://app.shipos.co.il/api/v2/"),
};
http.DefaultRequestHeaders.Add("X-Client-Id",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_ID"));
http.DefaultRequestHeaders.Add("X-Client-Secret",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_SECRET"));

var licenseKey = Environment.GetEnvironmentVariable("SHIPOS_LICENSE_KEY");
var url = "batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55/items"
    + $"?license_key={licenseKey}&per_page=100";

// Repeat with &cursor={meta.next_cursor} until next_cursor is null.
var page = await http.GetFromJsonAsync<JsonDocument>(url)
    ?? throw new InvalidOperationException("Empty response");

foreach (var item in page.RootElement.GetProperty("data").EnumerateArray())
{
    if (item.GetProperty("status").GetString() == "failed")
    {
        Console.WriteLine($"item {item.GetProperty("position").GetInt32()} failed: "
            + item.GetProperty("error").GetRawText());
    }
}
ruby
require "net/http"
require "json"

base = "https://app.shipos.co.il/api/v2/batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55/items"
query = { license_key: ENV.fetch("SHIPOS_LICENSE_KEY"), per_page: 100 }
failed_items = []

loop do
  uri = URI(base)
  uri.query = URI.encode_www_form(query)

  request = Net::HTTP::Get.new(uri)
  request["X-Client-Id"] = ENV.fetch("SHIPOS_CLIENT_ID")
  request["X-Client-Secret"] = ENV.fetch("SHIPOS_CLIENT_SECRET")
  request["Accept"] = "application/json"

  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
  page = JSON.parse(response.body)

  failed_items += page["data"].select { |item| item["status"] == "failed" }
  break if page.dig("meta", "next_cursor").nil?

  query[:cursor] = page.dig("meta", "next_cursor")
end

puts "#{failed_items.size} failed items"
rust
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
use serde_json::Value;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();
    let url = "https://app.shipos.co.il/api/v2/batches/9b1f7c2e-3a4d-4f18-9c7a-2b6e1d0f8a55/items";
    let license_key = std::env::var("SHIPOS_LICENSE_KEY")?;
    let mut cursor: Option<String> = None;

    loop {
        let mut query = vec![("license_key", license_key.clone()), ("per_page", "100".into())];
        if let Some(c) = &cursor {
            query.push(("cursor", c.clone()));
        }

        let page: Value = client
            .get(url)
            .query(&query)
            .header("X-Client-Id", std::env::var("SHIPOS_CLIENT_ID")?)
            .header("X-Client-Secret", std::env::var("SHIPOS_CLIENT_SECRET")?)
            .header("Accept", "application/json")
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;

        for item in page["data"].as_array().unwrap_or(&vec![]) {
            if item["status"] == "failed" {
                println!("item {} failed: {}", item["position"], item["error"]);
            }
        }

        cursor = page["meta"]["next_cursor"].as_str().map(str::to_string);
        if cursor.is_none() {
            return Ok(());
        }
    }
}
json
{
  "data": [
    {
      "id": 4101,
      "position": 0,
      "status": "created",
      "shipment_id": "b7d3a1e0-9f2c-4a8b-8e11-6c5d4b3a2f10",
      "payload": { "...": "the exact body you submitted for this item" },
      "error": null,
      "created_at": "2026-07-29T08:00:00.000000Z",
      "updated_at": "2026-07-29T08:00:04.000000Z"
    },
    {
      "id": 4102,
      "position": 1,
      "status": "failed",
      "shipment_id": null,
      "payload": { "...": "the exact body you submitted for this item" },
      "error": {
        "code": "validation_failed",
        "ship_data.city": ["The ship_data.city field is required."]
      },
      "created_at": "2026-07-29T08:00:00.000000Z",
      "updated_at": "2026-07-29T08:00:05.000000Z"
    }
  ],
  "links": { "prev": null, "next": null, "first": null, "last": null },
  "meta": { "path": "...", "per_page": 100, "next_cursor": null, "prev_cursor": null }
}

A created item carries the new shipment's UUID in shipment_id — use it with GET /shipments/{shipment}, the label endpoints, and so on.

Handling failed items

Every failed item has an error object with a stable code plus a message and/or field-level errors:

error.codeMeaningWhat to do
validation_failedThe item's body failed the shipment validation rules; field errors included.Fix the listed fields and resubmit that item.
forbiddenThe item's license_key does not belong to your account.Correct the key (list yours via GET /licenses).
carrier_errorThe carrier rejected the shipment.Fix the data the carrier complained about, or contact support.
server_errorAn unexpected error while processing the item.Retry the item; contact support if it persists.

To retry, collect the failed items' payload values, fix them, and submit them as a new batch (or as individual POST /shipments calls when only a few failed). Successfully created items are real shipments — do not resubmit those.

Step 4 — Export the results to CSV

Once the batch is done you can pull a CSV of your shipments. Exports are asynchronous too: queue one, poll it, download it.

Exports are license-wide

An export covers the license's shipments matching the filters (currently filter[active]), not one specific batch — the same rows you would see listing GET /shipments. Run it after your import to get a spreadsheet that includes everything the batch created.

Queue the export

POST /exports responds 202 with the export record. Only one export per license may be in flight — a second request while one is pending/processing returns 409 with code: "duplicate_request", so just keep polling the first.

bash
curl --location 'https://app.shipos.co.il/api/v2/exports' \
--header 'X-Client-Id: {client_id}' \
--header 'X-Client-Secret: {client_secret}' \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data '{
  "license_key": "{license_key}",
  "filter": { "active": true }
}'
js
// Node.js 18+ / browsers — no dependencies
const res = await fetch('https://app.shipos.co.il/api/v2/exports', {
  method: 'POST',
  headers: {
    'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
    'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    license_key: licenseKey,
    filter: { active: true },
  }),
})

const { data: export_ } = await res.json() // res.status === 202
const exportId = export_.id // poll this integer id
php
<?php
// composer require guzzlehttp/guzzle

$client = new \GuzzleHttp\Client(['base_uri' => 'https://app.shipos.co.il/api/v2/']);

$response = $client->post('exports', [
    'headers' => [
        'X-Client-Id' => getenv('SHIPOS_CLIENT_ID'),
        'X-Client-Secret' => getenv('SHIPOS_CLIENT_SECRET'),
        'Accept' => 'application/json',
    ],
    'json' => [
        'license_key' => $licenseKey,
        'filter' => ['active' => true],
    ],
]);

$export = json_decode((string) $response->getBody(), true)['data'];
$exportId = $export['id']; // poll this integer id
php
<?php

use Illuminate\Support\Facades\Http;

$export = Http::withHeaders([
    'X-Client-Id' => config('services.shipos.client_id'),
    'X-Client-Secret' => config('services.shipos.client_secret'),
])
    ->acceptJson()
    ->post('https://app.shipos.co.il/api/v2/exports', [
        'license_key' => $licenseKey,
        'filter' => ['active' => true],
    ])
    ->throw()
    ->json('data');

$exportId = $export['id']; // poll this integer id
python
# pip install httpx
import os

import httpx

response = httpx.post(
    "https://app.shipos.co.il/api/v2/exports",
    headers={
        "X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
        "X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
        "Accept": "application/json",
    },
    json={
        "license_key": license_key,
        "filter": {"active": True},
    },
)
response.raise_for_status()

export = response.json()["data"]  # response.status_code == 202
export_id = export["id"]  # poll this integer id
go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	body, _ := json.Marshal(map[string]any{
		"license_key": os.Getenv("SHIPOS_LICENSE_KEY"),
		"filter":      map[string]bool{"active": true},
	})

	req, _ := http.NewRequest("POST", "https://app.shipos.co.il/api/v2/exports", bytes.NewReader(body))
	req.Header.Set("X-Client-Id", os.Getenv("SHIPOS_CLIENT_ID"))
	req.Header.Set("X-Client-Secret", os.Getenv("SHIPOS_CLIENT_SECRET"))
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var payload struct {
		Data struct {
			ID int `json:"id"`
		} `json:"data"`
	}
	json.NewDecoder(res.Body).Decode(&payload)

	fmt.Println("poll export", payload.Data.ID) // res.StatusCode == 202
}
java
// Java 17+ — java.net.http, no dependencies (parse with Jackson/Gson)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class QueueExport {
    public static void main(String[] args) throws Exception {
        String body = """
            {"license_key": "%s", "filter": {"active": true}}
            """.formatted(System.getenv("SHIPOS_LICENSE_KEY"));

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://app.shipos.co.il/api/v2/exports"))
            .header("X-Client-Id", System.getenv("SHIPOS_CLIENT_ID"))
            .header("X-Client-Secret", System.getenv("SHIPOS_CLIENT_SECRET"))
            .header("Accept", "application/json")
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());

        System.out.println(response.body()); // 202 {"data":{"id":512, ...}}
    }
}
csharp
// .NET 8+ — System.Net.Http.Json
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://app.shipos.co.il/api/v2/"),
};
http.DefaultRequestHeaders.Add("X-Client-Id",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_ID"));
http.DefaultRequestHeaders.Add("X-Client-Secret",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_SECRET"));

var response = await http.PostAsJsonAsync("exports", new
{
    license_key = Environment.GetEnvironmentVariable("SHIPOS_LICENSE_KEY"),
    filter = new { active = true },
});
response.EnsureSuccessStatusCode(); // 202

var payload = await response.Content.ReadFromJsonAsync<JsonDocument>();
var exportId = payload!.RootElement.GetProperty("data").GetProperty("id").GetInt32();
Console.WriteLine($"poll export {exportId}");
ruby
require "net/http"
require "json"

uri = URI("https://app.shipos.co.il/api/v2/exports")
request = Net::HTTP::Post.new(uri)
request["X-Client-Id"] = ENV.fetch("SHIPOS_CLIENT_ID")
request["X-Client-Secret"] = ENV.fetch("SHIPOS_CLIENT_SECRET")
request["Accept"] = "application/json"
request["Content-Type"] = "application/json"
request.body = JSON.dump(
  license_key: ENV.fetch("SHIPOS_LICENSE_KEY"),
  filter: { active: true },
)

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

raise "ShipOS error: #{response.body}" unless response.is_a?(Net::HTTPSuccess)

export = JSON.parse(response.body).fetch("data") # 202
export_id = export["id"] # poll this integer id
rust
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let payload: Value = reqwest::Client::new()
        .post("https://app.shipos.co.il/api/v2/exports")
        .header("X-Client-Id", std::env::var("SHIPOS_CLIENT_ID")?)
        .header("X-Client-Secret", std::env::var("SHIPOS_CLIENT_SECRET")?)
        .header("Accept", "application/json")
        .json(&json!({
            "license_key": std::env::var("SHIPOS_LICENSE_KEY")?,
            "filter": { "active": true },
        }))
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    println!("poll export {}", payload["data"]["id"]); // 202
    Ok(())
}

Response 202:

json
{
  "data": {
    "id": 512,
    "type": "shipments",
    "status": "pending",
    "platform": null,
    "row_count": null,
    "filename": null,
    "error": null,
    "download_url": null,
    "created_at": "2026-07-29T08:10:00.000000Z",
    "updated_at": "2026-07-29T08:10:00.000000Z"
  }
}

Poll until completed

GET /exports/{export} — the status moves pendingprocessingcompleted (or failed, with the reason in error). When completed, download_url, filename, and row_count are populated:

bash
curl --location 'https://app.shipos.co.il/api/v2/exports/512?license_key={license_key}' \
--header 'X-Client-Id: {client_id}' \
--header 'X-Client-Secret: {client_secret}' \
--header 'Accept: application/json'
js
// Node.js 18+ / browsers — no dependencies
const exportId = 512
const url = `https://app.shipos.co.il/api/v2/exports/${exportId}?license_key=${licenseKey}`
const headers = {
  'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
  'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
  Accept: 'application/json',
}

let exportRecord
do {
  await new Promise((resolve) => setTimeout(resolve, 3000))

  const res = await fetch(url, { headers })
  exportRecord = (await res.json()).data
  console.log(exportRecord.status)
} while (!['completed', 'failed'].includes(exportRecord.status))

const downloadUrl = exportRecord.download_url // null when status is failed
php
<?php
// composer require guzzlehttp/guzzle

$client = new \GuzzleHttp\Client([
    'base_uri' => 'https://app.shipos.co.il/api/v2/',
    'headers' => [
        'X-Client-Id' => getenv('SHIPOS_CLIENT_ID'),
        'X-Client-Secret' => getenv('SHIPOS_CLIENT_SECRET'),
        'Accept' => 'application/json',
    ],
]);

$exportId = 512;

do {
    sleep(3);

    $export = json_decode((string) $client->get("exports/{$exportId}", [
        'query' => ['license_key' => $licenseKey],
    ])->getBody(), true)['data'];

    echo $export['status'], PHP_EOL;
} while (! in_array($export['status'], ['completed', 'failed'], true));

$downloadUrl = $export['download_url']; // null when status is failed
php
<?php

use Illuminate\Support\Facades\Http;

$exportId = 512;

do {
    sleep(3);

    $export = Http::withHeaders([
        'X-Client-Id' => config('services.shipos.client_id'),
        'X-Client-Secret' => config('services.shipos.client_secret'),
    ])
        ->acceptJson()
        ->get("https://app.shipos.co.il/api/v2/exports/{$exportId}", [
            'license_key' => $licenseKey,
        ])
        ->throw()
        ->json('data');

    logger()->info($export['status']);
} while (! in_array($export['status'], ['completed', 'failed'], true));

$downloadUrl = $export['download_url']; // null when status is failed
python
# pip install httpx
import os
import time

import httpx

export_id = 512
client = httpx.Client(
    base_url="https://app.shipos.co.il/api/v2",
    headers={
        "X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
        "X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
        "Accept": "application/json",
    },
)

while True:
    time.sleep(3)

    response = client.get(f"/exports/{export_id}", params={"license_key": license_key})
    response.raise_for_status()
    export = response.json()["data"]

    print(export["status"])
    if export["status"] in ("completed", "failed"):
        break

download_url = export["download_url"]  # None when status is failed
go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

type exportResponse struct {
	Data struct {
		Status      string `json:"status"`
		DownloadURL string `json:"download_url"`
	} `json:"data"`
}

func main() {
	url := "https://app.shipos.co.il/api/v2/exports/512" +
		"?license_key=" + os.Getenv("SHIPOS_LICENSE_KEY")

	for {
		time.Sleep(3 * time.Second)

		req, _ := http.NewRequest("GET", url, nil)
		req.Header.Set("X-Client-Id", os.Getenv("SHIPOS_CLIENT_ID"))
		req.Header.Set("X-Client-Secret", os.Getenv("SHIPOS_CLIENT_SECRET"))
		req.Header.Set("Accept", "application/json")

		res, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}

		var export exportResponse
		json.NewDecoder(res.Body).Decode(&export)
		res.Body.Close()

		fmt.Println(export.Data.Status)
		if export.Data.Status == "completed" || export.Data.Status == "failed" {
			fmt.Println(export.Data.DownloadURL) // empty when status is failed
			return
		}
	}
}
java
// Java 17+ — java.net.http, no dependencies (parse with Jackson/Gson)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class PollExport {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://app.shipos.co.il/api/v2/exports/512"
                + "?license_key=" + System.getenv("SHIPOS_LICENSE_KEY")))
            .header("X-Client-Id", System.getenv("SHIPOS_CLIENT_ID"))
            .header("X-Client-Secret", System.getenv("SHIPOS_CLIENT_SECRET"))
            .header("Accept", "application/json")
            .build();

        String body;
        do {
            Thread.sleep(3_000);
            body = client.send(request, HttpResponse.BodyHandlers.ofString()).body();
            System.out.println(body); // {"data":{"status":"...","download_url":...}}
        } while (!body.contains("\"status\":\"completed\"")
            && !body.contains("\"status\":\"failed\""));
    }
}
csharp
// .NET 8+ — System.Net.Http.Json
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://app.shipos.co.il/api/v2/"),
};
http.DefaultRequestHeaders.Add("X-Client-Id",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_ID"));
http.DefaultRequestHeaders.Add("X-Client-Secret",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_SECRET"));

var licenseKey = Environment.GetEnvironmentVariable("SHIPOS_LICENSE_KEY");
var url = $"exports/512?license_key={licenseKey}";

JsonElement export;
string? status;
do
{
    await Task.Delay(3_000);

    var payload = await http.GetFromJsonAsync<JsonDocument>(url);
    export = payload!.RootElement.GetProperty("data");

    status = export.GetProperty("status").GetString();
    Console.WriteLine(status);
} while (status is not ("completed" or "failed"));

Console.WriteLine(export.GetProperty("download_url").GetString());
ruby
require "net/http"
require "json"

uri = URI("https://app.shipos.co.il/api/v2/exports/512")
uri.query = URI.encode_www_form(license_key: ENV.fetch("SHIPOS_LICENSE_KEY"))

request = Net::HTTP::Get.new(uri)
request["X-Client-Id"] = ENV.fetch("SHIPOS_CLIENT_ID")
request["X-Client-Secret"] = ENV.fetch("SHIPOS_CLIENT_SECRET")
request["Accept"] = "application/json"

loop do
  sleep 3

  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(request)
  end
  export = JSON.parse(response.body).fetch("data")

  puts export["status"]
  if %w[completed failed].include?(export["status"])
    puts export["download_url"] # nil when status is failed
    break
  end
end
rust
// [dependencies]
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
use serde_json::Value;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = reqwest::Client::new();
    let url = "https://app.shipos.co.il/api/v2/exports/512";

    loop {
        tokio::time::sleep(Duration::from_secs(3)).await;

        let payload: Value = client
            .get(url)
            .query(&[("license_key", std::env::var("SHIPOS_LICENSE_KEY")?)])
            .header("X-Client-Id", std::env::var("SHIPOS_CLIENT_ID")?)
            .header("X-Client-Secret", std::env::var("SHIPOS_CLIENT_SECRET")?)
            .header("Accept", "application/json")
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;

        let export = &payload["data"];
        println!("{}", export["status"]);
        if export["status"] == "completed" || export["status"] == "failed" {
            println!("{}", export["download_url"]); // null when status is failed
            return Ok(());
        }
    }
}
json
{
  "data": {
    "id": 512,
    "type": "shipments",
    "status": "completed",
    "platform": null,
    "row_count": 1873,
    "filename": "shipments-20260729081000.csv",
    "error": null,
    "download_url": "https://app.shipos.co.il/api/v2/exports/512/download",
    "created_at": "2026-07-29T08:10:00.000000Z",
    "updated_at": "2026-07-29T08:10:42.000000Z"
  }
}

Download the CSV

GET /exports/{export}/download streams the file. Before the export completes it returns 404 with code: "not_found" and message Export file is not ready. — poll status first.

bash
curl --location 'https://app.shipos.co.il/api/v2/exports/512/download?license_key={license_key}' \
--header 'X-Client-Id: {client_id}' \
--header 'X-Client-Secret: {client_secret}' \
--output shipments.csv
js
// Node.js 18+ — top-level await in an ESM module
import { writeFile } from 'node:fs/promises'

const exportId = 512
const res = await fetch(
  `https://app.shipos.co.il/api/v2/exports/${exportId}/download?license_key=${licenseKey}`,
  {
    headers: {
      'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
      'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
    },
  },
)

if (!res.ok) {
  const { error } = await res.json()
  throw new Error(`${error.code}: ${error.message}`) // not_found until it is ready
}

// Write the raw bytes — the UTF-8 BOM is part of the file.
await writeFile('shipments.csv', Buffer.from(await res.arrayBuffer()))
php
<?php
// composer require guzzlehttp/guzzle

$client = new \GuzzleHttp\Client(['base_uri' => 'https://app.shipos.co.il/api/v2/']);

$exportId = 512;

// 'sink' streams straight to disk — the raw bytes keep the UTF-8 BOM intact.
$client->get("exports/{$exportId}/download", [
    'headers' => [
        'X-Client-Id' => getenv('SHIPOS_CLIENT_ID'),
        'X-Client-Secret' => getenv('SHIPOS_CLIENT_SECRET'),
    ],
    'query' => ['license_key' => $licenseKey],
    'sink' => 'shipments.csv',
]);
php
<?php

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;

$exportId = 512;

$response = Http::withHeaders([
    'X-Client-Id' => config('services.shipos.client_id'),
    'X-Client-Secret' => config('services.shipos.client_secret'),
])
    ->get("https://app.shipos.co.il/api/v2/exports/{$exportId}/download", [
        'license_key' => $licenseKey,
    ])
    ->throw();

// Store the raw body — the UTF-8 BOM is part of the file.
Storage::put('shipments.csv', $response->body());
python
# pip install httpx
import os

import httpx

export_id = 512

with httpx.stream(
    "GET",
    f"https://app.shipos.co.il/api/v2/exports/{export_id}/download",
    headers={
        "X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
        "X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
    },
    params={"license_key": license_key},
) as response:
    response.raise_for_status()

    # Binary mode — the UTF-8 BOM is part of the file.
    with open("shipments.csv", "wb") as file:
        for chunk in response.iter_bytes():
            file.write(chunk)
go
package main

import (
	"io"
	"net/http"
	"os"
)

func main() {
	url := "https://app.shipos.co.il/api/v2/exports/512/download" +
		"?license_key=" + os.Getenv("SHIPOS_LICENSE_KEY")

	req, _ := http.NewRequest("GET", url, nil)
	req.Header.Set("X-Client-Id", os.Getenv("SHIPOS_CLIENT_ID"))
	req.Header.Set("X-Client-Secret", os.Getenv("SHIPOS_CLIENT_SECRET"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	file, err := os.Create("shipments.csv")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	// Copy the raw bytes — the UTF-8 BOM is part of the file.
	if _, err := io.Copy(file, res.Body); err != nil {
		panic(err)
	}
}
java
// Java 17+ — java.net.http, no dependencies
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;

public class DownloadExport {
    public static void main(String[] args) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://app.shipos.co.il/api/v2/exports/512/download"
                + "?license_key=" + System.getenv("SHIPOS_LICENSE_KEY")))
            .header("X-Client-Id", System.getenv("SHIPOS_CLIENT_ID"))
            .header("X-Client-Secret", System.getenv("SHIPOS_CLIENT_SECRET"))
            .build();

        // ofFile writes the raw bytes — the UTF-8 BOM is part of the file.
        HttpResponse<Path> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofFile(Path.of("shipments.csv")));

        System.out.println(response.statusCode()); // 404 until the export is ready
    }
}
csharp
// .NET 8+
using var http = new HttpClient
{
    BaseAddress = new Uri("https://app.shipos.co.il/api/v2/"),
};
http.DefaultRequestHeaders.Add("X-Client-Id",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_ID"));
http.DefaultRequestHeaders.Add("X-Client-Secret",
    Environment.GetEnvironmentVariable("SHIPOS_CLIENT_SECRET"));

var licenseKey = Environment.GetEnvironmentVariable("SHIPOS_LICENSE_KEY");

var response = await http.GetAsync($"exports/512/download?license_key={licenseKey}");
response.EnsureSuccessStatusCode(); // 404 until the export is ready

// Raw byte stream — the UTF-8 BOM is part of the file.
await using var file = File.Create("shipments.csv");
await response.Content.CopyToAsync(file);
ruby
require "net/http"

uri = URI("https://app.shipos.co.il/api/v2/exports/512/download")
uri.query = URI.encode_www_form(license_key: ENV.fetch("SHIPOS_LICENSE_KEY"))

request = Net::HTTP::Get.new(uri)
request["X-Client-Id"] = ENV.fetch("SHIPOS_CLIENT_ID")
request["X-Client-Secret"] = ENV.fetch("SHIPOS_CLIENT_SECRET")

Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request) do |response|
    raise "ShipOS error: #{response.body}" unless response.is_a?(Net::HTTPSuccess)

    # Binary mode — the UTF-8 BOM is part of the file.
    File.open("shipments.csv", "wb") do |file|
      response.read_body { |chunk| file.write(chunk) }
    end
  end
end
rust
// [dependencies]
// reqwest = { version = "0.12" }
// tokio = { version = "1", features = ["full"] }

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let bytes = reqwest::Client::new()
        .get("https://app.shipos.co.il/api/v2/exports/512/download")
        .query(&[("license_key", std::env::var("SHIPOS_LICENSE_KEY")?)])
        .header("X-Client-Id", std::env::var("SHIPOS_CLIENT_ID")?)
        .header("X-Client-Secret", std::env::var("SHIPOS_CLIENT_SECRET")?)
        .send()
        .await?
        .error_for_status()? // 404 until the export is ready
        .bytes()
        .await?;

    // Write the raw bytes — the UTF-8 BOM is part of the file.
    tokio::fs::write("shipments.csv", &bytes).await?;
    Ok(())
}

The CSV columns are: shipping_code, short_tracking_code, type, status, is_active, first_name, last_name, phone, city, address_1, created_at. The file starts with a UTF-8 BOM so Hebrew renders correctly in Excel.

Quick error reference

StatuscodeWhereWhen
403forbiddenallNo usable license (inactive/expired/not owned).
404not_foundGET /batches/{uuid}, GET /exports/{export}, downloadThe resource does not belong to your license, or the export file is not ready yet.
409duplicate_requestPOST /exportsAn export for this license is already being prepared — poll it instead.
422validation_failedPOST /batchesshipments missing/empty or over 2000 items; or multiple active licenses and no license_key.