ShipZest Gateway API

The Gateway API lets you validate addresses, create USPS shipping labels, and manage orders programmatically. It uses a two-phase ordering model: first validate your batch of orders, then confirm to create labels. This ensures you catch address errors and know the exact cost before committing funds.

Base URL https://app.shipzest.com/gateway/v1

All requests must include your API key in the Authorization header. Request bodies are JSON, and every documented endpoint/method pair answers with the JSON envelope — see Errors for the five responses that are not JSON (a 405 with an empty body, a generic HTML 404 page on an unknown path, a bodiless 301 when a required trailing slash is missing, and either a bodiless 200 or a 204 CORS preflight for OPTIONS).

Every snippet on this page continues from the Quick Start block below. The non-cURL examples in the endpoint sections reference BASE_URL / baseUrl, API_KEY / apiKey, headers and the HTTP client defined there — paste those two constants (and, for C#, the using lines and the client) above the snippet you copied. Prefer that to pasting the whole Quick Start panel: most JavaScript panels declare their own const response, so a Quick Start panel followed by an endpoint panel is usually a SyntaxError: Identifier 'response' has already been declared. Most C# panels collide the same way — top-level statements share one implicit scope and they declare var response and var json, so concatenating two gives CS0128: a local variable named 'response' is already defined. The two polling panels, Get Order Status and Get Batch Status, are the exceptions, and nesting is what makes them exceptions: every polling panel declares its response inside the while body, which is a nested scope. The JavaScript and Ruby ones also rename it to res; the C# ones keep response (and json), so renaming would not save them — the nesting alone is why those two report CS0136 in C# (shadowing an enclosing local) rather than CS0128, and nothing at all in JavaScript. Pasting only the constants avoids all of it, and is the right habit for every panel. Two panels are handled differently, both in Authentication, because both are fully self-contained: the C# one re-declares client and repeats the using lines, and a using directive may not follow top-level statements, so pasting it below the Quick Start panel is CS1529; the PHP one opens with its own <?php tag, and a second opening tag may not follow PHP code, so pasting it below the Quick Start PHP panel is PHP Parse error: syntax error, unexpected token "<". Run those two on their own. A few panels need one more require/import of their own; where they do, it is the first line of the panel. The Node.js panels use top-level await, which parses only in an ES module: save them as .mjs (or set "type": "module" in your package.json) and run them on Node 18+. The cURL tabs are always self-contained.

Not every panel guards its response. The polling loops in Get Order Status and Get Batch Status always check the HTTP status before they touch the body, because a loop is where an unhandled 429 does the most damage. Several single-shot panels unwrap data straight away instead, to keep the happy path readable. data is null on every error response, so an unguarded snippet crashes on a 429 or a 404 just as readily as a loop would. Before shipping any panel, wrap the call the way Rate Limits' api_request() helper does, or copy the guard out of a polling panel.

Quick Start

# Verify your API key works
curl https://app.shipzest.com/gateway/v1/account/ \
  -H "Authorization: Bearer sk_live_your_api_key_here"
import requests

BASE_URL = "https://app.shipzest.com/gateway/v1"
API_KEY = "sk_live_your_api_key_here"
headers = {"Authorization": f"Bearer {API_KEY}"}

response = requests.get(
    f"{BASE_URL}/account/",
    headers=headers,
)
print(response.json())
const BASE_URL = "https://app.shipzest.com/gateway/v1";
const API_KEY = "sk_live_your_api_key_here";

const response = await fetch(`${BASE_URL}/account/`, {
  headers: { "Authorization": `Bearer ${API_KEY}` },
});

const data = await response.json();
console.log(data);
<?php
$baseUrl = "https://app.shipzest.com/gateway/v1";
$apiKey = "sk_live_your_api_key_here";

$ch = curl_init("$baseUrl/account/");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer $apiKey",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
echo $response;
require "net/http"
require "json"

BASE_URL = "https://app.shipzest.com/gateway/v1"
API_KEY = "sk_live_your_api_key_here"

uri = URI("#{BASE_URL}/account/")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"

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

puts JSON.parse(response.body)
using System;                 // Console, DateTime, Math — implicit on net6+
using System.Net.Http;
using System.Text;             // Encoding (request bodies)
using System.Text.Json;        // JsonSerializer / JsonDocument
using System.Threading.Tasks;  // Task.Delay in the polling loops

var baseUrl = "https://app.shipzest.com/gateway/v1";
var apiKey = "sk_live_your_api_key_here";

var client = new HttpClient();
client.DefaultRequestHeaders.Add(
    "Authorization", $"Bearer {apiKey}"
);

var response = await client.GetAsync($"{baseUrl}/account/");
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(json);

Authentication

The Gateway API uses Bearer token authentication. Include your API key in the Authorization header of every request.

Generate your API key from the Help & Support → API page in your dashboard (available once the External API feature is enabled for your account). Each account has a single API key, scoped to your account and to the brand that account belongs to — it reaches ShipZest orders and balance, not those your account holds under any other brand. Generating a new key immediately replaces (rotates) the previous one — the old key stops working at once.

Header Format

API keys are prefixed with sk_live_ followed by 48 hexadecimal characters (56 characters total). Send the key as a Bearer token:

Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Security Best Practices

  • Never expose your API key in client-side code, public repos, or logs
  • Use environment variables to store your key
  • Rotate your key from the dashboard if you suspect it has leaked — regenerating immediately invalidates the old secret
  • The full key is shown only once at generation — store it securely, it cannot be retrieved later
If your key is compromised, generate a new one from the dashboard — this immediately revokes the old key. All requests with the old key will then return 401 Unauthorized.

The Three 401 Variants

A 401 always means the request never reached your account, but the error.code tells you exactly what to fix:

CodeCause
AUTH_MISSING No Authorization header, or the value does not start with Bearer . The prefix match is byte-exact and case-sensitive, unlike the scheme comparison in RFC 7235: a perfectly valid key sent as bearer sk_live_… is classified here, as no credentials at allAUTH_MISSING / “Authentication required”, not AUTH_TOKEN_INVALID. Send the scheme exactly as Bearer , with one space
AUTH_TOKEN_INVALID A Bearer token was sent but it does not start with sk_live_. Only the prefix is checked here — length and hex-ness are not
INVALID_API_KEY The token starts with sk_live_ but the key is unknown, revoked, or deactivated. A truncated or non-hex sk_live_ token lands here too, not on AUTH_TOKEN_INVALID

See the error code reference for the full authentication error list.

curl https://app.shipzest.com/gateway/v1/account/ \
  -H "Authorization: Bearer sk_live_your_api_key_here" \
  -H "Content-Type: application/json"
import requests

headers = {
    "Authorization": "Bearer sk_live_your_api_key_here",
    "Content-Type": "application/json",
}

response = requests.get(
    "https://app.shipzest.com/gateway/v1/account/",
    headers=headers,
)
print(response.json())
const response = await fetch(
  "https://app.shipzest.com/gateway/v1/account/",
  {
    headers: {
      "Authorization": "Bearer sk_live_your_api_key_here",
      "Content-Type": "application/json",
    },
  }
);

const data = await response.json();
console.log(data);
<?php
$ch = curl_init("https://app.shipzest.com/gateway/v1/account/");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer sk_live_your_api_key_here",
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = json_decode(curl_exec($ch), true);
print_r($response);
require "net/http"
require "json"

uri = URI("https://app.shipzest.com/gateway/v1/account/")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer sk_live_your_api_key_here"
request["Content-Type"] = "application/json"

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

puts JSON.parse(response.body)
using System;                 // Console — implicit on net6+, explicit here
using System.Net.Http;

var client = new HttpClient();
client.DefaultRequestHeaders.Add(
    "Authorization", "Bearer sk_live_your_api_key_here"
);

var response = await client.GetAsync(
    "https://app.shipzest.com/gateway/v1/account/"
);

var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(json);
200 — Authenticated response
{
  "success": true,
  "data": {
    "balance": 142.50,
    "daily_orders_used": 12,
    "max_bulk_labels": 100
  },
  "error": null,
  "message": "OK"
}
401 — Missing Authorization header (AUTH_MISSING)
// No header (or no "Bearer " prefix). A malformed token returns
// AUTH_TOKEN_INVALID instead; an unknown/revoked key returns
// INVALID_API_KEY — same envelope, different code and message.
{
  "success": false,
  "data": null,
  "error": {
    "code": "AUTH_MISSING",
    "details": {}
  },
  "message": "Authentication required"
}

Errors

The Gateway API uses standard HTTP status codes and returns a consistent JSON error format. Every response from a documented endpoint/method pair follows the same envelope structure, making error handling predictable. The five exceptions are called out below.

Response Envelope

All responses include success, data, error, and message fields. On success, error is null. On failure, data is null and error contains a machine-readable code and optional details.

Five responses are generated before the API layer and carry no envelope. Calling a real endpoint with the wrong HTTP method returns 405 with an empty body; an unknown path under /gateway/v1/ returns a generic HTML 404 page; a GET that omits a required trailing slash returns a bodiless 301 to the slashed path (see Paths are matched exactly below); and an OPTIONS request answers in one of two non-JSON shapes. Carrying an Origin header — which every browser CORS preflight does — it is short-circuited to 204 No Content with no Allow header and no authentication required. The Access-Control-* headers come back only when the request came from an allowed origin; a genuinely cross-origin preflight gets the bare 204 without them, which the browser reads as “denied”. This API is meant to be called server-to-server — your key must never reach a browser — so treat a working preflight as a local convenience, not as part of the contract. Without an Origin, and authenticated, it reaches a bare 200 with Content-Type: text/html, a zero-length body and an Allow header whose value is per endpoint — POST, OPTIONS on /orders/validate, GET, HEAD, OPTIONS on the read endpoints. (An OPTIONS with neither an Origin nor a key is the one case that is enveloped: 401 AUTH_MISSING.) None of the five is JSON. Only the documented endpoint/method pairs are envelope-guaranteed, so check the status code (and that the body is non-empty) before calling json.loads() on an error response.

HTTP Status Codes

CodeMeaning
200Success — every Gateway endpoint (including validate and confirm) returns 200 on success
400Validation error — check error.details
401Missing or invalid API key
403Account suspended or insufficient permissions
404Resource not found. A known endpoint with an unknown id returns the JSON envelope (RESOURCE_NOT_FOUND); an unknown path returns an HTML page instead
405Wrong HTTP method for this endpoint — returned with an empty body, no JSON envelope
409Conflict — batch already confirmed
413Request body exceeds the maximum accepted size, 10 MB by default (FILE_TOO_LARGE) — usually oversized field content rather than row count, since the 100-row cap returns BATCH_TOO_LARGE (400) first. The size check runs before authentication, so an oversized body is rejected with 413 even with no API key, and the response carries no X-RateLimit-* headers
422Insufficient balance or unprocessable
429Rate limit exceeded — check Retry-After header
500Internal server error
503Temporary service outage — retry with backoff. On Gateway paths this is always SERVICE_UNAVAILABLE. A file-storage outage never reaches you as a 503 envelope: the order still returns 200 with its download URLs omitted, and an already-issued link answers a bodiless 404/503 — see Retrieve Past Order

Paths are matched exactly

/orders/validate, /orders/confirm/{batch_id} and /orders/{order_id}/details take no trailing slash; every other path (/account/, /wallet/, /orders/, /orders/{order_id}/, /batches/{batch_id}/) requires one. Every path printed on this page already uses the correct form, so this only bites clients that normalise URLs. A stray slash on POST /orders/validate/ returns the empty-bodied 405 above even though the method was right (the path falls through to /orders/{order_id}/, which is GET-only); on confirm or details it returns the generic HTML 404 page. A missing slash on a GET returns a 301 to the slashed path. None of the three is a JSON envelope, and none means a wrong method or a purged batch.

Response headers

Every response — success, error, and the non-envelope responses above — carries X-Request-ID. The server generates a UUID, or echoes your own value back verbatim if you send the header, so you can use it as a two-way correlation id. Quote it when contacting support. Unlike the X-RateLimit-* headers described under Rate Limits, it is present even on 401 and 413 responses, which are produced before the rate limiter runs.

Error response format
// 400 — batch exceeds your account's max_bulk_labels.
// Note: a top-level VALIDATION_ERROR (malformed JSON body, empty orders
// array) carries EMPTY details ({}). Field-level errors are reported
// per row inside the HTTP 200 validate response (orders[].details.errors[]).
{
  "success": false,
  "data": null,
  "error": {
    "code": "BATCH_TOO_LARGE",
    "details": {
      "errors": [
        {
          "field": "orders",
          "code": "BATCH_TOO_LARGE",
          "message": "Batch size 150 exceeds maximum of 100"
        }
      ]
    }
  },
  "message": "Validation failed"
}
Insufficient balance (422)
{
  "success": false,
  "data": null,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "details": {
      "required_amount": 45.50,
      "current_balance": 12.00,
      "shortfall": 33.50
    }
  },
  "message": "Insufficient balance to confirm this batch"
}
Rate limited (429)
{
  "success": false,
  "data": null,
  "error": {
    "code": "RATE_LIMITED",
    "details": {
      "retry_after": 45
    }
  },
  "message": "Too many requests. Please try again later."
}

Rate Limits

The Gateway API enforces rate limits to ensure fair usage. Limits are tracked per account — the window is keyed on the account, not the key, so rotating your key does not reset it (an account has exactly one key at a time). When you exceed a limit, the API returns 429 Too Many Requests with a Retry-After header.

ScopeLimit
All endpoints100 requests / minute per account, counted independently by each API server process — during a burst the effective ceiling can be a small multiple of 100, so treat 100/minute as the contract, not as the exact throttling point

Rate Limit Headers

Every authenticated response includes rate limit headers so you can track your usage. Authentication-failure responses (401/403) and oversized bodies (413) are returned before rate limiting runs, so they carry no X-RateLimit-* headers. The bodiless 301 emitted for a missing trailing slash has none either, for a different reason: the limiter did run (the request was authenticated), but the redirect is a fresh response built outside the API layer, so the headers are discarded. Don't assume the headers are always present:

HeaderDescription
X-RateLimit-Limit Maximum requests allowed in the window
X-RateLimit-Remaining Requests remaining in the current window as seen by the server process that answered this request. Advisory — it is not guaranteed to decrease monotonically across consecutive responses
X-RateLimit-Reset Unix timestamp when the window resets
Retry-After Seconds to wait (only on 429 responses)
Rate limit headers example
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1712150400
Content-Type: application/json
Handling rate limits (Python)
import time
import requests

def api_request(url, headers, max_retries=3):
    for attempt in range(max_retries):
        response = requests.get(url, headers=headers)

        if response.status_code != 429:
            return response

        # Wait for the rate limit to reset
        retry_after = int(
            response.headers.get("Retry-After", 60)
        )
        time.sleep(retry_after)

    raise Exception("Rate limit exceeded")

Get Wallet

Retrieve the deposit wallet address for your account. Deposit USDC or USDT to this address to add credits to your balance. The API reports exactly one network label, Ethereum (ERC-20) — that is a fixed string, not a restriction. The same address accepts USDC and USDT on every network we support: Ethereum, Polygon and Base. The dashboard deposit flow recommends Polygon (fees around $0.01, versus $2–10 on Ethereum); deposits credit the same balance whichever of the three you use.

GET /gateway/v1/wallet/

Response Fields

FieldTypeDescription
address string Ethereum wallet address (0x...)
network string Fixed display label, always Ethereum (ERC-20). It does not narrow where you may deposit — Ethereum, Polygon and Base all credit this address.
Every account is issued a deposit wallet automatically at sign-up, so this endpoint normally returns an address on the very first call — there is no onboarding step to perform. In the rare case none exists, the API returns 404 with error.code RESOURCE_NOT_FOUND and the message “No wallet found. Generate a wallet from the dashboard first.” To generate one, open Billing → Deposit in the dashboard and continue to the Send Payment step — choose Crypto, then a coin and network. The wallet is created automatically at that step; simply opening the billing page is not enough. Then retry.
curl https://app.shipzest.com/gateway/v1/wallet/ \
  -H "Authorization: Bearer sk_live_your_api_key_here"
response = requests.get(
    f"{BASE_URL}/wallet/",
    headers=headers,
)
print(response.json())
const response = await fetch(`${BASE_URL}/wallet/`, {
  headers: { "Authorization": `Bearer ${API_KEY}` },
});

const data = await response.json();
console.log(data);
$ch = curl_init("$baseUrl/wallet/");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer $apiKey",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = json_decode(curl_exec($ch), true);
print_r($response);
uri = URI("#{BASE_URL}/wallet/")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"

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

puts JSON.parse(response.body)
var response = await client.GetAsync($"{baseUrl}/wallet/");
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(json);
200 — Response
{
  "success": true,
  "data": {
    "address": "0x1a2b3c4d5e6f7890abcdef1234567890abcdef12",
    "network": "Ethereum (ERC-20)"
  },
  "error": null,
  "message": "OK"
}

Account Info

Retrieve your current account balance, daily usage, and limits. Use this before placing orders to verify you have sufficient credits.

GET /gateway/v1/account/

Response Fields

FieldTypeDescription
balance number Spendable balance in USD credits: your credit balance minus the cost of orders already created but not yet charged. The dashboard sidebar and the dashboard overview show this same spendable figure; the Billing page's card — confusingly labelled “Available Balance” — shows the gross credit balance instead, so that one card sits above this number whenever you have orders in flight, with no charge having happened yet. It is shared across your whole account: unlike daily_orders_used below, which counts only the orders placed for ShipZest, every order you create draws on this one figure. It is the same figure reported as current_balance in an INSUFFICIENT_BALANCE error.

A rising balance is not proof of a refund. The hold on an order is dropped automatically once it has sat in processing for more than 30 minutes, so balance can rise again before the order's status changes and without any charge or refund having occurred. A timed-out order is normally failed automatically shortly afterwards, but that release is unconditional and the fail is not: if label generation is interrupted — the usual reason an order is stuck — nothing fails it, and the order can still complete and charge later. Reconcile spend from order status and transactions, never from balance deltas.
daily_orders_used integer Orders your account created for ShipZest today, counted from midnight UTC — including orders placed in the dashboard, not just API orders. Orders your account places for a different brand are counted separately and are not included here; the balance above, by contrast, is shared. One confirmed batch counts as one order however many labels it carries. This is the same counter DAILY_ORDER_LIMIT_EXCEEDED is enforced against, but not the same read: this endpoint counts your orders live on every call, while the limit check reads that count through a 60-second per-process cache — the same volatile, per-server store described under Rate Limits. The enforced number is therefore never higher than the one you see here and two API servers can disagree, so near the limit treat this field as advisory rather than as an exact gate: a validate can still be accepted after it reads as full. The limit itself is not exposed here and is returned in that error's details.
max_bulk_labels integer Maximum labels per batch (default: 100)
curl https://app.shipzest.com/gateway/v1/account/ \
  -H "Authorization: Bearer sk_live_your_api_key_here"
