Skip to content

Lists, filters & pagination

Every paginated v2 list uses cursor pagination: instead of page numbers you follow an opaque cursor token from page to page. This page documents the paging and filter parameters of every list endpoint, then walks a complete pagination loop.

How cursor pagination works

Request parameters (query string):

ParamDescription
per_pageItems per page. Each endpoint has its own default; all are capped at 100.
cursorOpaque token pointing at the next (or previous) page. Omit it for the first page; take it from the previous response's meta.next_cursor (or just follow links.next, which already carries it plus your other query params).

Paginated responses share this envelope:

json
{
  "data": [ { "...": "resources" } ],
  "links": {
    "first": null,
    "last": null,
    "prev": null,
    "next": "https://app.shipos.co.il/api/v2/orders?cursor=eyJpZCI6OTg3NiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ&per_page=25"
  },
  "meta": {
    "path": "https://app.shipos.co.il/api/v2/orders",
    "per_page": 25,
    "next_cursor": "eyJpZCI6OTg3NiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ",
    "prev_cursor": null
  }
}
  • meta.next_cursor — token for the next page; null on the last page. That null is your loop's stop condition.
  • meta.prev_cursor — token for the previous page; null on the first page.
  • links.next / links.prev — ready-made URLs carrying the cursor and your query params; links.first/links.last are always null (cursors have no "jump to last page").
  • There is no total count — cursor pagination doesn't count the full result set (that is what makes it fast on large tables). If you need a full extract, loop to the end or use an export.

Treat the cursor as an opaque string: pass it back exactly as received, URL-encoded (the links.next URL already is).

Endpoint-by-endpoint

GET /shipmentsreference

Cursor-paginated. per_page default 25, max 100.

ParamTypeDescription
filter[active]booleantrue → only active shipments, false → only inactive (cancelled) ones. Omit for both.
sortstringAllowlisted sort key: created_at or id. Prefix with - for descending (sort=-created_at). Unknown keys fall back to the default. Default: newest first (id descending). A stable id tiebreaker is always applied.
per_page, cursorAs above.
bash
curl --location 'https://app.shipos.co.il/api/v2/shipments?filter[active]=true&sort=-created_at&per_page=50' \
--header 'X-Client-Id: {client_id}' \
--header 'X-Client-Secret: {client_secret}' \
--header 'Accept: application/json'
js
// Node 18+ / browsers — no dependencies
const params = new URLSearchParams({
  'filter[active]': 'true',
  sort: '-created_at',
  per_page: '50',
})

const response = await fetch(`https://app.shipos.co.il/api/v2/shipments?${params}`, {
  headers: {
    'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
    'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
    Accept: 'application/json',
  },
})

if (!response.ok) {
  const { error } = await response.json()
  throw new Error(`${error.code}: ${error.message}`)
}

const page = await response.json()

console.log(page.data.length, 'shipments; next cursor:', page.meta.next_cursor)
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',
    ],
]);

$page = json_decode($client->get('shipments', [
    'query' => [
        'filter[active]' => 'true',
        'sort' => '-created_at',
        'per_page' => 50,
    ],
])->getBody()->getContents(), true);

printf("%d shipments\n", count($page['data']));
echo 'next cursor: ', $page['meta']['next_cursor'] ?? 'null', PHP_EOL;
php
<?php

use Illuminate\Support\Facades\Http;

$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/shipments', [
        'filter[active]' => 'true',
        'sort' => '-created_at',
        'per_page' => 50,
    ])
    ->throw()
    ->json();

logger()->info(count($page['data']).' shipments', [
    'next_cursor' => $page['meta']['next_cursor'],
]);
python
# pip install httpx
import os

import httpx

response = httpx.get(
    "https://app.shipos.co.il/api/v2/shipments",
    params={"filter[active]": "true", "sort": "-created_at", "per_page": 50},
    headers={
        "X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
        "X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
        "Accept": "application/json",
    },
)
response.raise_for_status()
page = response.json()