response = requests.get(
    f"{BASE_URL}/account/",
    headers=headers,
)
account = response.json()["data"]
print(f"Balance: ${account['balance']}")
print(f"Orders today: {account['daily_orders_used']}")
const response = await fetch(`${BASE_URL}/account/`, {
  headers: { "Authorization": `Bearer ${API_KEY}` },
});

const { data } = await response.json();
console.log(`Balance: $${data.balance}`);
console.log(`Orders today: ${data.daily_orders_used}`);
$ch = curl_init("$baseUrl/account/");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer $apiKey",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = json_decode(curl_exec($ch), true);
$account = $response["data"];
echo "Balance: \${$account['balance']}\n";
echo "Orders today: {$account['daily_orders_used']}\n";
uri = URI("#{BASE_URL}/account/")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"

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

account = JSON.parse(response.body)["data"]
puts "Balance: $#{account['balance']}"
puts "Orders today: #{account['daily_orders_used']}"
var response = await client.GetAsync($"{baseUrl}/account/");
var json = await response.Content.ReadAsStringAsync();

using var doc = JsonDocument.Parse(json);
var data = doc.RootElement.GetProperty("data");
Console.WriteLine($"Balance: ${data.GetProperty("balance")}");
Console.WriteLine($"Orders today: {data.GetProperty("daily_orders_used")}");
200 — Response
{
  "success": true,
  "data": {
    "balance": 142.50,
    "daily_orders_used": 12,
    "max_bulk_labels": 100
  },
  "error": null,
  "message": "OK"
}

Validate Orders

Submit a batch of orders for validation. The API checks addresses, verifies pricing, screens each row against the duplicate-detection window, and confirms your balance covers the total cost. Returns a batch_id that you use to confirm the orders.

Duplicate detection will not catch repeats of orders you created through this API. The window compares a row only against previously created single orders — the kind the dashboard creates. Everything POST /orders/confirm/{batch_id} creates is a bulk order, and bulk orders are stored without a fingerprint, so they are never matched. Three consequences follow, and all three are load-bearing if you are integrating programmatically:
  • Two identical rows inside one validate call are not compared with each other — both pass, and confirming that batch buys and charges the same shipment twice.
  • Re-validating rows you have already confirmed also comes back clean, at any interval, forever.
  • confirm_duplicate is therefore a no-op for an API-only account: there is nothing for it to bypass.
Deduplicate on your side. Put your own idempotency key in reference, record the returned order_id, and reconcile with GET /gateway/v1/orders/?reference=YOUR-REFERENCE before resending — that lookup matches the per-label references of bulk orders and is the real safeguard against double-shipping.
POST /gateway/v1/orders/validate

Request Body

FieldTypeDescription
ordersrequired array Array of order objects. Max = your account's max_bulk_labels (default 100 — see GET /account/). Exceeding it returns 400 BATCH_TOO_LARGE. Every element must be a JSON object: an element that is a string, number, boolean, array or null fails that row inside the 200 with VALIDATION_ERROR and details.errors[0].field = orders[i], exactly like any other malformed row — the rest of the batch still validates. That row's order_data echoes your element back verbatim, so on a 200 orders[].order_data is not always an object: model it as “any JSON value”.
Send every field as its documented JSON type — the string ones are checked. For string-typed fields the Type column below is enforced: a mismatch fails that row inside the 200, like any other bad value. The number and boolean types are read leniently, so the five rules below are the whole contract — do not read the Type column as “the API will catch my type bugs”.
  • Every field typed string must be a JSON string — in the order object and in both address objects, with one deliberate exception, reference. A number, boolean, object or array fails the row with VALIDATION_ERROR and a details.errors[] entry reading <field> must be a JSON string, not a number (or not a boolean, not an object, not an array). The classic case is a ZIP code: send "zip": "94104", not "zip": 94104 — a leading zero would be lost by your JSON encoder anyway.
    reference is the exception because it already had its own answer: a non-string reference fails the row with REFERENCE_INVALID_CHARS, not with the message above, and the value echoed in orders[].reference and orders[].order_data.reference keeps the JSON type you sent. Model those two as “any JSON value”; labels[].reference on the confirm response is always a string.
  • JSON null means “not supplied” in a string field and is normalised to an empty string. What happens next depends on the field: the five required address fields fail with their own per-field codes (NAME_REQUIRED, ADDR_LINE1_REQUIRED, CITY_REQUIRED, STATE_REQUIRED, ZIP_REQUIRED); service_type — the only required string field outside the address objects — fails with the generic VALIDATION_ERROR and the message “Service type is required”, as there is no SERVICE_TYPE_REQUIRED code; and an optional field simply stays blank. Do not branch on a *_REQUIRED suffix alone. The value echoed in order_data is the normalised "", not your null.
  • Every JSON number must be finite and no wider than a signed 64-bit integer. NaN and Infinity are not valid JSON numbers, and an integer above 9223372036854775807 cannot be stored: such a value is reported on its own path and replaced by null in the row. In a string-typed field that null is then normalised to "" by the rule above, so an out-of-range "zip" echoes as "" rather than as null. This applies anywhere in the row, including keys we do not document.
  • The four number fields of the Package Object are coerced, not type-checked. A numeric string is accepted and priced: "weight_oz": "16" is read as 16 oz and echoed back as the string "16", so a strict client must not assume the echo is a number. Exactly two values are rejected, each failing the row with VALIDATION_ERROR on package.<field>: a JSON true/false (which would otherwise be read as 1 and 0), and a string that parses to a non-finite number — "NaN", "nan", "Infinity", "1e400". That last one is worth knowing: a CSV export writes NaN into an empty weight column, and it used to take the whole request down with a 500.
  • confirm_duplicate is read for truthiness, not as a boolean. It is documented boolean and the API only asks whether it is set, so any non-empty string is true — "confirm_duplicate": "false" silently disables the duplicate check rather than enabling it. Send a real JSON true/false, or omit the key.

A short list of payload defects is still fatal to the whole request rather than to one row — see INTERNAL_SERVER_ERROR for what they are and how to tell them apart from an outage.

Order Object

FieldTypeDescription
ship_fromrequired object Sender address. See Address Object. An empty object {} is reported as the container being missing — a single VALIDATION_ERROR on ship_from reading “Sender address (ship_from) is required” — not as the five per-field *_REQUIRED codes; send at least one field to get field-level errors
ship_torequired object Recipient address. See Address Object. Same empty-object rule as ship_from: {} yields one VALIDATION_ERROR on ship_to, a partially filled object yields the per-field codes
packagerequired object Package dimensions and weight. See Package Object. Same empty-object rule: {} yields one VALIDATION_ERROR on package reading “Package details are required”, not the four weight/dimension errors
service_typerequired string priority_mail or ground_advantage. Matched case-insensitively and, unlike label_format below, case-folded before it is stored: "Priority_Mail" is accepted, is echoed back on the validate row exactly as you sent it, and is reported as "priority_mail" on the order and its labels from then on. ground_advantage rows over 15.99 oz fail with GROUND_ADVANTAGE_WEIGHT_EXCEEDED on this field — see Package Requirements
label_formatoptional string Label size: 4x6 (default) or a4 — the only accepted values (case-insensitive). Any other value that is not blank fails the row with a VALIDATION_ERROR entry on label_format. Blank values are the exception: an empty string, a whitespace-only string and JSON null all pass validation but are not normalised to the 4x6 default — the default applies only when the key is absent, so the order is stored blank. (A null is normalised to "" first, like every string field — see Send every field as its documented JSON type above — so it is the empty string that is stored and echoed.) The stored value is also not case-folded: "4X6" passes and is stored as "4X6". Omit the key rather than sending "", "  " or null.
referenceoptional string Your internal reference ID, stored with the order. It is echoed on the validate row, on confirm's rejected[] entries, and on labels[].reference in order-status, list and detail responses — but not for accepted labels in the confirm response itself, which reports them only as counts and a total price. Max 50 chars (REFERENCE_TOO_LONG); must start with a letter or digit and may contain only letters, digits, spaces, and _ - . / (REFERENCE_INVALID_CHARS) — e.g. PO#1234 is rejected because of the #. It must also be sent as a JSON string: a numeric or boolean value fails with that same code even when its digits are otherwise valid, so send "12345", not 12345. null is the one non-string that is accepted — it means “no reference” and is normalised to "", so the reference echoed on the row and reported on labels[].reference is always a string.
item_nameoptional string Item description stored on the label (e.g. for your own manifest). Passed through as-is.
confirm_duplicateoptional boolean Set to true to bypass the 1-hour duplicate detection window. Default: false. Two orders are duplicates when ship_from, ship_to, service_type and weight (rounded to 2 dp) all match, within the last hour, excluding orders that failed or were cancelled or refunded. The address comparison hashes only address1, address2, city, state and zipname, company, phone and email are not part of it, so two shipments to different people at the same street address collide. Package dimensions, reference and item_name are excluded too, so two different boxes of the same weight collide while a 0.01 oz difference defeats detection entirely.

The window only ever matches previously created single (dashboard) orders. Orders created through this API are bulk orders and carry no fingerprint, so nothing you confirm here is ever matched — which makes this flag a no-op for an API-only account. Deduplicate on your side and reconcile with GET /gateway/v1/orders/?reference=….

Address Object

FieldTypeDescription
namerequired string Full name (max 32 chars). Unaccented Latin letters (A–Z, a–z), spaces, apostrophes, hyphens, and periods only — digits, accented characters (é, ñ, ü), non-Latin scripts, or any other character fail the row with NAME_INVALID_CHARS (e.g. Bob Receiver 3rd and José Álvarez are both rejected). Transliterate before submitting: JoséJose.
companyoptional string Company name (max 40 chars). Unaccented Latin letters, digits, spaces, and ' - . , # & (COMPANY_INVALID_CHARS otherwise) — accented characters and non-Latin scripts are rejected here too.
address1required string Street address line 1 (max 32 chars). Unaccented Latin letters, digits, spaces, and ' - . , # / (ADDR_LINE1_INVALID_CHARS otherwise) — accented characters and non-Latin scripts are rejected here too.
address2optional string Apt, suite, unit (max 32 chars). Same character set as address1 (ADDR_LINE2_INVALID_CHARS otherwise).
cityrequired string City (2–24 chars — a 1-char city fails with CITY_TOO_SHORT). Unaccented Latin letters, spaces, apostrophes, hyphens, and periods only (CITY_INVALID_CHARS otherwise) — this rejects real deliverable US destinations spelled with accents, so transliterate: Cañon City, COCanon City, CO.
staterequired string 2-letter state code (e.g., CA). Full state names (e.g. California) are also accepted and are normalized to the 2-letter code before validation — the stored order carries the code. Bogus codes fail with INVALID_STATE; recognized-but-not-enabled codes (territories/military PR, GU, VI, AS, MP, MH, AA, AE, AP, or other states not supported for shipping) fail with STATE_NOT_SUPPORTED.
ziprequired string ZIP code — 5 digits, or ZIP+4 with hyphen (e.g., 12345-6789). 9 digits without a hyphen is rejected. Quote it: "94104", not 94104 — like every field in this table it is checked as a JSON string, and an unquoted ZIP fails the row (see Send every field as its documented JSON type).
phoneoptional string Phone number, stored with the order. All non-digit characters are ignored — not just formatting such as spaces, dashes, parens and +, but letters and punctuation too — and only the remaining digit count is checked: it must be 10–13 (INVALID_PHONE otherwise). The digits-only form is used solely for that count and is never written back, so the value is stored on the order and label exactly as you sent it. An extension such as (202) 555-0101 ext. 5 therefore passes — the extension's digit is silently counted as part of the number — and is stored verbatim. Strip extensions and free text yourself.
emailoptional string Email address, stored with the order. Max 254 chars and format-checked (INVALID_EMAIL otherwise).

Package Object

FieldTypeDescription
weight_ozrequired number Weight in ounces (1.6 – 1120 oz). ground_advantage is capped at 15.99 oz — see Package Requirements
length_inrequired number Length in inches (0.1 – 108)
width_inrequired number Width in inches (0.1 – 108)
height_inrequired number Height in inches (0.1 – 108)

Weight and dimensions are also checked in combination, so a package whose every field is in range can still fail: volume (13,000 cu in by default — the maximum weight and volume are service settings we control, so read the value in force from the error message), length + girth (165 in), and the ground_advantage weight cap. See Package Requirements.

Response

The response includes a batch_id, an expiry time (15 minutes), a summary, and per-order results. Each order's result is one of: passed, corrected, or failed.

Balance and limits are aggregate checks — not HTTP errors. If your balance cannot cover the batch total, or a daily order-count limit or per-order spend limit is hit, the endpoint still returns 200 with a batch_id, but every row that had passed is flipped to result: "failed" with the aggregate error repeated per row — including rows that were individually valid. Confirming that batch creates zero labels. The three codes are not interchangeable:
  • INSUFFICIENT_BALANCEdetails: required_amount, current_balance, shortfall. Top up, then re-validate.
  • DAILY_ORDER_LIMIT_EXCEEDED — a cap on the number of orders you create per day, not on money (details: limit, used, resets_at). Topping up does nothing; the counter clears at resets_at (midnight UTC). One confirmed batch counts as one order.
  • SINGLE_ORDER_LIMIT_EXCEEDED — the batch total exceeds the per-order spend cap (details: limit, requested). Split the batch.
curl -X POST https://app.shipzest.com/gateway/v1/orders/validate \
  -H "Authorization: Bearer sk_live_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "orders": [
      {
        "reference": "ORDER-001",
        "ship_from": {
          "name": "John Smith",
          "address1": "417 Montgomery St",
          "city": "San Francisco",
          "state": "CA",
          "zip": "94104"
        },
        "ship_to": {
          "name": "Jane Doe",
          "address1": "1600 Amphitheatre Pkwy",
          "address2": "Apt 2B",
          "city": "Mountain View",
          "state": "CA",
          "zip": "94043"
        },
        "package": {
          "weight_oz": 12,
          "length_in": 10,
          "width_in": 8,
          "height_in": 4
        },
        "service_type": "priority_mail"
      },
      {
        "reference": "ORDER-002",
        "ship_from": {
          "name": "John Smith",
          "address1": "417 Montgomery St",
          "city": "San Francisco",
          "state": "CA",
          "zip": "94104"
        },
        "ship_to": {
          "name": "Sam Lee",
          "address1": "350 5th Ave",
          "address2": "",
          "city": "New York",
          "state": "NY",
          "zip": "10118"
        },
        "package": {
          "weight_oz": 10,
          "length_in": 9,
          "width_in": 6,
          "height_in": 3
        },
        "service_type": "ground_advantage"
      }
    ]
  }'
import requests

response = requests.post(
    f"{BASE_URL}/orders/validate",
    headers=headers,
    json={
        "orders": [
            {
                "reference": "ORDER-001",
                "ship_from": {
                    "name": "John Smith",
                    "address1": "417 Montgomery St",
                    "city": "San Francisco",
                    "state": "CA",
                    "zip": "94104",
                },
                "ship_to": {
                    "name": "Jane Doe",
                    "address1": "1600 Amphitheatre Pkwy",
                    "address2": "Apt 2B",
                    "city": "Mountain View",
                    "state": "CA",
                    "zip": "94043",
                },
                "package": {
                    "weight_oz": 12,
                    "length_in": 10,
                    "width_in": 8,
                    "height_in": 4,
                },
                "service_type": "priority_mail",
            },
            {
                "reference": "ORDER-002",
                "ship_from": {
                    "name": "John Smith",
                    "address1": "417 Montgomery St",
                    "city": "San Francisco",
                    "state": "CA",
                    "zip": "94104",
                },
                "ship_to": {
                    "name": "Sam Lee",
                    "address1": "350 5th Ave",
                    "address2": "",
                    "city": "New York",
                    "state": "NY",
                    "zip": "10118",
                },
                "package": {
                    "weight_oz": 10,
                    "length_in": 9,
                    "width_in": 6,
                    "height_in": 3,
                },
                "service_type": "ground_advantage",
            }
        ]
    },
)

result = response.json()

# Check the status before unwrapping: validate has top-level 4xx paths
# (ORDERS_REQUIRED, VALIDATION_ERROR, BATCH_TOO_LARGE, 401, 413, 429) and
# on every one of them "data" is null.
if response.status_code != 200:
    raise SystemExit(
        f"validate failed: {response.status_code} "
        f"{result['error']['code']}{result['message']}"
    )

batch_id = result["data"]["batch_id"]
print(f"Batch: {batch_id}")
print(f"Summary: {result['data']['summary']}")
const response = await fetch(
  `${BASE_URL}/orders/validate`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      orders: [
        {
          reference: "ORDER-001",
          ship_from: {
            name: "John Smith",
            address1: "417 Montgomery St",
            city: "San Francisco",
            state: "CA",
            zip: "94104",
          },
          ship_to: {
            name: "Jane Doe",
            address1: "1600 Amphitheatre Pkwy",
            address2: "Apt 2B",
            city: "Mountain View",
            state: "CA",
            zip: "94043",
          },
          package: {
            weight_oz: 12,
            length_in: 10,
            width_in: 8,
            height_in: 4,
          },
          service_type: "priority_mail",
        },
        {
          reference: "ORDER-002",
          ship_from: {
            name: "John Smith",
            address1: "417 Montgomery St",
            city: "San Francisco",
            state: "CA",
            zip: "94104",
          },
          ship_to: {
            name: "Sam Lee",
            address1: "350 5th Ave",
            address2: "",
            city: "New York",
            state: "NY",
            zip: "10118",
          },
          package: {
            weight_oz: 10,
            length_in: 9,
            width_in: 6,
            height_in: 3,
          },
          service_type: "ground_advantage",
        },
      ],
    }),
  }
);

const result = await response.json();

// Check the status before unwrapping: validate has top-level 4xx paths
// (ORDERS_REQUIRED, VALIDATION_ERROR, BATCH_TOO_LARGE, 401, 413, 429) and
// on every one of them "data" is null.
if (response.status !== 200) {
  throw new Error(
    `validate failed: ${response.status} ${result.error.code}${result.message}`
  );
}

console.log("Batch:", result.data.batch_id);
console.log("Summary:", result.data.summary);
$payload = json_encode([
    "orders" => [
        [
            "reference" => "ORDER-001",
            "ship_from" => [
                "name" => "John Smith",
                "address1" => "417 Montgomery St",
                "city" => "San Francisco",
                "state" => "CA",
                "zip" => "94104",
            ],
            "ship_to" => [
                "name" => "Jane Doe",
                "address1" => "1600 Amphitheatre Pkwy",
                "address2" => "Apt 2B",
                "city" => "Mountain View",
                "state" => "CA",
                "zip" => "94043",
            ],
            "package" => [
                "weight_oz" => 12,
                "length_in" => 10,
                "width_in" => 8,
                "height_in" => 4,
            ],
            "service_type" => "priority_mail",
        ],
        [
            "reference" => "ORDER-002",
            "ship_from" => [
                "name" => "John Smith",
                "address1" => "417 Montgomery St",
                "city" => "San Francisco",
                "state" => "CA",
                "zip" => "94104",
            ],
            "ship_to" => [
                "name" => "Sam Lee",
                "address1" => "350 5th Ave",
                "address2" => "",
                "city" => "New York",
                "state" => "NY",
                "zip" => "10118",
            ],
            "package" => [
                "weight_oz" => 10,
                "length_in" => 9,
                "width_in" => 6,
                "height_in" => 3,
            ],
            "service_type" => "ground_advantage",
        ],
    ],
]);

$ch = curl_init("$baseUrl/orders/validate");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer $apiKey",
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$result = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

// Check the status before unwrapping: validate has top-level 4xx paths
// (ORDERS_REQUIRED, VALIDATION_ERROR, BATCH_TOO_LARGE, 401, 413, 429) and
// on every one of them "data" is null.
if ($code !== 200) {
    fwrite(STDERR, "validate failed: $code " . $result["error"]["code"] . "\n");
    exit(1);
}

echo "Batch: {$result['data']['batch_id']}\n";
uri = URI("#{BASE_URL}/orders/validate")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
request["Content-Type"] = "application/json"
request.body = {
  orders: [
    {
      reference: "ORDER-001",
      ship_from: {
        name: "John Smith", address1: "417 Montgomery St",
        city: "San Francisco", state: "CA", zip: "94104"
      },
      ship_to: {
        name: "Jane Doe", address1: "1600 Amphitheatre Pkwy",
        address2: "Apt 2B",
        city: "Mountain View", state: "CA", zip: "94043"
      },
      package: {
        weight_oz: 12, length_in: 10,
        width_in: 8, height_in: 4
      },
      service_type: "priority_mail"
    },
    {
      reference: "ORDER-002",
      ship_from: {
        name: "John Smith", address1: "417 Montgomery St",
        city: "San Francisco", state: "CA", zip: "94104"
      },
      ship_to: {
        name: "Sam Lee", address1: "350 5th Ave",
        address2: "",
        city: "New York", state: "NY", zip: "10118"
      },
      package: {
        weight_oz: 10, length_in: 9,
        width_in: 6, height_in: 3
      },
      service_type: "ground_advantage"
    }
  ]
}.to_json

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

result = JSON.parse(response.body)

# Check the status before unwrapping: validate has top-level 4xx paths
# (ORDERS_REQUIRED, VALIDATION_ERROR, BATCH_TOO_LARGE, 401, 413, 429) and
# on every one of them "data" is null.
unless response.code == "200"
  abort("validate failed: #{response.code} #{result['error']['code']}")
end

puts "Batch: #{result['data']['batch_id']}"
var payload = new
{
    orders = new[]
    {
        new
        {
            reference = "ORDER-001",
            ship_from = new
            {
                name = "John Smith",
                address1 = "417 Montgomery St",
                city = "San Francisco",
                state = "CA",
                zip = "94104"
            },
            ship_to = new
            {
                name = "Jane Doe",
                address1 = "1600 Amphitheatre Pkwy",
                address2 = "Apt 2B",
                city = "Mountain View",
                state = "CA",
                zip = "94043"
            },
            package = new
            {
                weight_oz = 12,
                length_in = 10,
                width_in = 8,
                height_in = 4
            },
            service_type = "priority_mail"
        },
        new
        {
            reference = "ORDER-002",
            ship_from = new
            {
                name = "John Smith",
                address1 = "417 Montgomery St",
                city = "San Francisco",
                state = "CA",
                zip = "94104"
            },
            ship_to = new
            {
                name = "Sam Lee",
                address1 = "350 5th Ave",
                address2 = "",
                city = "New York",
                state = "NY",
                zip = "10118"
            },
            package = new
            {
                weight_oz = 10,
                length_in = 9,
                width_in = 6,
                height_in = 3
            },
            service_type = "ground_advantage"
        }
    }
};

var content = new StringContent(
    JsonSerializer.Serialize(payload),
    Encoding.UTF8,
    "application/json"
);

var response = await client.PostAsync(
    $"{baseUrl}/orders/validate", content
);
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(json);
200 — Validation response
// Response to the request above. Both rows verify cleanly, so both
// come back "passed". Prices are illustrative here and in every other
// example on this page — the figures your account sees come from its own
// pricing rules, so do not treat 8.25 / 16.50 as real rates.
{
  "success": true,
  "data": {
    "batch_id": "batch_6615a3f2e4b0c1d2e3f4a5b6",
    "expires_at": "2025-04-10T15:30:00+00:00",
    "summary": {
      "total": 2,
      "passed": 2,
      "corrected": 0,
      "failed": 0,
      "estimated_total_price": 16.50
    },
    "orders": [
      {
        "index": 0,
        "reference": "ORDER-001",
        "result": "passed",
        "price": 8.25,
        "verification": {
          "ship_from": { "status": "verified" },
          "ship_to": { "status": "verified" }
        },
        "order_data": { /* ... echo of the submitted order ... */ }
      },
      {
        "index": 1,
        "reference": "ORDER-002",
        "result": "passed",
        "price": 8.25,
        "verification": {
          "ship_from": { "status": "verified" },
          "ship_to": { "status": "verified" }
        },
        "order_data": { /* ... echo of the submitted order ... */ }
      }
    ]
  },
  "error": null,
  "message": "Validation complete"
}
Corrected order in batch
// A row whose ship_to ZIP was wrong (10001 for 350 5th Ave). USPS
// resolves it to 10118-0110, so the row comes back "corrected".
// original and corrected always carry the same five keys, including
// address2 (empty string when you did not send one). The corrected
// values are what the confirmed order and the printed label use.
{
  "index": 2,
  "reference": "ORDER-003",
  "result": "corrected",
  "price": 8.25,
  "verification": {
    "ship_from": { "status": "verified" },
    "ship_to": {
      "status": "corrected",
      "original": {
        "address1": "350 5th Ave",
        "address2": "Suite 4100",
        "city": "New York",
        "state": "NY",
        "zip": "10001"
      },
      "corrected": {
        "address1": "350 5TH AVE",
        "address2": "STE 4100",
        "city": "NEW YORK",
        "state": "NY",
        "zip": "10118-0110"
      }
    }
  },
  "order_data": { /* ... order data, with the corrected ship_to applied ... */ }
}
Failed order in batch
// An order within the batch that failed validation. Failed rows echo
// order_data back alongside error/details. Field-level failures like this
// one echo your submission almost literally — with three rewrites. (1) A
// state that validates is normalized to its upper-case 2-letter code in the
// echo ("california" comes back "CA"), even on a row that failed on some
// other field. (2) A JSON null in any documented string field comes back as
// "" — null means "not supplied". (3) A number too large or too non-finite
// to store comes back as null (and then as "" if it sat in a string field).
// A row that failed AFTER an
// address correction was applied (ADDRESS_VERIFICATION_FAILED,
// SERVICE_NOT_ENABLED, DUPLICATE_ORDER, ORDER_CREATION_FAILED, or an
// aggregate balance/daily-limit flip — the list is not exhaustive)
// echoes the CORRECTED address, plus an added empty address2 —
// so never diff order_data against your submission to detect changes.
// Use verification (passed/corrected rows) or details (failed rows).
//
// order_data is echoed verbatim, so it is not always an object: a row you
// submitted as a string, number, boolean, array or null comes back in this
// same key with that type, inside the 200 (see the orders[] row above).
{
  "index": 3,
  "reference": "ORDER-004",
  "result": "failed",
  "order_data": { /* ... echo of the order as validated ... */ },
  "error": "VALIDATION_ERROR",
  "details": {
    "errors": [
      {
        "field": "ship_to.state",
        "code": "INVALID_STATE",
        "message": "Invalid US state code"
      }
    ]
  }
}

Confirm Orders

Confirm a previously validated batch to create shipping labels. Confirming creates the bulk order and queues it; the charge is applied once, when the order is picked up for label generation — never twice for the same batch. summary.total_price is the batch quote: it is held against your available balance as soon as the order is created (so GET /account/ balance drops by the batch total immediately) and settled once, at that pickup. A partial order is charged only for the labels that completed, and a failed order is not charged at all (see PROCESSOR_INSUFFICIENT_BALANCE in Error Codes) — so do not sum total_price across partial orders to reconcile spend, and do not use the account balance to decide whether a confirm landed: use the returned order_id, or GET /batches/{batch_id}/. The gateway is a bulk-ordering API: confirming a batch creates a single bulk order that holds every label with a passed or corrected validation result. Rows that failed validation are returned in rejected and are not part of the order.

POST /gateway/v1/orders/confirm/{batch_id}

Path Parameters

ParameterTypeDescription
batch_idrequired string Batch ID from the validate response (e.g., batch_6615a3f2...)
Confirm is one-shot, and replaying it is safe while the batch is retained. An unconfirmed batch expires 15 minutes after validation and then returns 400 BATCH_EXPIRED — re-validate to get a fresh batch_id. That code is a short-lived window, not the steady state: an hourly cleanup deletes never-confirmed batches once they expire, so from somewhere between 15 and 75 minutes after validation the same call answers 404 RESOURCE_NOT_FOUND instead, permanently. For a batch you never confirmed the two mean the same thing — re-validate. (The 404 paragraph further down is about the other case: a batch you did confirm, whose record has aged out.) A batch that was already confirmed returns 409 BATCH_ALREADY_CONFIRMED for as long as its record is kept — 24 hours after validation by default, a window we set and can change — including after those 15 minutes have passed. Overlapping confirms are covered too: the batch is claimed before any order is created, so if a retry reaches us while the first request is still running, exactly one of them creates the order and the other gets the 409. The same 24 hours apply to a confirm that was interrupted mid-create (an interruption between the claim and the order being written): it keeps answering 409, so the retry never silently creates a second order.

A confirm that fails with a top-level 4xx does not consume the batch. 422 INSUFFICIENT_BALANCE, 422 DAILY_ORDER_LIMIT_EXCEEDED, 422 SINGLE_ORDER_LIMIT_EXCEEDED, 422 SERVICE_NOT_ENABLED, 422 CARRIER_NOT_ENABLED and 422 ORDER_CREATION_FAILED all return before any order exists, so nothing was created and nothing was charged. Top up, or enable the service/carrier, then POST the same batch_id again while you are still inside the 15-minute window. Two of these cannot be cleared by a replay, because a replay re-evaluates the identical, immutable set of rows: SINGLE_ORDER_LIMIT_EXCEEDED (a cap on this order's total cost — re-validate smaller batches instead) and DAILY_ORDER_LIMIT_EXCEEDED (an order-count cap that clears only at midnight UTC, long after the 15-minute window — wait for the resets_at in error.details).

The converse does not hold: a 409 does not prove an order exists. It means only that some earlier confirm claimed this batch — it may or may not have created anything. Two claimed-but-empty cases answer 409 with no order behind them: a confirm interrupted between the claim and the order being written, and a batch in which every row failed validation (that confirm succeeds with order_id: null and labels_accepted: 0, and its replay is a 409 all the same). Do not read 409 as “those rows shipped”: call GET /gateway/v1/batches/{batch_id}/ and branch on order_id plus status — the 409 example below spells out every combination.

If a confirm request times out and you do not know whether it landed, call GET /gateway/v1/batches/{batch_id}/ first: a non-null order_id means the order already exists and you should poll it instead. Re-validating and re-confirming the same rows would create a second bulk order and charge you twice — duplicate detection never matches the bulk orders this API creates, so nothing on our side will catch the repeat. Deduplicate on your side.

Past the retention window the batch record is deleted, and both POST /orders/confirm/{batch_id} and GET /batches/{batch_id}/ answer 404 RESOURCE_NOT_FOUND. A 404 there is not evidence that your confirm never landed — orders outlive the batches that created them. Before re-validating anything, reconcile against the orders themselves: GET /gateway/v1/orders/?reference=YOUR-REFERENCE matches the per-label references of bulk orders, so a hit means the confirm landed and you must not send those rows again. Size your retry horizon to the retention window: a confirm you cannot resolve within 24 hours of validating has to be reconciled by reference, not by batch_id.

Response

Returns the single bulk order_id, a summary (labels_accepted, labels_rejected, total_price), and any rejected rows. The order starts as pending and moves to completed, failed, or partial (some labels succeeded, some failed) once processing finishes. Poll GET /gateway/v1/orders/{order_id}/ or GET /gateway/v1/batches/{batch_id}/ for status and per-label tracking numbers. If every row in the batch failed validation, the confirm still succeeds with order_id: null and labels_accepted: 0 — no order is created and nothing is charged. The envelope message is still "Bulk order created" in that case: the view sets that string on every success path. Branch on data.order_id, never on message — and never log message as proof that an order exists.

Rejected Row Object

Each entry of rejected[] is one row that failed at validate time and was therefore left out of the order:

FieldTypeDescription
index integer Zero-based position of the row in the orders array you validated. Not the same basis as labels[].index on the order you get back, which counts only the accepted labels — the field that matches this one is labels[].submitted_index. See Label Object.
reference string The row's reference, echoed back verbatim (empty string if you did not send one, or if you sent JSON null). A string on every passed and corrected row — a reference that is not a string cannot pass. On a failed row it carries the JSON type you sent, because sending a non-string is itself one of the ways to fail the row (REFERENCE_INVALID_CHARS): "reference": 12345 is echoed here as the number 12345. Type it as “any JSON value” if you read it before checking result; labels[].reference on the confirm response is always a string
error string The row's validate-time error code, e.g. VALIDATION_ERROR, ADDRESS_VERIFICATION_FAILED, DUPLICATE_ORDER
details object The same details payload the row carried at validate — e.g. {errors: [{field, code, message}]} for a field failure
curl -X POST https://app.shipzest.com/gateway/v1/orders/confirm/batch_6615a3f2e4b0c1d2e3f4a5b6 \
  -H "Authorization: Bearer sk_live_your_api_key_here" \
  -H "Content-Type: application/json"
batch_id = "batch_6615a3f2e4b0c1d2e3f4a5b6"

response = requests.post(
    f"{BASE_URL}/orders/confirm/{batch_id}",
    headers=headers,
)
body = response.json()

# Check the status before unwrapping: on any error "data" is null.
if response.status_code == 200:
    data = body["data"]
    # A 200 does NOT guarantee an order: if every row failed validation,
    # order_id is null and labels_accepted is 0 (nothing was charged).
    # The envelope message says "Bulk order created" either way.
    if data["order_id"] is None:
        print("Confirm accepted zero labels — nothing created or charged")
    else:
        print(f"Bulk order {data['order_id']} created "
              f"({data['summary']['labels_accepted']} labels, ${data['summary']['total_price']})")
    # rejected[] is the only place the API names the dropped rows.
    for row in data["rejected"]:
        print(f"  rejected row {row['index']} "
              f"({row['reference']}): {row['error']}")
elif response.status_code == 409:
    # Already confirmed — your earlier call landed. Read the order id
    # off the batch; NEVER re-validate these rows (that double-charges).
    batch = requests.get(
        f"{BASE_URL}/batches/{batch_id}/", headers=headers,
    ).json()["data"]
    if batch is None:
        # 404: the batch was purged (24h after validation). The order,
        # if any, outlived it — reconcile by reference, never re-validate.
        raise SystemExit(
            "batch purged — reconcile with GET /orders/?reference=..."
        )
    # order_id is null in TWO states — only "status" separates them.
    if batch["order_id"]:
        print(f"Already confirmed as {batch['order_id']} — poll that order")
    elif batch["status"] == "completed":
        print("Confirm accepted zero labels — terminal, nothing was "
              "created or charged; fix the rows and validate a new batch")
    else:
        print("Confirm still in flight — keep polling this batch")
else:
    # 400 BATCH_EXPIRED, 422 INSUFFICIENT_BALANCE, 404 …
    print(f"{response.status_code} {body['error']['code']}: {body['message']}")
const batchId = "batch_6615a3f2e4b0c1d2e3f4a5b6";

const response = await fetch(
  `${BASE_URL}/orders/confirm/${batchId}`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
  }
);

const body = await response.json();

// Check the status before unwrapping: on any error "data" is null.
if (response.status === 200) {
  const { data } = body;
  // A 200 does NOT guarantee an order: if every row failed validation,
  // order_id is null and labels_accepted is 0 (nothing was charged).
  // The envelope message says "Bulk order created" either way.
  console.log(data.order_id === null
    ? "Confirm accepted zero labels — nothing created or charged"
    : `Bulk order ${data.order_id} created ` +
      `(${data.summary.labels_accepted} labels, $${data.summary.total_price})`);
  // rejected[] is the only place the API names the dropped rows.
  for (const row of data.rejected) {
    console.log(`  rejected row ${row.index} (${row.reference}): ${row.error}`);
  }
} else if (response.status === 409) {
  // Already confirmed — your earlier call landed. Read the order id
  // off the batch; NEVER re-validate these rows (that double-charges).
  const res = await fetch(`${BASE_URL}/batches/${batchId}/`,
    { headers: { "Authorization": `Bearer ${API_KEY}` } });
  const { data } = await res.json();
  if (data === null) {
    // 404: the batch was purged (24h after validation). The order,
    // if any, outlived it — reconcile by reference, never re-validate.
    throw new Error("batch purged — reconcile with GET /orders/?reference=...");
  }
  // order_id is null in TWO states — only "status" separates them.
  if (data.order_id) {
    console.log(`Already confirmed as ${data.order_id} — poll that order`);
  } else if (data.status === "completed") {
    console.log("Confirm accepted zero labels — terminal, nothing was created " +
      "or charged; fix the rows and validate a new batch");
  } else {
    console.log("Confirm still in flight — keep polling this batch");
  }
} else {
  // 400 BATCH_EXPIRED, 422 INSUFFICIENT_BALANCE, 404 …
  console.error(response.status, body.error.code, body.message);
}
$batchId = "batch_6615a3f2e4b0c1d2e3f4a5b6";