print(len(page["data"]), "shipments; next cursor:", page["meta"]["next_cursor"])
go
package main

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

type shipmentPage struct {
	Data []json.RawMessage `json:"data"`
	Meta struct {
		NextCursor *string `json:"next_cursor"`
	} `json:"meta"`
}

func main() {
	query := url.Values{
		"filter[active]": {"true"},
		"sort":           {"-created_at"},
		"per_page":       {"50"},
	}

	req, _ := http.NewRequest("GET",
		"https://app.shipos.co.il/api/v2/shipments?"+query.Encode(), 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 shipmentPage
	if err := json.NewDecoder(res.Body).Decode(&page); err != nil {
		panic(err)
	}

	fmt.Println(len(page.Data), "shipments")
}
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 ShipOsShipments {
    public static void main(String[] args) throws Exception {
        String url = "https://app.shipos.co.il/api/v2/shipments"
            + "?filter%5Bactive%5D=true&sort=-created_at&per_page=50";

        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
            .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());

        if (response.statusCode() != 200) {
            throw new RuntimeException("ShipOS error: " + response.body());
        }

        // {"data":[...],"links":{...},"meta":{...}} — map with Jackson/Gson
        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 page = await http.GetFromJsonAsync<JsonDocument>(
        "shipments?filter%5Bactive%5D=true&sort=-created_at&per_page=50")
    ?? throw new InvalidOperationException("Empty response");

Console.WriteLine($"{page.RootElement.GetProperty("data").GetArrayLength()} shipments");
Console.WriteLine(
    page.RootElement.GetProperty("meta").GetProperty("next_cursor").GetString() ?? "null");
ruby
require "net/http"
require "json"

uri = URI("https://app.shipos.co.il/api/v2/shipments")
uri.query = URI.encode_www_form(
  "filter[active]" => "true", "sort" => "-created_at", "per_page" => 50
)

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) do |http|
  http.request(request)
end

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

page = JSON.parse(response.body)
puts "#{page["data"].size} shipments; next cursor: #{page.dig("meta", "next_cursor")}"
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 page: Value = reqwest::Client::new()
        .get("https://app.shipos.co.il/api/v2/shipments")
        .query(&[
            ("filter[active]", "true"),
            ("sort", "-created_at"),
            ("per_page", "50"),
        ])
        .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?;

    println!("{} shipments", page["data"].as_array().map_or(0, Vec::len));
    println!("next cursor: {}", page["meta"]["next_cursor"]);
    Ok(())
}

GET /ordersreference

Cursor-paginated, newest first. per_page default 25, max 100. No filters.

ParamTypeDescription
per_page, cursorAs above.

GET /orders/{order}/shipments (the shipments of one order) is not paginated — it returns all of that order's shipments in one response.

Returns — GET /shipments?filter[type]=2

There is no separate returns endpoint. A return is a shipment with the collection service type, so list them with the filter[type] parameter documented under GET /shipments above.

GET /batchesreference

Cursor-paginated, newest first, each batch with its items eager-loaded. per_page default 25, max 100. No filters.

GET /batches/{uuid}/itemsreference

Cursor-paginated in submission order (position, then id). per_page default 50, max 100. No filters.

GET /notifications/sms-logsreference

Cursor-paginated, newest first. per_page default 25, max 100.

ParamTypeDescription
filter[shipping_id]integerOnly logs for a single shipping.
filter[success]booleantrue → only successful sends, false → only failed ones. Omit for both.
per_page, cursorAs above.
bash
curl --location 'https://app.shipos.co.il/api/v2/notifications/sms-logs?filter[success]=false&per_page=50' \
--header 'X-Client-Id: {client_id}' \
--header 'X-Client-Secret: {client_secret}' \
--header 'Accept: application/json'
js
// Node 18+ / browsers — no dependencies
const params = new URLSearchParams({
  'filter[success]': 'false',
  per_page: '50',
})

const response = await fetch(
  `https://app.shipos.co.il/api/v2/notifications/sms-logs?${params}`,
  {
    headers: {
      'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
      'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
      Accept: 'application/json',
    },
  },
)