$ch = curl_init("$baseUrl/orders/confirm/$batchId");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer $apiKey",
    "Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$body = json_decode(curl_exec($ch), true);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

// Check the status before unwrapping: on any error "data" is null.
if ($code === 200) {
    $data = $body["data"];
    // A 200 does NOT guarantee an order: if every row failed validation,
    // order_id is null and labels_accepted is 0 (nothing was charged).
    // The envelope message says "Bulk order created" either way.
    if ($data["order_id"] === null) {
        echo "Confirm accepted zero labels — nothing created or charged\n";
    } else {
        echo "Bulk order {$data['order_id']} created "
           . "({$data['summary']['labels_accepted']} labels)\n";
    }
    // rejected[] is the only place the API names the dropped rows.
    foreach ($data["rejected"] as $row) {
        echo "  rejected row {$row['index']} "
           . "({$row['reference']}): {$row['error']}\n";
    }
} elseif ($code === 409) {
    // Already confirmed — GET /batches/{batch_id}/ for the order id.
    // NEVER re-validate these rows: that creates a second order.
    echo "Already confirmed — reconcile via the batch endpoint\n";
} else {
    echo $code . " " . $body["error"]["code"] . "\n";
}
batch_id = "batch_6615a3f2e4b0c1d2e3f4a5b6"

uri = URI("#{BASE_URL}/orders/confirm/#{batch_id}")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{API_KEY}"
request["Content-Type"] = "application/json"

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

body = JSON.parse(response.body)

# Check the status before unwrapping: on any error "data" is null.
case response.code
when "200"
  data = body["data"]
  # A 200 does NOT guarantee an order: if every row failed validation,
  # order_id is null and labels_accepted is 0 (nothing was charged).
  # The envelope message says "Bulk order created" either way.
  if data["order_id"].nil?
    puts "Confirm accepted zero labels — nothing created or charged"
  else
    puts "Bulk order #{data['order_id']} created " \
         "(#{data['summary']['labels_accepted']} labels)"
  end
  # rejected[] is the only place the API names the dropped rows.
  data["rejected"].each do |row|
    puts "  rejected row #{row['index']} " \
         "(#{row['reference']}): #{row['error']}"
  end
when "409"
  # Already confirmed — GET /batches/{batch_id}/ for the order id.
  # NEVER re-validate these rows: that creates a second order.
  puts "Already confirmed — reconcile via the batch endpoint"
else
  puts "#{response.code} #{body['error']['code']}"
end
var batchId = "batch_6615a3f2e4b0c1d2e3f4a5b6";

var response = await client.PostAsync(
    $"{baseUrl}/orders/confirm/{batchId}",
    new StringContent("", Encoding.UTF8, "application/json")
);

var json = await response.Content.ReadAsStringAsync();

// Branch on the status before parsing: on any error "data" is null, and a
// 503 raised by a proxy in front of the API is HTML, not an envelope.
if (response.IsSuccessStatusCode)
{
    using var doc = JsonDocument.Parse(json);
    var data = doc.RootElement.GetProperty("data");
    // A 200 does NOT guarantee an order: if every row failed validation,
    // order_id is null and labels_accepted is 0 (nothing was charged).
    // Branch on data.order_id, never on message.
    var orderId = data.GetProperty("order_id");
    if (orderId.ValueKind == JsonValueKind.Null)
    {
        Console.WriteLine("Confirm accepted zero labels — nothing created or charged");
    }
    else
    {
        var accepted = data.GetProperty("summary").GetProperty("labels_accepted").GetInt32();
        Console.WriteLine($"Bulk order {orderId.GetString()} created ({accepted} labels)");
    }
    // rejected[] is the only place the API names the dropped rows.
    foreach (var row in data.GetProperty("rejected").EnumerateArray())
    {
        var index = row.GetProperty("index").GetInt32();
        var reference = row.GetProperty("reference").GetString();
        var rowError = row.GetProperty("error").GetString();
        Console.WriteLine($"  rejected row {index} ({reference}): {rowError}");
    }
}
else if ((int)response.StatusCode == 409)
{
    // Already confirmed — your earlier call landed. Read the order id off
    // the batch; NEVER re-validate these rows (that double-charges).
    var batchResponse = await client.GetAsync($"{baseUrl}/batches/{batchId}/");
    var batchJson = await batchResponse.Content.ReadAsStringAsync();
    if ((int)batchResponse.StatusCode == 404)
    {
        // The batch was purged (24h after validation). The order, if any,
        // outlived it — reconcile by reference, never re-validate.
        throw new Exception("batch purged — reconcile with GET /orders/?reference=...");
    }
    using var batchDoc = JsonDocument.Parse(batchJson);
    var batch = batchDoc.RootElement.GetProperty("data");
    // order_id is null in TWO states — only "status" separates them.
    var batchOrderId = batch.GetProperty("order_id");
    if (batchOrderId.ValueKind != JsonValueKind.Null)
    {
        Console.WriteLine($"Already confirmed as {batchOrderId.GetString()} — poll that order");
    }
    else if (batch.GetProperty("status").GetString() == "completed")
    {
        Console.WriteLine("Confirm accepted zero labels — terminal, nothing was created or charged");
    }
    else
    {
        Console.WriteLine("Confirm still in flight — keep polling this batch");
    }
}
else
{
    // 400 BATCH_EXPIRED, 422 INSUFFICIENT_BALANCE, 404 …
    // Guard the parse: the HTML body this comment warns about would throw
    // here, turning a readable proxy error into a JsonException.
    string errCode;
    try
    {
        using var errDoc = JsonDocument.Parse(json);
        errCode = errDoc.RootElement.GetProperty("error").GetProperty("code").GetString();
    }
    catch (JsonException)
    {
        errCode = "non-JSON error body";
    }
    Console.WriteLine($"{(int)response.StatusCode} {errCode}");
}
200 — Confirmation response
// NOT the two-row batch above: a different, three-row batch, used here
// and in the "Reconciling with your own rows" example under Get Order
// Status because it is the case where index and submitted_index diverge.
// The middle row (index 1, ORDER-002) failed validation, so two labels
// were accepted — ORDER-001 from row 0 and ORDER-003 from row 2 — and the
// middle one is reported in rejected[].
// labels_accepted + labels_rejected always equals the number of rows you
// validated, and every rejected index is a position in that same array.
{
  "success": true,
  "data": {
    "batch_id": "batch_6615c4d5e6f7a8b9c0d1e2f3",
    "order_id": "6615b7c8d9e0f1a2b3c4d5e6",
    "summary": {
      "labels_accepted": 2,
      "labels_rejected": 1,
      "total_price": 16.50
    },
    "rejected": [
      {
        "index": 1,
        "reference": "ORDER-002",
        "error": "VALIDATION_ERROR",
        "details": {
          "errors": [
            {
              "field": "ship_to.state",
              "code": "INVALID_STATE",
              "message": "Invalid US state code"
            }
          ]
        }
      }
    ]
  },
  "error": null,
  "message": "Bulk order created"
}
200 — Confirm that accepted zero labels
// A FOURTH batch, unrelated to the three-row one above: one row, and it
// failed validation. So no order was created and nothing was charged.
// (Each example on this page uses its own batch_id — one batch_id never
// has two outcomes, because a batch can only be confirmed once.)
// Note the envelope message: it is the SAME string as the success case
// above, because the view sets it on every success path.
// Branch on data.order_id — a null order_id here is TERMINAL, and polling
// GET /orders/null/ or waiting for an order to appear will never resolve.
{
  "success": true,
  "data": {
    "batch_id": "batch_6615e9f0a1b2c3d4e5f6a7b8",
    "order_id": null,
    "summary": {
      "labels_accepted": 0,
      "labels_rejected": 1,
      "total_price": 0.0
    },
    "rejected": [
      {
        "index": 0,
        "reference": "ORDER-005",
        "error": "VALIDATION_ERROR",
        "details": {
          "errors": [
            {
              "field": "ship_to.state",
              "code": "INVALID_STATE",
              "message": "Invalid US state code"
            }
          ]
        }
      }
    ]
  },
  "error": null,
  "message": "Bulk order created"
}
409 — Batch already confirmed (replay)
// Returned for every repeat confirm of the same batch for as long as the
// batch record is retained (24 hours after validation by default) — the
// 15-minute expiry never overrides it.
// Treat 409 as "an earlier confirm claimed this batch",
// never as "an order exists": an interrupted confirm and an
// all-rejected batch both replay as 409 with nothing created. Call
// GET /gateway/v1/batches/{batch_id}/ to read the order_id, and
// do not re-validate unless that order is failed or cancelled (both are
// terminal, so those rows do need a new order) — or partial, in which
// case re-validate only the labels[] entries whose status is "failed";
// the ones that completed are shipped and must not be resent.
//
// order_id: null is only conclusive together with status. status:
// "completed" + order_id: null means the confirm landed and created
// nothing — every row had failed validation (the labels_accepted: 0
// case), nothing was charged, so fix the rows and validate a new batch.
// status: "pending" + order_id: null means a confirm is still in flight
// (or was interrupted mid-create): do NOT validate a new batch — poll
// this endpoint, then reconcile with GET /orders/?reference=... before
// resending anything.
//
// Past the retention window this endpoint returns 404 RESOURCE_NOT_FOUND
// instead: the batch is gone, but the order is not. Reconcile with
// GET /gateway/v1/orders/?reference=... before re-validating.
{
  "success": false,
  "data": null,
  "error": {
    "code": "BATCH_ALREADY_CONFIRMED",
    "details": {}
  },
  "message": "Batch has already been confirmed"
}
400 — Batch expired
// Only an UNCONFIRMED batch can expire. Re-validate to continue.
{
  "success": false,
  "data": null,
  "error": {
    "code": "BATCH_EXPIRED",
    "details": {}
  },
  "message": "Batch has expired. Please re-validate your orders."
}

Get Order Status

Poll the status of a single order. Use this after confirming a batch to check whether labels have been generated. Completed bulk orders — every order this API creates is one — carry a tracking number per label in labels[].tracking_number, with no top-level tracking_number, plus merged secure, time-limited download URLs (labels_pdf_url, csv_url, invoice_url). This endpoint does not filter by origin, so it also returns single orders created in the dashboard, and those use the singular label_pdf_url shape instead. Branch on order_type == "bulk" before reading labels — see the Two order shapes callout below.

GET /gateway/v1/orders/{order_id}/

Path Parameters

ParameterTypeDescription
order_idrequired string Order ID from the confirm response

Order Statuses

StatusDescription
pending Order created, waiting to be picked up for label generation
processing The label is being generated
completed All labels generated — each entry in labels[] carries a tracking number, plus merged download URLs
partial Some labels succeeded, some failed — check each entry's status in labels[]. You are only charged for the labels that succeeded; failed labels are never charged. Note that price stays at the quoted batch total from confirmation — on a partial order the amount actually debited is lower (only the succeeded labels' share), and no field reports that figure, so do not sum price across partial orders to reconcile spend.
failed All labels failed — read error_data for the label-generation error. On a hard failure label generation writes order-level error_data only, so labels[] entries carry just index, submitted_index, reference, and service_type (submitted_index is omitted only when the source row cannot be resolved — see Label Object; it is present on every order this API created, old ones included); per-label status/error are attached from the per-label results a partial order carries. Map failures back to your rows with submitted_index, never with index. Treat per-label error fields as absent here rather than empty. Create a new order.
cancelled Order was cancelled by support. The response additionally carries a cancelled_at ISO 8601 timestamp alongside price and the labels[] breakdown. It carries no completed_at and no download URLs — read both with .get() semantics.
refunded Credits refunded to your balance. A refund can follow a completed or partial order, and it withdraws the download URLs — see below. It also drops completed_at: a refunded response carries price and the labels[] breakdown but neither the timestamp nor the URLs, so read both with .get() semantics rather than copying the key access from a completed-order example.
Individual labels can be cancelled without the order status changing. Support can cancel a subset of the labels in a bulk order, usually with a proportional refund. The order stays completed (or partial), price is unchanged, and the merged PDF/CSV/invoice links stay available. The label keeps its status: "completed" and its original tracking_number, because those record the label that was actually generated — the cancellation is reported separately, as cancelled: true on that entry of labels[]. Check cancelled before treating a label as shippable: its tracking number will not move. It is not proof of a refund. The refund is a separate, support-chosen flag on the cancel action, and it is skipped for an order that was never charged or when the label's share rounds to 0.00; this API reports no refund flag or amount, so a refunded and an unrefunded cancellation look identical here. Reconcile credits against your balance and transactions rather than assuming one per cancelled label. Only when every label has been cancelled does the order itself flip to cancelled.

Label Object

One entry per label in the bulk order, in the order the labels were created. Returned by this endpoint, by Get Batch Status (nested under order), by Retrieve Past Order and by List Orders.

The sibling label_count field in every bulk response is the number of labels in the order — always equal to labels.length. It is fixed when the order is created from the accepted rows and never shrinks when labels fail, so on a partial order it is larger than the number of labels actually produced and larger than the page count of the merged PDF. It is a count of attempts, not of successes: count labels[] entries whose status is completed if you need the latter.

FieldTypeDescription
index integer Position of this label within this order — always 0, 1, 2 … with no gaps. This is not the row number you validated: confirm only creates labels for the rows that passed, so every rejected row shifts the ones after it. Do not compare it with rejected[].index from the confirm response, which counts submitted rows.
submitted_index integer Zero-based position of this label in the orders array you sent to POST /orders/validate — the same basis as rejected[].index. This is the field to reconcile against your own rows. Omitted only when the source row cannot be resolved. In practice it is always present on an order this API created, including orders created long before the field existed: the row is recovered from the stored price breakdown when the label itself does not carry it. Still code defensively — treat a missing submitted_index as "unknown row", never as row 0. One cross-surface caveat: these endpoints also return orders you created in the dashboard by CSV upload, and those report the CSV data row, counting from 1 after the header row(s) — not a zero-based array position, and not the spreadsheet line number. Branch on order_type if that distinction matters to your reconciliation.
reference string The reference you submitted for that row, echoed back. Always a string: empty if you did not send one, and empty rather than null if you sent an explicit JSON null (it is normalised at validate — see Send every field as its documented JSON type). Also usable for reconciliation via GET /orders/?reference=....
service_type string priority_mail or ground_advantage
status string Per-label outcome, attached once label generation has run: completed or failed — there is no cancelled value. Absent while the order is pending/processing, on a hard failed order (which reports the reason once, in order-level error_data), and on an order that was cancelled before processing — in all three the entry carries only index, submitted_index, reference and service_type, so read every other key with .get() semantics. An order cancelled after its labels were produced keeps the per-label results it already had, so its entries still report status: "completed" and a tracking number (see the callout above).
tracking_number string Carrier tracking number. Present only for labels that succeeded.
error string Human-readable failure reason on a label whose status is failed. Free text, not an error code — don't switch on it.
cancelled boolean Present and true only when support cancelled this individual label — which usually, but not always, came with a refund of its share (see the callout above; no refund flag or amount is exposed). Absent otherwise — read it with .get() semantics. A cancelled label keeps status: "completed" and its tracking_number (those record the label that was actually generated), so this is the only field that distinguishes it from a live shipment. Treat a label as shippable only when cancelled is absent.
cancelled_at string ISO 8601 timestamp of the cancellation. Present whenever cancelled is.
Download URLs exist only while the order is completed or partial. labels_pdf_url, csv_url and invoice_url are absent keys — not empty strings — for pending, processing, failed and cancelled orders, and they disappear again if the order is later refunded. Fetch and store the files while the order is completed rather than re-fetching them on demand, and read the URLs with .get() semantics. On a partial order the merged PDF and CSV contain only the labels that succeeded, so their page and row counts are smaller than labels.length — map pages back with labels[].status == "completed". The invoice is line-itemed the same way (“Shipping Labels (1 of 2)”).
Two order shapes come back from this endpoint. The bulk shape (order_type: "bulk") has labels[] and a merged labels_pdf_url. Everything this API's confirm creates has that shape — and so does every CSV bulk upload made in the dashboard, which runs through the same order-creation core. The single shape has no order_type and no labels[] — it carries order_details plus a singular label_pdf_url — and comes from single orders created outside this API: single orders placed in the dashboard, and historical orders imported into an account when it is migrated. So “created in the dashboard” does not imply the single shape; only the order's own order_type tells you. Both shapes are intermixed in List Orders, so branch on order_type == "bulk" before reading labels — and note that a dashboard CSV order's submitted_index is a 1-based CSV data row rather than a 0-based validate index (see Label Object).

Imported orders are the sparsest single shape. An import stores the migration marker rather than shipment details, so those orders come back completed with order_details.ship_from, ship_to and package as empty objects, order_details.service_type and order_details.tracking_number as empty strings, and an empty reference — while price and the download URLs are populated normally. Read every key of a single order defensively; an empty order_details is a migrated order, not an error.
Polling recommendation: Start polling every 2 seconds. Use exponential backoff (1.5x multiplier) up to a 30-second interval. Stop after 2 minutes and show a “check back later” message. For batches, use the Batch Status endpoint instead of polling each order individually.
curl https://app.shipzest.com/gateway/v1/orders/6615b1c2d3e4f5a6b7c8d9e0/ \
  -H "Authorization: Bearer sk_live_your_api_key_here"
import time

order_id = "6615b1c2d3e4f5a6b7c8d9e0"
interval = 2   # Start at 2 seconds
timeout = 120   # 2-minute timeout
elapsed = 0

while elapsed < timeout:
    response = requests.get(
        f"{BASE_URL}/orders/{order_id}/",
        headers=headers,
    )
    if response.status_code != 200:
        # On any error the envelope's "data" is null, so never unwrap it.
        # 429 and 503 are worth waiting out; 404 (wrong or foreign order id) and 401 never recover.
        # Branch on the status BEFORE parsing the body: a 503 raised by a
        # proxy in front of the API is HTML, not an envelope.
        if response.status_code in (429, 503):
            wait = int(response.headers.get("Retry-After", interval))
            time.sleep(wait)
            elapsed += wait
            continue
        code = response.json()["error"]["code"]
        raise SystemExit(f"{response.status_code} {code} - not retryable")
    data = response.json()["data"]

    # cancelled and refunded are terminal too — support can move the
    # order there at any time, and polling past them never completes.
    if data["status"] in ("completed", "partial"):
        # Bulk order (everything this API creates) vs a single order
        # created in the dashboard, which has no labels[].
        if data.get("order_type") == "bulk":
            for label in data["labels"]:
                # submitted_index is the row you sent. It is absent on legacy
                # orders — index is only a position inside this order,
                # so never print it as if it were your row number.
                where = (f"row {label['submitted_index']}"
                         if "submitted_index" in label
                         else f"position {label['index']}")
                print(f"  {where} {label['reference']}: "
                      f"{label.get('status')} {label.get('tracking_number', '')}")
            print(f"Labels PDF: {data.get('labels_pdf_url')}")
        else:
            print(f"Label PDF: {data.get('label_pdf_url')}")
        break
    elif data["status"] in ("failed", "cancelled", "refunded"):
        # error_data is carried only when the status is "failed" — on
        # cancelled/refunded the key is absent, not null. (The detail
        # endpoint always includes it; this one does not.)
        if data["status"] == "failed":
            print(f"failed: {data['error_data']}")
        else:
            print(data["status"])
        break

    time.sleep(interval)
    elapsed += interval
    interval = min(interval * 1.5, 30)
else:
    print("Timed out — check back later")
const orderId = "6615b1c2d3e4f5a6b7c8d9e0";
let interval = 2000;
const timeout = 120000;
let elapsed = 0;

while (elapsed < timeout) {
  const res = await fetch(
    `${BASE_URL}/orders/${orderId}/`,
    { headers: { "Authorization": `Bearer ${API_KEY}` } }
  );
  if (!res.ok) {
    // On any error the envelope's "data" is null, so never unwrap it.
    // 429 and 503 are worth waiting out; 404 (wrong or foreign order id) and 401 never recover.
    // Branch on the status BEFORE parsing the body: a 503 raised by a
    // proxy in front of the API is HTML, not an envelope.
    if (res.status === 429 || res.status === 503) {
      const wait = Number(res.headers.get("Retry-After") || interval / 1000) * 1000;
      await new Promise((r) => setTimeout(r, wait));
      elapsed += wait;
      continue;
    }
    const { error } = await res.json();
    throw new Error(`${res.status} ${error.code} - not retryable`);
  }
  const { data } = await res.json();

  // cancelled and refunded are terminal too — support can move the
  // order there at any time, and polling past them never completes.
  if (data.status === "completed" || data.status === "partial") {
    // Bulk order (everything this API creates) vs a single order
    // created in the dashboard, which has no labels[].
    if (data.order_type === "bulk") {
      data.labels.forEach((l) => {
        // submitted_index is the row you sent. It is absent on legacy
        // orders — index is only a position inside this order, so
        // never print it as if it were your row number.
        const where = l.submitted_index === undefined
          ? `position ${l.index}` : `row ${l.submitted_index}`;
        console.log(`  ${where} ${l.reference}: ${l.status} ${l.tracking_number || ""}`);
      });
      console.log("Labels PDF:", data.labels_pdf_url);
    } else {
      console.log("Label PDF:", data.label_pdf_url);
    }
    break;
  } else if (["failed", "cancelled", "refunded"].includes(data.status)) {
    // error_data is carried only when the status is "failed" — on
    // cancelled/refunded the key is absent, not null. (The detail
    // endpoint always includes it; this one does not.)
    if (data.status === "failed") console.log("failed:", data.error_data);
    else console.log(data.status);
    break;
  }

  await new Promise((r) => setTimeout(r, interval));
  elapsed += interval;
  interval = Math.min(interval * 1.5, 30000);
}
// The loop breaks on a terminal status, so reaching the budget means
// the order is still in flight — say so instead of exiting silently.
if (elapsed >= timeout) console.log("Timed out — check back later");
$orderId = "6615b1c2d3e4f5a6b7c8d9e0";
$interval = 2;
$timeout = 120;
$elapsed = 0;

while ($elapsed < $timeout) {
    $ch = curl_init("$baseUrl/orders/$orderId/");
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: Bearer $apiKey",
    ]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HEADER, true);   // keep the headers: Retry-After lives there
    $raw = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $headers = substr($raw, 0, $headerSize);
    if ($code !== 200) {
        // On any error the envelope's "data" is null, so never unwrap it.
        // 429 and 503 are worth waiting out; 404 (wrong or foreign order id) and 401 never recover.
        if ($code === 429 || $code === 503) {
            // Honour the server cooldown; fall back to our own interval.
            preg_match('/^Retry-After:\s*(\d+)/mi', $headers, $m);
            $wait = (int) ($m[1] ?? ceil($interval));
            sleep($wait);
            $elapsed += $wait;
            continue;
        }
        // Abort loudly: a shell wrapper must be able to tell this from success.
        // Decode only now: a 503 raised by a proxy in front of the API
        // is an HTML page, not an envelope, so json_decode() returns null.
        $body = json_decode(substr($raw, $headerSize), true);
        $errCode = $body["error"]["code"] ?? "non-JSON error body";
        fwrite(STDERR, "$code $errCode - not retryable\n");
        exit(1);
    }
    $body = json_decode(substr($raw, $headerSize), true);
    $data = $body["data"];

    // cancelled and refunded are terminal too — support can move the
    // order there at any time, and polling past them never completes.
    if (in_array($data["status"], ["completed", "partial"])) {
        // Bulk order (everything this API creates) vs a single order
        // created in the dashboard, which has no labels[].
        if (($data["order_type"] ?? "") === "bulk") {
            foreach ($data["labels"] as $l) {
                // submitted_index is the row you sent; absent on legacy
                // orders, where index is only a position in this order.
                $where = isset($l["submitted_index"])
                    ? "row {$l['submitted_index']}"
                    : "position {$l['index']}";
                echo "  $where {$l['reference']}: {$l['status']} " . ($l["tracking_number"] ?? "") . "\n";
            }
            echo "Labels PDF: " . ($data["labels_pdf_url"] ?? "") . "\n";
        } else {
            echo "Label PDF: " . ($data["label_pdf_url"] ?? "") . "\n";
        }
        break;
    } elseif (in_array($data["status"], ["failed", "cancelled", "refunded"])) {
        echo $data["status"] . "\n";
        break;
    }

    // $interval becomes a float on the second pass; sleep() takes an int,
    // so cast explicitly or PHP 8.1+ warns and truncates the wait.
    sleep((int) ceil($interval));
    $elapsed += $interval;
    $interval = min($interval * 1.5, 30);
}
// The loop breaks on a terminal status, so reaching the budget means
// the order is still in flight — say so instead of exiting silently.
if ($elapsed >= $timeout) {
    echo "Timed out — check back later\n";
}
order_id = "6615b1c2d3e4f5a6b7c8d9e0"
interval = 2
timeout = 120
elapsed = 0

while elapsed < timeout
  uri = URI("#{BASE_URL}/orders/#{order_id}/")
  req = Net::HTTP::Get.new(uri)
  req["Authorization"] = "Bearer #{API_KEY}"
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
  unless res.is_a?(Net::HTTPSuccess)
    # On any error the envelope's "data" is null, so never unwrap it.
    # 429 and 503 are worth waiting out; 404 (wrong or foreign order id) and 401 never recover.
    # Branch on the status BEFORE parsing the body: a 503 raised by a
    # proxy in front of the API is HTML, not an envelope.
    if ["429", "503"].include?(res.code)
      wait = (res["Retry-After"] || interval).to_i
      sleep(wait)
      elapsed += wait
      next
    end
    body = JSON.parse(res.body)
    abort("#{res.code} #{body['error']['code']} - not retryable")
  end
  data = JSON.parse(res.body)["data"]

  # cancelled and refunded are terminal too — support can move the
  # order there at any time, and polling past them never completes.
  if ["completed", "partial"].include?(data["status"])
    # Bulk order (everything this API creates) vs a single order
    # created in the dashboard, which has no labels[].
    if data["order_type"] == "bulk"
      data["labels"].each do |l|
        # submitted_index is the row you sent; absent on legacy
        # orders, where index is only a position in this order.
        where = l.key?("submitted_index") ? "row #{l['submitted_index']}" : "position #{l['index']}"
        puts "  #{where} #{l['reference']}: #{l['status']} #{l['tracking_number']}"
      end
      puts "Labels PDF: #{data['labels_pdf_url']}"
    else
      puts "Label PDF: #{data['label_pdf_url']}"
    end
    break
  elsif ["failed", "cancelled", "refunded"].include?(data["status"])
    # error_data is carried only when the status is "failed" — on
    # cancelled/refunded the key is absent, not null. (The detail
    # endpoint always includes it; this one does not.)
    if data["status"] == "failed"
      puts "failed: #{data['error_data']}"
    else
      puts data["status"]
    end
    break
  end

  sleep(interval)
  elapsed += interval
  interval = [interval * 1.5, 30].min
end

# The loop breaks on a terminal status, so reaching the budget means
# the order is still in flight — say so instead of exiting silently.
puts "Timed out — check back later" if elapsed >= timeout
var orderId = "6615b1c2d3e4f5a6b7c8d9e0";
var interval = 2000;
var timeout = 120000;
var elapsed = 0;

while (elapsed < timeout)
{
    var response = await client.GetAsync(
        $"{baseUrl}/orders/{orderId}/"
    );
    var json = await response.Content.ReadAsStringAsync();
    if (!response.IsSuccessStatusCode)
    {
        // On any error the envelope's "data" is null, so never unwrap it.
        // 429 and 503 are worth waiting out; 404 (wrong or foreign order id) and 401 never recover.
        // Branch on the status BEFORE parsing the body: a 503 raised by a
        // proxy in front of the API is HTML, not an envelope.
        if ((int)response.StatusCode == 429 || (int)response.StatusCode == 503)
        {
            var wait = (int)(response.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? interval);
            await Task.Delay(wait);
            elapsed += wait;
            continue;
        }
        using var errDoc = JsonDocument.Parse(json);
        var errCode = errDoc.RootElement.GetProperty("error").GetProperty("code").GetString();
        throw new Exception($"{(int)response.StatusCode} {errCode} - not retryable");
    }
    using var doc = JsonDocument.Parse(json);
    var status = doc.RootElement
        .GetProperty("data")
        .GetProperty("status")
        .GetString();

    // partial, cancelled and refunded are terminal too — polling
    // past them never reaches "completed".
    if (status == "completed" || status == "partial"
        || status == "failed" || status == "cancelled"
        || status == "refunded")
    {
        Console.WriteLine(json);
        break;
    }

    await Task.Delay(interval);
    elapsed += interval;
    interval = Math.Min((int)(interval * 1.5), 30000);
}

// The loop breaks on a terminal status, so reaching the budget means
// the order is still in flight — say so instead of exiting silently.
if (elapsed >= timeout) Console.WriteLine("Timed out — check back later");
Pending response
{
  "success": true,
  "data": {
    "order_id": "6615b1c2d3e4f5a6b7c8d9e0",
    "order_type": "bulk",
    "status": "pending",
    "label_count": 2,
    "created_at": "2025-04-10T15:16:18+00:00",
    "price": 16.50,
    // This is the two-row batch from Validate/Confirm above: nothing was
    // rejected, so submitted_index equals index. They diverge as soon as a
    // row is rejected — see "Reconciling with your own rows" below.
    "labels": [
      { "index": 0, "submitted_index": 0, "reference": "ORDER-001", "service_type": "priority_mail" },
      { "index": 1, "submitted_index": 1, "reference": "ORDER-002", "service_type": "ground_advantage" }
    ]
  },
  "error": null,
  "message": "OK"
}
Completed response (per-label tracking)
{
  "success": true,
  "data": {
    "order_id": "6615b1c2d3e4f5a6b7c8d9e0",
    "order_type": "bulk",
    "status": "completed",
    "label_count": 2,
    "created_at": "2025-04-10T15:16:18+00:00",
    "completed_at": "2025-04-10T15:16:42+00:00",
    "price": 16.50,
    // Nothing was rejected in this batch, so submitted_index equals
    // index. Reconcile on submitted_index anyway — it is the value that
    // stays correct when a row is rejected.
    "labels": [
      {
        "index": 0, "submitted_index": 0, "reference": "ORDER-001",
        "service_type": "priority_mail", "status": "completed",
        "tracking_number": "9400111899223100012345"
      },
      {
        "index": 1, "submitted_index": 1, "reference": "ORDER-002",
        "service_type": "ground_advantage", "status": "completed",
        "tracking_number": "9400111899223100067890"
      }
    ],
    "labels_pdf_url": "https://app.shipzest.com/storage/<token>/",
    "csv_url": "https://app.shipzest.com/storage/<token>/",
    "invoice_url": "https://app.shipzest.com/storage/<token>/"
  },
  "error": null,
  "message": "OK"
}
Partial response (some labels failed)
{
  "success": true,
  "data": {
    "order_id": "6615b1c2d3e4f5a6b7c8d9e0",
    "order_type": "bulk",
    "status": "partial",
    "label_count": 2,
    "created_at": "2025-04-10T15:16:18+00:00",
    "completed_at": "2025-04-10T15:16:42+00:00",
    "price": 16.50,
    // Nothing was rejected in this batch, so submitted_index equals
    // index. Reconcile on submitted_index anyway — it is the value that
    // stays correct when a row is rejected.
    "labels": [
      {
        "index": 0, "submitted_index": 0, "reference": "ORDER-001",
        "service_type": "priority_mail", "status": "completed",
        "tracking_number": "9400111899223100012345"
      },
      {
        "index": 1, "submitted_index": 1, "reference": "ORDER-002",
        "service_type": "ground_advantage", "status": "failed",
        "error": "Carrier rejected address"
      }
    ],
    // All three URL keys are set together; a value is "" when that
    // file was not produced. The merged PDF and CSV cover only the
    // labels whose status is "completed" — here 1 page, not 2.
    "labels_pdf_url": "https://app.shipzest.com/storage/<token>/",
    "csv_url": "https://app.shipzest.com/storage/<token>/",
    "invoice_url": "https://app.shipzest.com/storage/<token>/"
  },
  "error": null,
  "message": "OK"
}
Reconciling with your own rows (submitted_index)
// A different order: the one the three-row batch in the Confirm example
// created (batch_6615c4d5e6f7a8b9c0d1e2f3). Its middle row failed
// validation, so that response carries rejected[0].index == 1 with
// reference ORDER-002 and no label here claims 1 as a submitted row.
// The two labels sit at index 0 and 1 but
// came from submitted rows 0 and 2, so index is off by one from the
// second row on. Reconcile on submitted_index (or on reference),
// never on index.
{
  "success": true,
  "data": {
    "order_id": "6615b7c8d9e0f1a2b3c4d5e6",
    "order_type": "bulk",
    "status": "completed",
    "label_count": 2,
    "created_at": "2025-04-10T16:04:02+00:00",
    "completed_at": "2025-04-10T16:04:29+00:00",
    "price": 16.50,
    "labels": [
      {
        "index": 0, "submitted_index": 0, "reference": "ORDER-001",
        "service_type": "priority_mail", "status": "completed",
        "tracking_number": "9400111899223100024680"
      },
      {
        "index": 1, "submitted_index": 2, "reference": "ORDER-003",
        "service_type": "ground_advantage", "status": "completed",
        "tracking_number": "9400111899223100013579"
      }
    ],
    "labels_pdf_url": "https://app.shipzest.com/storage/<token>/",
    "csv_url": "https://app.shipzest.com/storage/<token>/",
    "invoice_url": "https://app.shipzest.com/storage/<token>/"
  },
  "error": null,
  "message": "OK"
}

Get Batch Status

Poll the status of a confirmed batch's bulk order. A confirmed batch maps to exactly one bulk order, so this returns that order's status and its full per-label serialization under order. Equivalent to GET /gateway/v1/orders/{order_id}/ for the batch's order.

order can be null. Four states produce it, and the response then carries the validation summary object instead:
  • status: "pending" — the batch has not been confirmed yet.
  • status: "pending" — the batch was never confirmed and has now passed its 15-minute expiry. This endpoint does not check expiry, so an expired batch keeps answering pending until the hourly cleanup deletes it and the path starts answering 404 — and the response carries no expires_at to tell you apart from a live batch. pending on a batch you never successfully confirmed can therefore be terminal: POST /orders/confirm/{batch_id} would now answer 400 BATCH_EXPIRED. Bound your poll (the samples use a 5-minute budget); if you never saw a 200 or a 409 from confirm, reconcile with GET /orders/?reference=... and validate a new batch.
  • status: "pending" — a confirm is still in flight, or was interrupted mid-create. This is indistinguishable from the two cases above on this endpoint. Never read it as “my confirm never landed”: keep polling, or retry the same batch_id (a 409 proves it landed). Do not re-validate the rows. See the 409 example under Confirm Orders.

    Polling this state resolves on its own. If the interrupted confirm did create an order, the next call to this endpoint finds it and starts reporting its real order_id — you do not have to reconcile by reference to discover that. If it created nothing, the batch becomes confirmable again a few minutes later, and replaying POST /orders/confirm/{batch_id} then returns 200 with a new order instead of 409. So the two correct responses to this state are keep polling and retry the same batch_id — in either order, and neither can double-charge you.
  • status: "completed" — the confirm accepted zero labels (every row failed validation). This is terminal: no order will ever appear. Read summary to see why.
Decide on status and guard on order != null only before reading order.labels — a loop that waits for order to appear never terminates on the confirmed-empty case.
GET /gateway/v1/batches/{batch_id}/

Order Statuses