if (!response.ok) {
  const { error } = await response.json()
  throw new Error(`${error.code}: ${error.message}`)
}

const page = await response.json()

console.log(page.data.length, 'failed sends; next cursor:', page.meta.next_cursor)
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',
    ],
]);

$page = json_decode($client->get('notifications/sms-logs', [
    'query' => [
        'filter[success]' => 'false',
        'per_page' => 50,
    ],
])->getBody()->getContents(), true);

printf("%d failed sends\n", count($page['data']));
echo 'next cursor: ', $page['meta']['next_cursor'] ?? 'null', PHP_EOL;
php
<?php

use Illuminate\Support\Facades\Http;

$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/notifications/sms-logs', [
        'filter[success]' => 'false',
        'per_page' => 50,
    ])
    ->throw()
    ->json();

logger()->info(count($page['data']).' failed sends', [
    'next_cursor' => $page['meta']['next_cursor'],
]);
python
# pip install httpx
import os

import httpx

response = httpx.get(
    "https://app.shipos.co.il/api/v2/notifications/sms-logs",
    params={"filter[success]": "false", "per_page": 50},
    headers={
        "X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
        "X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
        "Accept": "application/json",
    },
)
response.raise_for_status()
page = response.json()

print(len(page["data"]), "failed sends; next cursor:", page["meta"]["next_cursor"])
go
package main

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

type smsLogPage struct {
	Data []json.RawMessage `json:"data"`
	Meta struct {
		NextCursor *string `json:"next_cursor"`
	} `json:"meta"`
}

func main() {
	query := url.Values{
		"filter[success]": {"false"},
		"per_page":        {"50"},
	}

	req, _ := http.NewRequest("GET",
		"https://app.shipos.co.il/api/v2/notifications/sms-logs?"+query.Encode(), 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 smsLogPage
	if err := json.NewDecoder(res.Body).Decode(&page); err != nil {
		panic(err)
	}

	fmt.Println(len(page.Data), "failed sends")
}
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 ShipOsSmsLogs {
    public static void main(String[] args) throws Exception {
        String url = "https://app.shipos.co.il/api/v2/notifications/sms-logs"
            + "?filter%5Bsuccess%5D=false&per_page=50";

        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
            .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());

        if (response.statusCode() != 200) {
            throw new RuntimeException("ShipOS error: " + response.body());
        }

        // {"data":[...],"links":{...},"meta":{...}} — map with Jackson/Gson
        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 page = await http.GetFromJsonAsync<JsonDocument>(
        "notifications/sms-logs?filter%5Bsuccess%5D=false&per_page=50")
    ?? throw new InvalidOperationException("Empty response");

Console.WriteLine($"{page.RootElement.GetProperty("data").GetArrayLength()} failed sends");
Console.WriteLine(
    page.RootElement.GetProperty("meta").GetProperty("next_cursor").GetString() ?? "null");
ruby
require "net/http"
require "json"

uri = URI("https://app.shipos.co.il/api/v2/notifications/sms-logs")
uri.query = URI.encode_www_form("filter[success]" => "false", "per_page" => 50)

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) do |http|
  http.request(request)
end

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

page = JSON.parse(response.body)
puts "#{page["data"].size} failed sends; next cursor: #{page.dig("meta", "next_cursor")}"
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 page: Value = reqwest::Client::new()
        .get("https://app.shipos.co.il/api/v2/notifications/sms-logs")
        .query(&[("filter[success]", "false"), ("per_page", "50")])
        .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?;

    println!("{} failed sends", page["data"].as_array().map_or(0, Vec::len));
    println!("next cursor: {}", page["meta"]["next_cursor"]);
    Ok(())
}

GET /webhooks/{uuid}/deliveriesreference

Cursor-paginated, newest first. per_page default 25, max 100. No filters.

Endpoints with NO pagination

These return the complete result set in a single data array — there is no cursor, per_page, links, or meta:

EndpointNotes
GET /pickup-pointsreferenceReturns the carrier's full point list. city (substring filter) and limit (cap, max 500) trim the result, and lat/lng/address rank it nearest-first, but there are no pages — limit is a cap, not a page size.
GET /licensesreferenceAll of the account's licenses.
GET /webhooksreferenceAll of the license's webhook subscriptions.
GET /orders/{order}/shipmentsreferenceAll shipments of one order.
GET /notifications/sms-templates, GET /reference/*Full lists.

Boolean filter values

Boolean filters (filter[active], filter[success]) accept the usual truthy/falsy forms: 1/0, true/false.

Worked example — walking all orders

Fetch every order, page by page, stopping when meta.next_cursor is null.

bash
#!/usr/bin/env bash
# Requires jq. Walks /orders page by page.
BASE='https://app.shipos.co.il/api/v2'
CURSOR=''

while :; do
  URL="$BASE/orders?per_page=100"
  [ -n "$CURSOR" ] && URL="$URL&cursor=$CURSOR"

  PAGE=$(curl --silent --location "$URL" \
    --header "X-Client-Id: $CLIENT_ID" \
    --header "X-Client-Secret: $CLIENT_SECRET" \
    --header 'Accept: application/json')

  echo "$PAGE" | jq -r '.data[] | "\(.order_id)\t\(.number)\t\(.total) \(.currency)"'

  CURSOR=$(echo "$PAGE" | jq -r '.meta.next_cursor // empty')
  [ -z "$CURSOR" ] && break
done
js
// Node 18+ / browsers — no dependencies
const headers = {
  'X-Client-Id': process.env.SHIPOS_CLIENT_ID,
  'X-Client-Secret': process.env.SHIPOS_CLIENT_SECRET,
  'Accept': 'application/json',
}

const orders = []
let cursor = null

do {
  const params = new URLSearchParams({ per_page: '100' })
  if (cursor) params.set('cursor', cursor)

  const res = await fetch(`https://app.shipos.co.il/api/v2/orders?${params}`, { headers })
  const page = await res.json()

  orders.push(...page.data)
  cursor = page.meta.next_cursor // null on the last page
} while (cursor)

console.log(`Fetched ${orders.length} orders`)
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',
];

$cursor = null;
$orders = [];

do {
    $response = $client->get('orders', [
        'headers' => $headers,
        'query' => array_filter([
            'per_page' => 100,
            'cursor' => $cursor,
        ]),
    ]);

    $page = json_decode((string) $response->getBody(), true);

    foreach ($page['data'] as $order) {
        $orders[] = $order;
    }

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

printf("Fetched %d orders\n", count($orders));
php
<?php

use Illuminate\Support\Facades\Http;

$client = Http::withHeaders([
    'X-Client-Id' => config('services.shipos.client_id'),
    'X-Client-Secret' => config('services.shipos.client_secret'),
])->acceptJson()->baseUrl('https://app.shipos.co.il/api/v2');

$cursor = null;
$orders = [];

do {
    $page = $client->get('orders', array_filter([
        'per_page' => 100,
        'cursor' => $cursor,
    ]))->throw()->json();

    $orders = array_merge($orders, $page['data']);

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

logger()->info('Fetched '.count($orders).' orders');
python
# pip install httpx
import os

import httpx

headers = {
    "X-Client-Id": os.environ["SHIPOS_CLIENT_ID"],
    "X-Client-Secret": os.environ["SHIPOS_CLIENT_SECRET"],
    "Accept": "application/json",
}

orders = []
cursor = None

with httpx.Client(base_url="https://app.shipos.co.il/api/v2", headers=headers) as client:
    while True:
        params = {"per_page": 100}
        if cursor:
            params["cursor"] = cursor

        page = client.get("/orders", params=params).raise_for_status().json()
        orders.extend(page["data"])

        cursor = page["meta"]["next_cursor"]  # None on the last page
        if not cursor:
            break

print(f"Fetched {len(orders)} orders")
go
package main

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

type ordersPage struct {
	Data []json.RawMessage `json:"data"`
	Meta struct {
		NextCursor *string `json:"next_cursor"`
	} `json:"meta"`
}

func main() {
	cursor, total := "", 0

	for {
		query := url.Values{"per_page": {"100"}}
		if cursor != "" {
			query.Set("cursor", cursor)
		}

		req, _ := http.NewRequest("GET",
			"https://app.shipos.co.il/api/v2/orders?"+query.Encode(), 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 page ordersPage
		err = json.NewDecoder(res.Body).Decode(&page)
		res.Body.Close()
		if err != nil {
			panic(err)
		}

		total += len(page.Data)
		if page.Meta.NextCursor == nil { // null on the last page
			break
		}
		cursor = *page.Meta.NextCursor
	}

	fmt.Printf("Fetched %d orders\n", total)
}
java
// Java 17+ — java.net.http (cursor read with Jackson)
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

public class ShipOsWalkOrders {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        ObjectMapper mapper = new ObjectMapper();
        String cursor = null;
        int total = 0;

        do {
            String url = "https://app.shipos.co.il/api/v2/orders?per_page=100"
                + (cursor == null ? ""
                    : "&cursor=" + URLEncoder.encode(cursor, StandardCharsets.UTF_8));

            HttpRequest request = HttpRequest.newBuilder(URI.create(url))
                .header("X-Client-Id", System.getenv("SHIPOS_CLIENT_ID"))
                .header("X-Client-Secret", System.getenv("SHIPOS_CLIENT_SECRET"))
                .header("Accept", "application/json")
                .build();

            JsonNode page = mapper.readTree(
                client.send(request, HttpResponse.BodyHandlers.ofString()).body());

            total += page.get("data").size();

            JsonNode next = page.at("/meta/next_cursor"); // null on the last page
            cursor = next.isNull() || next.isMissingNode() ? null : next.asText();
        } while (cursor != null);

        System.out.println("Fetched " + total + " orders");
    }
}
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"));

string? cursor = null;
var total = 0;

do
{
    var url = "orders?per_page=100"
        + (cursor is null ? "" : $"&cursor={Uri.EscapeDataString(cursor)}");

    var page = await http.GetFromJsonAsync<JsonDocument>(url)
        ?? throw new InvalidOperationException("Empty response");

    total += page.RootElement.GetProperty("data").GetArrayLength();

    var next = page.RootElement.GetProperty("meta").GetProperty("next_cursor");
    cursor = next.ValueKind == JsonValueKind.Null ? null : next.GetString();
} while (cursor is not null);

Console.WriteLine($"Fetched {total} orders");
ruby
require "net/http"
require "json"

cursor = nil
total = 0

loop do
  uri = URI("https://app.shipos.co.il/api/v2/orders")
  params = { "per_page" => 100 }
  params["cursor"] = cursor if cursor
  uri.query = URI.encode_www_form(params)

  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) { |h| h.request(request) }
  raise "ShipOS error: #{response.body}" unless response.is_a?(Net::HTTPSuccess)

  page = JSON.parse(response.body)
  total += page["data"].size

  cursor = page.dig("meta", "next_cursor") # nil on the last page
  break unless cursor
end

puts "Fetched #{total} orders"
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 mut cursor: Option<String> = None;
    let mut total = 0usize;

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

        let page: Value = client
            .get("https://app.shipos.co.il/api/v2/orders")
            .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?;

        total += page["data"].as_array().map_or(0, Vec::len);

        cursor = page["meta"]["next_cursor"].as_str().map(str::to_owned);
        if cursor.is_none() {
            break;
        }
    }

    println!("Fetched {total} orders");
    Ok(())
}

The same loop works for /shipments, /orders, /batches, batch items, SMS logs, and webhook deliveries — only the endpoint, filters, and per_page default change.

Cursors don't survive query changes

A cursor encodes the position within one specific ordering. Keep per_page, sort, and filters identical across the loop — changing them mid-walk invalidates the position. Start a fresh walk (no cursor) whenever the query changes.