StatusMeaning
pending With an order: the bulk order is created and waiting to be picked up for label generation. With order: null: the batch is unconfirmed, or a confirm is still in flight or was interrupted mid-create. Do not read order: null as “my confirm never landed” — the order row and its balance hold are created a moment before the batch record learns the order_id, so a real, charged order may already exist while this still reports null. Keep polling, or replay the same batch_id (a 409 means it is already claimed). Never re-validate the rows on the strength of this state: that creates a second bulk order and charges you twice
processing The labels are being generated
completed All labels completed successfully — or, with order: null, the confirm accepted zero labels and no order was created (still terminal)
partial Some labels completed, some failed
failed All labels failed
cancelled Support cancelled the order — terminal, stop polling
refunded Support refunded a completed/partial order — terminal, stop polling
Support actions: an order can move to cancelled (from pending/processing) or refunded (from completed/partial) at any time. Pollers should treat both as terminal alongside completed, partial, and failed.
curl https://app.shipzest.com/gateway/v1/batches/batch_6615a3f2e4b0c1d2e3f4a5b6/ \
  -H "Authorization: Bearer sk_live_your_api_key_here"
import time

batch_id = "batch_6615a3f2e4b0c1d2e3f4a5b6"
interval = 5   # 5-second fixed interval for batches
timeout = 300   # 5-minute timeout
elapsed = 0

while elapsed < timeout:
    response = requests.get(
        f"{BASE_URL}/batches/{batch_id}/",
        headers=headers,
    )
    if response.status_code != 200:
        # On any error the envelope's "data" is null, so never unwrap it.
        # 429 and 503 are worth waiting out; 404 means the batch record was purged or unknown - reconcile with
        # GET /orders/?reference=... instead of re-validating - and 401 never recovers.
        # Branch on the status BEFORE parsing the body: a 503 raised by a
        # proxy in front of the API is HTML, not an envelope.
        if response.status_code in (429, 503):
            wait = int(response.headers.get("Retry-After", interval))
            time.sleep(wait)
            elapsed += wait
            continue
        code = response.json()["error"]["code"]
        raise SystemExit(f"{response.status_code} {code} - not retryable")
    data = response.json()["data"]

    # Decide on the status. order is None for an unconfirmed batch
    # (keep polling) AND for a confirmed-empty one, which is terminal.
    if data["status"] in (
        "completed", "partial", "failed", "cancelled", "refunded"
    ):
        print(f"Batch {data['status']}:")
        if data["order"]:
            for label in data["order"]["labels"]:
                # submitted_index is the row you sent; absent on legacy
                # orders, where index is only a position in this order.
                where = (f"row {label['submitted_index']}"
                         if "submitted_index" in label
                         else f"position {label['index']}")
                print(f"  {where} "
                      f"{label['reference']}: {label.get('status')} "
                      f"{label.get('tracking_number', '')}")
        else:
            print("  confirm created no labels —", data["summary"])
        break

    time.sleep(interval)
    elapsed += interval
else:
    print("Timed out — check back later")
const batchId = "batch_6615a3f2e4b0c1d2e3f4a5b6";
const interval = 5000;
const timeout = 300000;
let elapsed = 0;

while (elapsed < timeout) {
  const res = await fetch(
    `${BASE_URL}/batches/${batchId}/`,
    { headers: { "Authorization": `Bearer ${API_KEY}` } }
  );
  if (!res.ok) {
    // On any error the envelope's "data" is null, so never unwrap it.
    // 429 and 503 are worth waiting out; 404 means the batch record was purged or unknown - reconcile with
    // GET /orders/?reference=... instead of re-validating - and 401 never recovers.
    // Branch on the status BEFORE parsing the body: a 503 raised by a
    // proxy in front of the API is HTML, not an envelope.
    if (res.status === 429 || res.status === 503) {
      const wait = Number(res.headers.get("Retry-After") || interval / 1000) * 1000;
      await new Promise((r) => setTimeout(r, wait));
      elapsed += wait;
      continue;
    }
    const { error } = await res.json();
    throw new Error(`${res.status} ${error.code} - not retryable`);
  }
  const { data } = await res.json();

  // Decide on the status. order is null for an unconfirmed batch
  // (keep polling) AND for a confirmed-empty one, which is terminal.
  if (["completed", "partial", "failed",
      "cancelled", "refunded"].includes(data.status)) {
    if (data.order) {
      console.log(`Batch ${data.status}:`, data.order.labels);
    } else {
      console.log("confirm created no labels —", data.summary);
    }
    break;
  }

  await new Promise((r) => setTimeout(r, interval));
  elapsed += interval;
}

// Reaching the budget means the batch is still in flight.
if (elapsed >= timeout) console.log("Timed out — check back later");
$batchId = "batch_6615a3f2e4b0c1d2e3f4a5b6";
$interval = 5;
$timeout = 300;
$elapsed = 0;

while ($elapsed < $timeout) {
    $ch = curl_init("$baseUrl/batches/$batchId/");
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: Bearer $apiKey",
    ]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HEADER, true);   // keep the headers: Retry-After lives there
    $raw = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $headers = substr($raw, 0, $headerSize);
    if ($code !== 200) {
        // On any error the envelope's "data" is null, so never unwrap it.
        // 429 and 503 are worth waiting out; 404 means the batch record was purged or unknown - reconcile with
        // GET /orders/?reference=... instead of re-validating - and 401 never recovers.
        if ($code === 429 || $code === 503) {
            // Honour the server cooldown; fall back to our own interval.
            preg_match('/^Retry-After:\s*(\d+)/mi', $headers, $m);
            $wait = (int) ($m[1] ?? ceil($interval));
            sleep($wait);
            $elapsed += $wait;
            continue;
        }
        // Abort loudly: a shell wrapper must be able to tell this from success.
        // Decode only now: a 503 raised by a proxy in front of the API
        // is an HTML page, not an envelope, so json_decode() returns null.
        $body = json_decode(substr($raw, $headerSize), true);
        $errCode = $body["error"]["code"] ?? "non-JSON error body";
        fwrite(STDERR, "$code $errCode - not retryable\n");
        exit(1);
    }
    $body = json_decode(substr($raw, $headerSize), true);
    $data = $body["data"];

    // Decide on the status. order is null for an unconfirmed batch
    // (keep polling) AND for a confirmed-empty one, which is terminal.
    if (in_array($data["status"],
            ["completed", "partial", "failed", "cancelled", "refunded"])) {
        echo "Batch {$data['status']}\n";
        if ($data["order"] !== null) {
            print_r($data["order"]["labels"]);
        } else {
            echo "  confirm created no labels\n";
            print_r($data["summary"]);
        }
        break;
    }

    sleep($interval);
    $elapsed += $interval;
}

// Reaching the budget means the batch is still in flight.
if ($elapsed >= $timeout) {
    echo "Timed out — check back later\n";
}
batch_id = "batch_6615a3f2e4b0c1d2e3f4a5b6"
interval = 5
timeout = 300
elapsed = 0

while elapsed < timeout
  uri = URI("#{BASE_URL}/batches/#{batch_id}/")
  req = Net::HTTP::Get.new(uri)
  req["Authorization"] = "Bearer #{API_KEY}"
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
  unless res.is_a?(Net::HTTPSuccess)
    # On any error the envelope's "data" is null, so never unwrap it.
    # 429 and 503 are worth waiting out; 404 means the batch record was purged or unknown - reconcile with
    # GET /orders/?reference=... instead of re-validating - and 401 never recovers.
    # Branch on the status BEFORE parsing the body: a 503 raised by a
    # proxy in front of the API is HTML, not an envelope.
    if ["429", "503"].include?(res.code)
      wait = (res["Retry-After"] || interval).to_i
      sleep(wait)
      elapsed += wait
      next
    end
    body = JSON.parse(res.body)
    abort("#{res.code} #{body['error']['code']} - not retryable")
  end
  data = JSON.parse(res.body)["data"]

  # Decide on the status. order is nil for an unconfirmed batch
  # (keep polling) AND for a confirmed-empty one, which is terminal.
  if ["completed", "partial", "failed",
      "cancelled", "refunded"].include?(data["status"])
    if data["order"]
      puts "Batch #{data['status']}: #{data['order']['labels']}"
    else
      puts "Batch #{data['status']}: confirm created no labels — #{data['summary']}"
    end
    break
  end

  sleep(interval)
  elapsed += interval
end

# Reaching the budget means the batch is still in flight.
puts "Timed out — check back later" if elapsed >= timeout
var batchId = "batch_6615a3f2e4b0c1d2e3f4a5b6";
var interval = 5000;
var timeout = 300000;
var elapsed = 0;

while (elapsed < timeout)
{
    var response = await client.GetAsync(
        $"{baseUrl}/batches/{batchId}/"
    );
    var json = await response.Content.ReadAsStringAsync();
    if (!response.IsSuccessStatusCode)
    {
        // On any error the envelope's "data" is null, so never unwrap it.
        // 429 and 503 are worth waiting out; 404 means the batch record was purged or unknown - reconcile with
        // GET /orders/?reference=... instead of re-validating - and 401 never recovers.
        // Branch on the status BEFORE parsing the body: a 503 raised by a
        // proxy in front of the API is HTML, not an envelope.
        if ((int)response.StatusCode == 429 || (int)response.StatusCode == 503)
        {
            var wait = (int)(response.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? interval);
            await Task.Delay(wait);
            elapsed += wait;
            continue;
        }
        using var errDoc = JsonDocument.Parse(json);
        var errCode = errDoc.RootElement.GetProperty("error").GetProperty("code").GetString();
        throw new Exception($"{(int)response.StatusCode} {errCode} - not retryable");
    }
    using var doc = JsonDocument.Parse(json);
    var status = doc.RootElement
        .GetProperty("data")
        .GetProperty("status")
        .GetString();

    // cancelled and refunded are terminal too — support can move the
    // order there at any time.
    if (status == "completed" || status == "partial"
        || status == "failed" || status == "cancelled"
        || status == "refunded")
    {
        Console.WriteLine(json);
        break;
    }

    await Task.Delay(interval);
    elapsed += interval;
}

// Reaching the budget means the batch is still in flight.
if (elapsed >= timeout) Console.WriteLine("Timed out — check back later");
200 — Batch status response
{
  "success": true,
  "data": {
    "batch_id": "batch_6615a3f2e4b0c1d2e3f4a5b6",
    "order_id": "6615b1c2d3e4f5a6b7c8d9e0",
    "status": "completed",
    "order": {
      "order_id": "6615b1c2d3e4f5a6b7c8d9e0",
      "order_type": "bulk",
      "status": "completed",
      "label_count": 2,
      "created_at": "2025-04-10T15:16:18+00:00",
      "completed_at": "2025-04-10T15:16:42+00:00",
      "price": 16.50,
      "labels": [
        { "index": 0, "submitted_index": 0, "reference": "ORDER-001", "service_type": "priority_mail", "status": "completed", "tracking_number": "9400111899223100012345" },
        { "index": 1, "submitted_index": 1, "reference": "ORDER-002", "service_type": "ground_advantage", "status": "completed", "tracking_number": "9400111899223100067890" }
      ],
      "labels_pdf_url": "https://app.shipzest.com/storage/<token>/",
      "csv_url": "https://app.shipzest.com/storage/<token>/",
      "invoice_url": "https://app.shipzest.com/storage/<token>/"
    }
  },
  "error": null,
  "message": "OK"
}
200 — Unconfirmed batch (order is null)
// Batch not confirmed yet, confirm still in flight, or confirmed with
// zero accepted labels. order_id and order are null; the validation
// summary is returned instead. status: "pending" = unconfirmed OR a
// confirm is in flight (keep polling, do NOT re-validate);
// "completed" = confirmed with zero labels — terminal, nothing was
// created or charged.
//
// A third batch, not the one above: this one validated two rows and one
// of them failed, so its summary differs from the all-passed example.
{
  "success": true,
  "data": {
    "batch_id": "batch_6615d7e8f9a0b1c2d3e4f5a6",
    "order_id": null,
    "status": "pending",
    "summary": {
      "total": 2,
      "passed": 1,
      "corrected": 0,
      "failed": 1,
      "estimated_total_price": 8.25
    },
    "order": null
  },
  "error": null,
  "message": "OK"
}

List Orders

Retrieve a paginated list of your orders with optional filters. Useful for building order history views or syncing data to your system.

GET /gateway/v1/orders/

Query Parameters

ParameterTypeDescription
pageoptional integer Page number (default: 1, minimum 1). 0 and negative values are clamped to 1, and a non-numeric value falls back to 1 — neither is rejected. A page past the end is not an error: it returns the real total with an empty items array.
per_pageoptional integer Results per page (default: 20, max: 100, minimum 1). Out-of-range values are clamped silently: 1000 becomes 100, and 0 or a negative number becomes 1 — it is not read as “no limit”, so ?per_page=0 returns exactly one order per page. A non-numeric value falls back to 20.
statusoptional string Filter by status. Exactly these seven values, lowercase: pending, processing, completed, partial, failed, cancelled, refunded. COMPLETED or complete are not matched — see the note below the table.
sort_byoptional string created_at, status, or updated_at (default: created_at)
sort_orderoptional string asc or desc (default: desc)
date_fromoptional string Start date, YYYY-MM-DD, interpreted as UTC midnight (e.g. 2025-04-01). Send the date only — no time component. Filters on the order's created_at
date_tooptional string End date, YYYY-MM-DD (UTC), inclusive of the whole day. Send the date only: a value with a time component is treated as that instant plus 24 hours, which silently widens your window by a day. Filters on the order's created_at — there is no completed_at-based filter, so “orders completed last week” is not expressible here: an order created on the 24th and completed on the 25th falls inside date_from=2026-07-24&date_to=2026-07-24 and outside date_from=2026-07-25. Filter by creation date and check completed_at yourself
referenceoptional string Search by your reference ID. Partial, case-insensitive match. Matches the per-label references of gateway bulk orders (labels[].reference) as well as the top-level reference of dashboard-created single orders.
Bad filter values are ignored, not rejected. An unrecognised status (wrong case, a plural, a typo), an unparseable date_from/date_to, and an unknown sort_by/sort_order are all dropped: the request still returns 200, but unfiltered and default-sorted. ?status=COMPLETED therefore returns your whole order history rather than an error or an empty page. page and per_page are handled the same way — a non-numeric value falls back to 1 / 20 and an out-of-range one is clamped (see the table above) — so a broken pagination variable is silently absorbed too. Validate filter values client-side, and sanity-check total and per_page in the response against what you expected.
The list contains every order on your account — not only gateway-created ones. Single orders created from the dashboard appear too, with a different item shape: no order_type, label_count, or labels[]; instead a top-level reference and (once completed) an order_details object with ship_from, ship_to, package, service_type, and tracking_number. Branch on order_type == "bulk" before reading labels[].
# Completed orders since a date (YYYY-MM-DD, UTC — no time component).
# GNU date first, BSD/macOS date as the fallback. If the substitution
# fails you send date_from= , which is silently ignored (see the callout
# below) and quietly returns your whole order history instead of 7 days.
from=$(date -u -d '7 days ago' +%F 2>/dev/null || date -u -v-7d +%F)
curl "https://app.shipzest.com/gateway/v1/orders/?status=completed&date_from=$from&per_page=50" \
  -H "Authorization: Bearer sk_live_your_api_key_here"

# Reconcile one of your own references (partial, case-insensitive) —
# this is the lookup to use when a batch_id has been pruned
curl "https://app.shipzest.com/gateway/v1/orders/?reference=ORDER-001" \
  -H "Authorization: Bearer sk_live_your_api_key_here"
from datetime import datetime, timedelta, timezone

response = requests.get(
    f"{BASE_URL}/orders/",
    headers=headers,
    params={
        "status": "completed",   # exact lowercase value or it is ignored
        # UTC, like the cURL tab's `date -u`: the API reads this as UTC
        # midnight, so a local date would shift the window by a day.
        "date_from": (datetime.now(timezone.utc).date() - timedelta(days=7)).isoformat(),
        "per_page": 50,
    },
)

data = response.json()["data"]   # pagination lives under "data"
print(f"Total: {data['total']}, Page: {data['page']}")
for order in data["items"]:
    print(f"  {order['order_id']}{order['status']}")

# Reconcile by your own reference (partial, case-insensitive). This is
# the lookup that replaces batch_id once a batch has been pruned.
found = requests.get(
    f"{BASE_URL}/orders/", headers=headers,
    params={"reference": "ORDER-001"},
).json()["data"]
print(f"{found['total']} order(s) carry that reference")
const weekAgo = new Date(Date.now() - 7 * 86400000)
  .toISOString().slice(0, 10);   // YYYY-MM-DD, no time part

const params = new URLSearchParams({
  status: "completed",   // exact lowercase value or it is ignored
  date_from: weekAgo,
  per_page: "50",
});

const response = await fetch(
  `${BASE_URL}/orders/?${params}`,
  { headers: { "Authorization": `Bearer ${API_KEY}` } }
);

const { data } = await response.json(); // pagination lives under "data"
console.log(`Total: ${data.total}, Page: ${data.page}`);
data.items.forEach((o) =>
  console.log(`  ${o.order_id}${o.status}`)
);
$query = http_build_query([
    "status" => "completed",   // exact lowercase value or it is ignored
    // gmdate(), not date(): the API reads this as UTC midnight, and
    // date() would use whatever date.timezone php.ini happens to set.
    "date_from" => gmdate("Y-m-d", strtotime("-7 days")),
    "per_page" => 50,
]);

$ch = curl_init("$baseUrl/orders/?$query");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer $apiKey",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

// pagination lives under "data"
$data = json_decode(curl_exec($ch), true)["data"];
echo "Total: {$data['total']}, Page: {$data['page']}\n";
foreach ($data["items"] as $order) {
    echo "  {$order['order_id']}{$order['status']}\n";
}
require "date"   # date is not loaded by default — Quick Start requires only net/http + json

uri = URI("#{BASE_URL}/orders/")
uri.query = URI.encode_www_form(
  status: "completed",   # exact lowercase value or it is ignored
  # UTC, like the cURL tab's `date -u` — the API reads this as UTC midnight.
  date_from: (Time.now.utc.to_date - 7).iso8601,
  per_page: 50
)

req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{API_KEY}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }

data = JSON.parse(res.body)["data"]  # pagination lives under "data"
puts "Total: #{data['total']}, Page: #{data['page']}"
data["items"].each { |o| puts "  #{o['order_id']}#{o['status']}" }
var response = await client.GetAsync(
    $"{baseUrl}/orders/?status=completed"
    + $"&date_from={DateTime.UtcNow.AddDays(-7):yyyy-MM-dd}&per_page=50"
);

var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(json);
200 — Paginated response
// Standard envelope: items/total/page/per_page live under "data".
// The list holds ALL of your orders. Gateway-created orders are bulk
// (order_type: "bulk") and carry the per-label breakdown; dashboard-
// created single orders have no order_type/labels — branch on
// order_type before reading labels[]. File URLs are omitted from
// list responses.
{
  "success": true,
  "data": {
    "items": [
      {
        "order_id": "6615b1c2d3e4f5a6b7c8d9e0",
        "order_type": "bulk",
        "status": "completed",
        "label_count": 2,
        "created_at": "2025-04-10T15:16:18+00:00",
        "completed_at": "2025-04-10T15:16:42+00:00",
        "price": 16.50,
        "labels": [
          { "index": 0, "submitted_index": 0, "reference": "ORDER-001", "service_type": "priority_mail", "status": "completed", "tracking_number": "9400111899223100012345" },
          { "index": 1, "submitted_index": 1, "reference": "ORDER-002", "service_type": "ground_advantage", "status": "completed", "tracking_number": "9400111899223100067890" }
        ]
      },
      {
        // Dashboard-created single order — different shape
        "order_id": "6615c9d8e7f6a5b4c3d2e1f0",
        "reference": "WEB-1042",
        "status": "completed",
        "created_at": "2025-04-09T11:02:44+00:00",
        "completed_at": "2025-04-09T11:03:01+00:00",
        "price": 5.50,
        "order_details": {
          "ship_from": { /* ... */ },
          "ship_to": { /* ... */ },
          "package": { /* ... */ },
          "service_type": "ground_advantage",
          "tracking_number": "9400111899223100054321"
        }
      }
    ],
    "total": 47,
    "page": 1,
    "per_page": 50
  },
  "error": null,
  "message": "OK"
}

Retrieve Past Order

Retrieve full details for any order, including the per-label breakdown, tracking numbers, file download URLs, and error data.

For bulk orders — everything this API creates — this returns exactly what GET /orders/{order_id}/ returns plus error_data, which the status endpoint only returns on a failed order. On a failed order the two responses are therefore identical. It adds no tracking, no file URLs and no per-label data that the status endpoint withholds, and the download URLs are still gated on completed/partial. Use it when you want the label-generation error alongside the label breakdown. For dashboard-created single orders it does more: those get their order_details object at any status, which the status endpoint only returns once the order is complete.

GET /gateway/v1/orders/{order_id}/details

Response Includes

  • labels — Per-label breakdown: index, submitted_index, reference, service_type, and (once processed) tracking_number, status, and error. See Label Object
  • labels_pdf_url, csv_url, invoice_url — Top-level secure, time-limited download URLs (24-hour expiry by default; a window we set) for the merged label PDF, manifest CSV, and invoice. Present only while the order is completed or partial — the keys are absent entirely for every other status, including after a refund, so download and store the files while you have them. These links are self-authorizing: GET them with no Authorization header — they open directly in a browser and can be handed to an end user. Treat the URL itself as a secret: anyone holding it can download the file until it expires. They are also not envelope endpoints — an expired, tampered or unknown token returns a bare 404, and a storage failure a bare 503, both with a zero-byte body and no error.code to parse. Re-fetch the order to mint a fresh link.
  • error_data — The label-generation error on a failed order: {code, message, details}, null otherwise. Its code comes from a separate vocabulary — see Label Generation Errors
Gateway orders are bulk orders (order_type: "bulk"), so tracking numbers live per-label inside labels[] and file URLs are top-level (there is no order_details or files wrapper object). Orders created outside the gateway (dashboard single orders) can also be retrieved here: those have no order_type or labels[] — instead a top-level reference, an order_details object (ship_from, ship_to, package, service_type, tracking_number), and label_pdf_url / invoice_url / csv_url download links.
curl https://app.shipzest.com/gateway/v1/orders/6615b1c2d3e4f5a6b7c8d9e0/details \
  -H "Authorization: Bearer sk_live_your_api_key_here"
order_id = "6615b1c2d3e4f5a6b7c8d9e0"

response = requests.get(
    f"{BASE_URL}/orders/{order_id}/details",
    headers=headers,
)

data = response.json()["data"]
print(f"Status: {data['status']}")

# Two shapes: bulk orders (everything this API creates) carry labels[];
# dashboard-created single orders carry order_details instead.
if data.get("order_type") == "bulk":
    for label in data["labels"]:
        # submitted_index is the row you sent; absent on legacy
        # orders, where index is only a position in this order.
        where = (f"row {label['submitted_index']}"
                 if "submitted_index" in label
                 else f"position {label['index']}")
        print(f"  {where} "
              f"{label['reference']}: {label.get('tracking_number', '')}")
    # Absent unless the order is completed or partial.
    if data.get("labels_pdf_url"):
        print(f"Labels PDF: {data['labels_pdf_url']}")
else:
    details = data.get("order_details", {})
    print(f"  {data.get('reference', '')}: {details.get('tracking_number', '')}")
    if data.get("label_pdf_url"):
        print(f"Label PDF: {data['label_pdf_url']}")
const orderId = "6615b1c2d3e4f5a6b7c8d9e0";

const response = await fetch(
  `${BASE_URL}/orders/${orderId}/details`,
  { headers: { "Authorization": `Bearer ${API_KEY}` } }
);

const { data } = await response.json();
console.log("Status:", data.status);

// Two shapes: bulk orders (everything this API creates) carry labels[];
// dashboard-created single orders carry order_details instead.
if (data.order_type === "bulk") {
  data.labels.forEach((l) => {
    // submitted_index is the row you sent; absent on legacy orders,
    // where index is only a position inside this order.
    const where = l.submitted_index === undefined
      ? `position ${l.index}` : `row ${l.submitted_index}`;
    console.log(`  ${where} ${l.reference}: ${l.tracking_number || ""}`);
  });
  // Absent unless the order is completed or partial.
  if (data.labels_pdf_url) console.log("Labels PDF:", data.labels_pdf_url);
} else {
  console.log(`  ${data.reference ?? ""}: ${data.order_details?.tracking_number ?? ""}`);
  if (data.label_pdf_url) console.log("Label PDF:", data.label_pdf_url);
}
$orderId = "6615b1c2d3e4f5a6b7c8d9e0";

$ch = curl_init("$baseUrl/orders/$orderId/details");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer $apiKey",
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$data = json_decode(curl_exec($ch), true)["data"];
echo "Status: {$data['status']}\n";

// Two shapes: bulk orders (everything this API creates) carry labels[];
// dashboard-created single orders carry order_details instead.
if (($data["order_type"] ?? "") === "bulk") {
    foreach ($data["labels"] as $l) {
        // pending/failed labels have no tracking_number yet;
        // submitted_index is absent on legacy orders.
        $where = isset($l["submitted_index"])
            ? "row {$l['submitted_index']}"
            : "position {$l['index']}";
        echo "  $where {$l['reference']}: " . ($l["tracking_number"] ?? "") . "\n";
    }
    // Absent unless the order is completed or partial.
    if (isset($data["labels_pdf_url"])) {
        echo "Labels PDF: {$data['labels_pdf_url']}\n";
    }
} else {
    $details = $data["order_details"] ?? [];
    echo "  " . ($data["reference"] ?? "") . ": " . ($details["tracking_number"] ?? "") . "\n";
    if (isset($data["label_pdf_url"])) {
        echo "Label PDF: {$data['label_pdf_url']}\n";
    }
}
order_id = "6615b1c2d3e4f5a6b7c8d9e0"

uri = URI("#{BASE_URL}/orders/#{order_id}/details")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{API_KEY}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }

data = JSON.parse(res.body)["data"]
puts "Status: #{data['status']}"

# Two shapes: bulk orders (everything this API creates) carry labels[];
# dashboard-created single orders carry order_details instead.
if data["order_type"] == "bulk"
  data["labels"].each do |l|
    # submitted_index is absent on legacy orders.
    where = l.key?("submitted_index") ? "row #{l['submitted_index']}" : "position #{l['index']}"
    puts "  #{where} #{l['reference']}: #{l['tracking_number']}"
  end
  # Absent unless the order is completed or partial.
  puts "Labels PDF: #{data['labels_pdf_url']}" if data["labels_pdf_url"]
else
  details = data["order_details"] || {}
  puts "  #{data['reference']}: #{details['tracking_number']}"
  puts "Label PDF: #{data['label_pdf_url']}" if data["label_pdf_url"]
end
var orderId = "6615b1c2d3e4f5a6b7c8d9e0";

var response = await client.GetAsync(
    $"{baseUrl}/orders/{orderId}/details"
);

var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(json);
200 — Full detail response
{
  "success": true,
  "data": {
    "order_id": "6615b1c2d3e4f5a6b7c8d9e0",
    "order_type": "bulk",
    "status": "completed",
    "label_count": 2,
    "created_at": "2025-04-10T15:16:18+00:00",
    "completed_at": "2025-04-10T15:16:42+00:00",
    "price": 16.50,
    "labels": [
      { "index": 0, "submitted_index": 0, "reference": "ORDER-001", "service_type": "priority_mail", "status": "completed", "tracking_number": "9400111899223100012345" },
      { "index": 1, "submitted_index": 1, "reference": "ORDER-002", "service_type": "ground_advantage", "status": "completed", "tracking_number": "9400111899223100067890" }
    ],
    "labels_pdf_url": "https://app.shipzest.com/storage/<token>/",
    "csv_url": "https://app.shipzest.com/storage/<token>/",
    "invoice_url": "https://app.shipzest.com/storage/<token>/",
    "error_data": null
  },
  "error": null,
  "message": "OK"
}

Two-Phase Ordering

The Gateway API uses a validate-then-confirm workflow. This gives you a chance to review corrected addresses, check pricing, and handle errors before any credits are deducted.

Workflow

  1. ValidatePOST /orders/validate with your orders. Each order is individually validated (address, dimensions, pricing, dedup). You get back a batch_id and per-order results. Note that the dedup step compares each row only against previously created single (dashboard) orders: rows within one batch are not compared with each other, and orders this API creates are bulk orders that are never matched — so deduplicate on your side (see Validate Orders).
  2. Review — Check the response. Fix any failed orders. Review corrected addresses. Verify the estimated_total_price.
  3. ConfirmPOST /orders/confirm/{batch_id}. All passed and corrected rows are created as a single bulk order, charged once. You get back one order_id; rows that failed validation are returned in rejected.
  4. Poll — Use GET /orders/{order_id}/ or GET /batches/{batch_id}/ to check when labels are ready and read per-label tracking numbers.
  5. Download — When an order is completed or partial, the response includes secure, time-limited download URLs for the merged label PDF, manifest CSV and invoice (24-hour expiry by default; a window we set). partial is the normal outcome for a bulk order in which some labels failed — you have been charged for the labels that succeeded, so those files are yours to download. Fetch them with a plain GET and no Authorization header — the token in the URL is the credential, so treat the link as a secret. Download links are not envelope endpoints: an expired, tampered or unknown token returns a bare 404 and a storage failure a bare 503, both with an empty body and no error.code. Re-fetch the order to mint a fresh link.
Tip: If an order is corrected, the corrected addresses are automatically applied when you confirm. You do not need to resubmit with corrected data.

Batch Expiry

Validated batches expire after 15 minutes. If the batch expires before you confirm, you will receive a BATCH_EXPIRED error. Simply re-validate your orders. Expiry applies only to batches that were never confirmed: once a batch has been confirmed, every further confirm of it returns 409 BATCH_ALREADY_CONFIRMED — the 15-minute deadline never overrides that — so a retry after a network timeout does not produce a second order for as long as the batch record exists (see Batch Retention below). Pricing is resolved again at confirm using the same pricing rules, so in practice the charged total matches the validate quote — unless your account's pricing configuration changes between validate and confirm, in which case the price current at confirm time applies.

Batch Retention

Batch records are not kept forever, and the 409 idempotency signal lasts exactly as long as the record does. A background job prunes them: batches that were never confirmed, once they are past their 15-minute expiry, and batches a confirm has claimed, 24 hours after validation (the default retention window; we set it and can change it). The 24-hour rule covers a confirm that was interrupted mid-create as well as one that finished — that is the case where you most need the 409, because the order may exist and be charged. A confirm that failed outright with a top-level 4xx released its claim and created nothing, so its batch is back under the 15-minute rule. Once a batch has been pruned, both POST /orders/confirm/{batch_id} and GET /batches/{batch_id}/ return 404 RESOURCE_NOT_FOUND.

That 404 says only that the batch is gone — the order it created is still there, with its labels, tracking numbers and charge. Never read a 404 on a batch you believe you confirmed as "my confirm never landed". Look the order up by your own reference instead (GET /gateway/v1/orders/?reference=YOUR-REFERENCE, which matches the per-label references of bulk orders) and only re-validate rows that produced no order. Keep any confirm-retry logic inside the retention window, and record the order_id you get back so you never have to depend on the batch record at all.

Failed Orders Are Terminal

If an order moves to failed status after confirmation (no label could be generated), it cannot be retried or reprocessed. You must create a new order. You are only charged for labels that are successfully generated: a failed order is never charged, so there is no refund transaction to reconcile. For partial bulk orders you are charged only for the labels that succeeded.

Validation Results

Each order in the validation response has a result field with one of three values:

ResultMeaningAction
passed All validation passed. Check verification.*.status: verified means the address was confirmed as-is; pending / skipped mean the address was accepted without verification. Ready to confirm
corrected Address was auto-corrected by the verification service Review the verification field, then confirm
failed Validation failed — see error and details Fix the issue and re-validate the order

When you confirm a batch, only passed and corrected orders are created. Failed orders are skipped. You can include fixed versions of failed orders in a new validation batch.

Package Requirements

All package dimensions and weight are validated against USPS limits. These constraints are enforced at validation time to prevent errors at label generation.

Maximum weight and maximum volume are service settings we control, not fixed constants — we can raise or lower either one, they are not yours to set, and no endpoint reports the effective value. The WEIGHT_EXCEEDED and VOLUME_EXCEEDED messages always quote the limit in force (“Package weight cannot exceed 1120 oz”, “Package volume (27000.0 cu in) exceeds 13000 cu in”), so read it from there rather than hard-coding these numbers.

ConstraintLimitError Code
Minimum weight 0.1 lbs (1.6 oz) WEIGHT_TOO_LIGHT
Maximum weight 70 lbs (1120 oz) by default WEIGHT_EXCEEDED
Maximum volume 13,000 cubic inches by default VOLUME_EXCEEDED
Maximum single dimension 108 inches DIMENSION_EXCEEDED
Minimum dimension 0.1 inches DIMENSION_TOO_SMALL
Length + girth 165 inches max LENGTH_GIRTH_EXCEEDED
Ground Advantage weight 15.99 oz max GROUND_ADVANTAGE_WEIGHT_EXCEEDED

Service Types

ValueDescriptionWeight Range
priority_mail USPS Priority Mail (1–3 business days) 1.6 – 1120 oz
ground_advantage USPS Ground Advantage (2–5 business days) 1.6 – 15.99 oz
Girth formula: dimensions are sorted first, so it does not matter which field holds the longest side. The constraint is longest dimension + 2 * (sum of the other two) ≤ 165 inches.

Address Verification

During validation, both ship-from and ship-to addresses are verified against USPS data by our address verification service. In normal operation the verification reports one of five statuses per direction:

StatusMeaning
verified Street (address1), unit/suite line (address2), city, state and 5-digit ZIP match USPS records. The check ignores the ZIP+4 suffix, so the +4 you send is passed through unchanged: a wrong +4 neither fails nor corrects the row, and the value you submitted is what lands on the order and the printed label (send 5-digit ZIPs if you do not maintain +4 data).
corrected Address was auto-corrected. Two things trigger it: a field was materially wrong (the wrong ZIP or a misspelled city was fixed), or a street/unit designator was standardized to USPS form (350 5th Avenue350 5TH AVE, Suite 4100STE 4100). Expect a steady stream of corrected rows if your data spells designators out — that is normal, not a data-quality problem. A ZIP+4 suffix or a change of letter case on its own does not make a row corrected — those differences are ignored and the row reports verified; the corrected value may still carry a ZIP+4 suffix. The response includes both original and corrected values (each with address1, address2, city, state, zip). The corrected address replaces the one you submitted — the confirmed order and the printed label carry the corrected values. Silent fixes (the verification service reports verified but changed a field, e.g. a wrong ZIP or misspelled city) are detected and reported as corrected too.
unverified Address could not be verified — likely invalid or undeliverable. The order's result is failed with error ADDRESS_VERIFICATION_FAILED (see Error Codes). Read the per-direction object under details, not verification: a failed row carries no verification key at all, and the status plus the added reason land at details.ship_to.status / details.ship_to.reason (same for ship_from).
pending Verification service temporarily unavailable — an outage, a timeout, or a temporary service interruption — or address verification is not enabled for your account, which is a permanent state rather than a transient one. Either way the order proceeds without verification.
skipped Verification is disabled for this direction in your account settings

Treat that list as the normal set, not a closed one: the status is passed through from the verification service without normalization, so an anomaly on its side can surface a value you have never seen (including the literal unknown). Any status other than verified, corrected, pending or skipped fails the row with ADDRESS_VERIFICATION_FAILED, so make that your default branch instead of assuming unverified. The four that pass are not interchangeable: verified and corrected mean the address was checked, pending and skipped mean it was not — and if address verification is switched off for your account, every row reports skipped and passes, so treat skipped as “unchecked, accepted”, never as a failure.

Address Field Limits

FieldLimitError Code
Address line 1 32 chars, required; ASCII letters/digits/spaces and ' - . , # / ADDR_LINE1_TOO_LONG ADDR_LINE1_INVALID_CHARS
Address line 2 32 chars, optional; same charset as line 1 ADDR_LINE2_TOO_LONG ADDR_LINE2_INVALID_CHARS
City 2–24 chars, required; ASCII letters/spaces and ' - . CITY_TOO_SHORT CITY_TOO_LONG CITY_INVALID_CHARS
State 2-letter code, or a full state name that normalizes to one; bogus/unrecognized codes rejected INVALID_STATE
State (recognized, not enabled) Territory/military codes (PR, GU, VI, AS, MP, MH, AA, AE, AP) or states not supported for shipping STATE_NOT_SUPPORTED
ZIP 5 digits or ZIP+4 with hyphen (12345-6789) INVALID_ZIP
Name 32 chars max; ASCII letters/spaces and ' - . only — no digits NAME_TOO_LONG NAME_INVALID_CHARS
Company 40 chars, optional; ASCII letters/digits/spaces and ' - . , # & COMPANY_TOO_LONG COMPANY_INVALID_CHARS
Phone Optional; every non-digit character is discarded, then the remaining digit count must be 10–13. Stored exactly as you sent it INVALID_PHONE
Email Optional; max 254 chars, format-checked INVALID_EMAIL

“Letters” means unaccented ASCII (A–Z, a–z) in the five character-set rows — Name, Company, Address line 1, Address line 2 and City. In those rows accented characters (é, ñ, ü) and non-Latin scripts fail the row with the matching *_INVALID_CHARS code — even for real, deliverable US destinations such as Cañon City, CO and ordinary recipient names such as José Álvarez. Transliterate to ASCII before submitting (JoséJose, Cañon CityCanon City).

The other rows do not use a *_INVALID_CHARS code, so do not branch on that suffix for them. Non-ASCII in State fails with INVALID_STATE, in ZIP with INVALID_ZIP and in Email with INVALID_EMAIL. Phone does not fail at all — every non-digit character is discarded before the length check (see the Address Object).

Error Codes

Complete reference of error codes returned by the Gateway API. All codes use UPPERCASE_SNAKE_CASE format.

Where a code surfaces matters. Codes badged 200 below are row- or field-level: they appear inside the HTTP 200 validate response — as orders[].error, or nested in orders[].details.errors[].code — never as a top-level HTTP error. Codes badged 4xx/5xx are top-level error.code values for the whole request. A third family, badged , never travels in error.code at all: those are the label generation errors carried inside a failed order's error_data.

Authentication Errors

CodeHTTPWhen
AUTH_MISSING 401 Authorization header missing or not using the Bearer scheme
AUTH_TOKEN_INVALID 401 Bearer token is present but does not start with the sk_live_ prefix
INVALID_API_KEY 401 Key has the right sk_live_ prefix but is unknown, revoked, or deactivated (a truncated or non-hex sk_live_ token lands here too)
ACCOUNT_SUSPENDED 403 Account is deactivated (details: suspended_at, reason)
FEATURE_DISABLED 403 The external API is not enabled for your account (details.feature). Contact support to enable it

Validation Errors

CodeHTTPWhen
VALIDATION_ERROR 400 / 200 As a top-level 400: malformed JSON body, an empty orders array, or a malformed Content-Length header (that last one is checked on any endpoint before authentication, and answers “Invalid Content-Length header” — though a reverse proxy in front of the API will usually reject a malformed Content-Length itself first, with a bare non-JSON 400, so do not depend on the envelope for that one) — details is empty ({}) in all three. Field-level errors surface per row inside the HTTP 200 validate response: orders[].error with orders[].details.errors[] ({field, code, message}). VALIDATION_ERROR is also the generic per-field code inside details.errors[] for structural problems: missing ship_from/ship_to/package, non-numeric weight or dimensions, unknown service_type, an invalid label_format, an element of orders that is not a JSON object at all (that row's details.errors[0].field is orders[i]), and a value whose JSON type is wrong — a number, boolean, object or array where a string is documented, or a number that is NaN, Infinity or too wide to store (see Send every field as its documented JSON type). Every one of these fails only its own row: none of them is ever a 5xx.
ORDERS_REQUIRED 400 The request body does not supply a usable top-level orders array. Three cases land here, under two different messages — match on the code, not the prose. The key is missing, or present but not an array (e.g. {"orders": "x"}): both answer Request body must contain an "orders" array. Or the body itself is valid JSON but not an object — a bare array, string, number, boolean or null — which answers Request body must be a JSON object containing an "orders" array and is the one to expect if you post [{…}, {…}] directly instead of wrapping it in {"orders": […]}. Both messages use straight double quotes around orders, exactly as shown.
NAME_REQUIRED 200 Name is missing (field-level, in details.errors[])
NAME_TOO_LONG 200 Name exceeds 32 characters
NAME_INVALID_CHARS 200 Name contains characters outside letters, spaces, ' - . (digits are rejected). Reached only by a JSON string: a name sent as a number, object or array is a type mismatch and reports the generic VALIDATION_ERROR instead (see Send every field as its documented JSON type)
COMPANY_TOO_LONG 200 Company exceeds 40 characters
COMPANY_INVALID_CHARS 200 Company contains characters outside letters, digits, spaces, ' - . , # &
ADDR_LINE1_REQUIRED 200 Address line 1 is missing
ADDR_LINE1_TOO_LONG 200 Address line 1 exceeds 32 characters
ADDR_LINE1_INVALID_CHARS 200 Address line 1 contains characters outside letters, digits, spaces, ' - . , # /
ADDR_LINE2_TOO_LONG 200 Address line 2 exceeds 32 characters
ADDR_LINE2_INVALID_CHARS 200 Address line 2 contains invalid characters (same charset as line 1)
CITY_REQUIRED 200 City is missing
CITY_TOO_SHORT 200 City is under 2 characters
CITY_TOO_LONG 200 City exceeds 24 characters
CITY_INVALID_CHARS 200 City contains characters outside letters, spaces, ' - .
STATE_REQUIRED 200 State is missing
INVALID_STATE 200 Not a recognized US state or territory code (bogus code)
STATE_NOT_SUPPORTED 200 Recognized US code that is not enabled for shipping — territories/military (PR, GU, VI, AS, MP, MH, AA, AE, AP) or another state not supported for shipping
ZIP_REQUIRED 200 ZIP code is missing
INVALID_ZIP 200 ZIP must be 5 digits or ZIP+4 with hyphen (e.g., 12345-6789) — 9 digits without a hyphen is rejected
INVALID_PHONE 200 Phone has fewer than 10 or more than 13 digits after stripping formatting
INVALID_EMAIL 200 Email exceeds 254 characters or is not a valid address format. Reached only by a JSON string: an email sent as a number, object or array is a type mismatch and reports the generic VALIDATION_ERROR instead (see Send every field as its documented JSON type)
REFERENCE_TOO_LONG 200 Reference exceeds 50 characters
REFERENCE_INVALID_CHARS 200 Reference must start with a letter or digit and contain only letters, digits, spaces, _ - . /. It must also be sent as a JSON string — a numeric or boolean value fails with this same code even when its digits would otherwise be valid, so send "12345", not 12345

Order Errors

CodeHTTPWhen
WEIGHT_EXCEEDED 200 Package exceeds 70 lbs (1120 oz) by default — this ceiling is a service setting we control, so read it from the error message rather than hard-coding it; see Package Requirements
WEIGHT_TOO_LIGHT 200 Package under 0.1 lbs (1.6 oz)
VOLUME_EXCEEDED 200 Package exceeds 13,000 cubic inches by default — a service setting we control, like the weight ceiling; see Package Requirements
DIMENSION_EXCEEDED 200 A dimension exceeds 108 inches
DIMENSION_TOO_SMALL 200 A dimension is under 0.1 inches
LENGTH_GIRTH_EXCEEDED 200 Length + girth exceeds 165 inches
GROUND_ADVANTAGE_WEIGHT_EXCEEDED 200 Ground Advantage max is 15.99 oz
DUPLICATE_ORDER 200 Matching order within 1-hour window — a match means identical ship_from, ship_to, service_type and weight (2 dp), and ignores orders that failed or were cancelled or refunded. Only the address lines are compared (address1, address2, city, state, zip): name, company, phone, email, package dimensions, reference and item_name are all ignored, so two shipments to different recipients at the same address can collide. Row-level at validate: orders[].error with details.duplicate_of / details.created_at — never a top-level 409. Set confirm_duplicate: true inside that order object and re-validate to bypass. Only previously created single (dashboard) orders can raise this code — orders created through this API are bulk orders and are never matched, so an API-only account will never see it. See Validate Orders.
STATE_NOT_ALLOWED 200 Ship-from or ship-to state not in the account's allowlist (checked on both; row-level orders[].error with details.field = ship_from.state or ship_to.state)
STATE_BLOCKED 200 Ship-from or ship-to state in the account's blocklist (checked on both directions; row-level like STATE_NOT_ALLOWED)
SERVICE_NOT_ENABLED 200 / 422 The requested service_type is disabled for your account. Row-level at validate (in details.service_type); enforced again at confirm, where the top-level 422 carries details.rows[] of {row, service_type}.
CARRIER_NOT_ENABLED 422 The service's carrier is not enabled for your account. Surfaces at confirm when no carriers are enabled for your account — contact support to enable them; the 422 carries details.rows[] of {row, service_type, carrier}.
ORDER_CREATION_FAILED 200 / 422 No pricing available for the requested service. Row-level at validate: orders[].error with details.message = “No pricing available for this service”. Top-level 422 at confirm if pricing resolution fails there.

Batch Errors

CodeHTTPWhen
BATCH_TOO_LARGE 400 Batch exceeds max labels per request (default: 100). Two details shapes: at validate it is {errors: [{field: "orders", code: "BATCH_TOO_LARGE", message: "Batch size N exceeds maximum of M"}]} (the shape shown in the Errors example above); at confirm — only reachable when your max_bulk_labels was lowered between validate and confirm — it is {max_allowed, provided} with the message “Confirmation failed”. Guard for the absence of details.errors[0].
BATCH_EXPIRED 400 An unconfirmed batch passed its 15-minute window. Re-validate. Never returned for a batch that was already confirmed — that case is always BATCH_ALREADY_CONFIRMED.
BATCH_ALREADY_CONFIRMED 409 Batch was already confirmed — returned for as long as the batch record is retained (24 hours after validation by default), including after the 15-minute expiry, for a confirm that overlaps one still in flight, and for one that was interrupted mid-create. This is the safe idempotency signal for a retried confirm: fetch GET /batches/{batch_id}/ for the existing order_id instead of re-validating. Past the retention window the batch is deleted and you get RESOURCE_NOT_FOUND (404) instead — see that row.

Billing Errors

CodeHTTPWhen
INSUFFICIENT_BALANCE 200 / 422 Balance too low. Details include required_amount, current_balance, shortfall. At validate: HTTP 200 with every passing row flipped to failed carrying this code and details. At confirm: top-level 422.
DAILY_ORDER_LIMIT_EXCEEDED 200 / 422 Exceeded daily order creation limit. Same surfacing as INSUFFICIENT_BALANCE: per-row at validate, top-level 422 at confirm.
SINGLE_ORDER_LIMIT_EXCEEDED 200 / 422 Order cost exceeds the per-order spend limit. Same surfacing as INSUFFICIENT_BALANCE.

Address Verification Errors

CodeHTTPWhen
ADDRESS_VERIFICATION_FAILED 200 Address could not be verified — likely undeliverable. Row-level at validate: orders[].error with details carrying the per-direction verification object (ship_from/ship_to with status and reason) — never a top-level 400.

General Errors

CodeHTTPWhen
RESOURCE_NOT_FOUND 404 Order, batch, or wallet not found (or belongs to another account). Also returned for a batch that has been pruned — batches a confirm has claimed, finished or interrupted, are kept 24 hours after validation by default — so on a confirm or GET /batches/{batch_id}/ this does not mean the confirm never landed. Reconcile with GET /orders/?reference=... before re-validating.
FILE_TOO_LARGE 413 The request body exceeds the maximum accepted request size (10 MB by default). details carries max_size_mb and actual_size_mb. Reachable on any endpoint. Row count is rarely the cause — the 100-row cap trips BATCH_TOO_LARGE (400) first, and 100 ordinary rows are well under 100 KB. Oversized field content (long item names or notes) is the realistic trigger.
RATE_LIMITED 429 Too many requests — check Retry-After header
INTERNAL_SERVER_ERROR 500 Unexpected server error — contact support. Most malformed payloads never reach it: a wrong JSON type in a string field, an unusable number, a non-finite package weight and a row that is not a JSON object each fail their own row inside the 200 (see Send every field as its documented JSON type). Two payload defects are still fatal to the whole request, and retrying will return the same 500 every time — check for them before you retry: (1) a lone UTF-16 surrogate in any string, i.e. half an emoji pair, such as "item_name": "Widget \ud83d" — the matching pair "😀" is fine, so this is an encoder bug, usually a string truncated at a fixed byte count; (2) anything in the body nested deeper than roughly 150 levels, arrays and objects alike and including under keys we do not document, which exceeds the storage layer's document-depth limit. Both are fixable in your encoder; neither will ever succeed on retry. If none of them applies, the fault is ours: a GET is safe to retry immediately, but never blind-retry POST /orders/confirm/{batch_id} — call GET /gateway/v1/batches/{batch_id}/ first to find out whether the confirm landed
SERVICE_UNAVAILABLE 503 Service temporarily unavailable — retry with backoff
STORAGE_UNAVAILABLE 503 File storage temporarily unavailable. No Gateway endpoint emits this code — it is listed only so you can recognise it if a non-Gateway surface returns it. The Gateway swallows storage failures instead: a completed/partial order still returns 200 with labels_pdf_url, csv_url and invoice_url omitted, and an already-issued download link answers a bodiless 404/503. Treat missing URL keys on a completed or partial order as “retry the order fetch with backoff”, not as “no files exist” — on every other status the keys are absent by design, and a file that genuinely was not produced comes back as an empty string rather than a missing key

Label Generation Errors (inside error_data)

These are the codes a failed order carries in error_data.code. They are written during label generation, after the order was created, so they have no HTTP status of their own and never appear as a top-level error.code — the request that returns them is a 200 from GET /orders/{order_id}/ or GET /orders/{order_id}/details. error_data is {code, message, details} on a failed order — but the two endpoints differ in whether the key is there at all. GET /orders/{order_id}/details always includes it (null when unpopulated); the polling endpoint GET /orders/{order_id}/ includes it only when status is failed — for every other status the key is absent entirely, not null. Read it with .get() semantics.

// GET /gateway/v1/orders/{order_id}/details — a failed order.
// EXCERPT, not a whole body: the response is enveloped like every other
// one on this page ({success, data, error, message}) and carries the full
// field set of GET /orders/{order_id}/ plus error_data. Only the two keys
// that matter here are shown; they live under "data".
{
  "status": "failed",
  "error_data": {
    "code": "PROCESSOR_LABEL_FAILED",
    "message": "Label generation failed: carrier rejected the request",
    "details": {}
  }
}
CodeHTTPWhen
PROCESSOR_LABEL_FAILED The label could not be generated — the carrier rejected the request
PROCESSOR_ADDRESS_REJECTED The carrier rejected the address at label time, after it passed verification
PROCESSOR_SERVICE_UNAVAILABLE The carrier service was temporarily unavailable
PROCESSOR_INSUFFICIENT_BALANCE Your balance had dropped below the order's cost by the time it was picked up for label generation (a concurrent order spent it). Distinct from the top-level 422 INSUFFICIENT_BALANCE, which is the pre-check at validate/confirm time
PROCESSING_TIMEOUT The order exceeded the maximum processing time and was automatically failed — message “Order timed out in processing state”

Treat this list as the normal set, not a closed one. error_data is written at label-generation time and is passed through unchanged, so an order can carry a code that is not in this table — older orders and service-level failures do. Always give error_data.code a default branch and fall back to error_data.message rather than assuming one of the values above.

On a partial order the per-label labels[].error is a human-readable sentence, not one of these codes — don't switch on it.

Polling (no webhooks)

The Gateway API is polling-based by design. It does not offer webhooks or callback URLs, and none are planned — polling the status endpoints is the supported way to track order progress. Labels are typically ready within seconds, so a short polling loop is all an integration needs.

Recommended Polling Strategy

ResourceEndpointStrategy
The order you created here (bulk), or a single order created in the dashboard GET /orders/{order_id}/ Start at a 2-second interval, back off by 1.5x up to 30 seconds, then show a “check back later” message. The sample loop stops after 2 minutes, which suits a single dashboard label; for a bulk order created through this API give it the same 5-minute budget as the row below — it is the same object, and a full batch — your account's max_bulk_labels, 100 by default — takes longer to finish.
The batch that created it GET /batches/{batch_id}/ Poll at a fixed 5-second interval with a 5-minute timeout. A confirmed batch maps to exactly one bulk order and this endpoint returns that same order payload under order (see Get Batch Status), so the two rows are two routes to one object — pick whichever id you kept and poll only that one, never both.

All polling draws on the same 100 requests / minute budget validate and confirm spend (see Rate Limits: the counter is kept per API server process, so treat 100/minute as the contract you should size against, not as the exact throttling point). The single-order strategy composes well because it backs off (about seven requests per order in the first minute), but the batch strategy's fixed 5-second interval is roughly twelve requests a minute per batch, so eight concurrent batches already approach the limit. Poll one route per batch — either its batch_id or the order_id it created, never both, since one call already covers every label — and honour Retry-After if you are ever limited.

Stop polling on any terminal status: completed, partial, failed, cancelled, or refunded. All five are checked by the ready-to-use polling loops in Get Order Status and Get Batch Status — Python, Node.js, PHP, Ruby, and C# (the cURL tab in both sections is a single request, not a loop).

Check the HTTP status before unwrapping data. On every error the envelope sets data to null, so a loop that reads data["status"] unconditionally crashes on the first 429 or 404 — exactly the responses this section tells you to expect. Each polling loop above therefore branches on the status code before it parses anything: it waits out 429 and 503, and aborts on 404/401, which never recover by retrying. Reading the body first would defeat the guard, because a 503 raised by a proxy in front of the API is an HTML page, not an envelope. On the retry path all ten loops — Python, Node.js, PHP, Ruby and C#, in both sections — read the Retry-After header and sleep for exactly that long, falling back to their own current interval when the response carries no such header (a 503 usually does not). Honouring the header matters: the gateway scope allows 100 requests per 60 seconds and sends a Retry-After of up to a full minute, so a loop that ignores it keeps firing every 2 or 5 seconds for the whole cooldown and collects roughly thirty more rejections. On the batch endpoint a 404 means the batch record has been purged or was never yours — reconcile with GET /orders/?reference=… rather than re-validating the rows. See also the api_request() helper in Rate Limits for a reusable Retry-After wrapper.

Decide on the status, never on whether order is present. GET /batches/{batch_id}/ returns status: "completed" with order: null when a confirm accepted zero labels — that batch is finished and will never change, so a loop that waits for order spins until its own timeout. The reverse case is status: "pending" with order_id: null, which means the batch is not confirmed yet or a confirm is still in flight: keep polling, and do not re-validate the rows.