KryptoShip 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.
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
GET /account/ to verify your credits./orders/validate for pre-flight checks.# Verify your API key works curl https://app.kryptoship.com/gateway/v1/account/ \ -H "Authorization: Bearer sk_live_your_api_key_here"
import requests BASE_URL = "https://app.kryptoship.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.kryptoship.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.kryptoship.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.kryptoship.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.kryptoship.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.
Header Format
API keys are prefixed with sk_live_ followed by 48 hexadecimal characters
(56 characters total). Send the key as a Bearer token:
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
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:
| Code | Cause |
|---|---|
| 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 all — AUTH_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.kryptoship.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.kryptoship.com/gateway/v1/account/", headers=headers, ) print(response.json())
const response = await fetch( "https://app.kryptoship.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.kryptoship.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.kryptoship.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.kryptoship.com/gateway/v1/account/" ); var json = await response.Content.ReadAsStringAsync(); Console.WriteLine(json);
{
"success": true,
"data": {
"balance": 142.50,
"daily_orders_used": 12,
"max_bulk_labels": 100
},
"error": null,
"message": "OK"
}// 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.
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
| Code | Meaning |
|---|---|
| 200 | Success — every Gateway endpoint (including validate and confirm) returns 200 on success |
| 400 | Validation error — check error.details |
| 401 | Missing or invalid API key |
| 403 | Account suspended or insufficient permissions |
| 404 | Resource not found. A known endpoint with an unknown id returns the JSON envelope (RESOURCE_NOT_FOUND); an unknown path returns an HTML page instead |
| 405 | Wrong HTTP method for this endpoint — returned with an empty body, no JSON envelope |
| 409 | Conflict — batch already confirmed |
| 413 | Request 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 |
| 422 | Insufficient balance or unprocessable |
| 429 | Rate limit exceeded — check Retry-After header |
| 500 | Internal server error |
| 503 | Temporary 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.
// 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" }
{
"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"
}{
"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.
| Scope | Limit |
|---|---|
| All endpoints | 100 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:
| Header | Description |
|---|---|
| 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) |
HTTP/1.1 200 OK X-RateLimit-Limit: 100 X-RateLimit-Remaining: 87 X-RateLimit-Reset: 1712150400 Content-Type: application/json
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.
Response Fields
| Field | Type | Description |
|---|---|---|
| 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. |
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.kryptoship.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);
{
"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.
Response Fields
| Field | Type | Description |
|---|---|---|
| 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
KryptoShip, 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 KryptoShip 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.kryptoship.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")}");
{
"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.
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_duplicateis therefore a no-op for an API-only account: there is nothing for it to bypass.
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.
Request Body
| Field | Type | Description |
|---|---|---|
| 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”. |
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
stringmust 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 withVALIDATION_ERRORand adetails.errors[]entry reading<field> must be a JSON string, not a number(ornot 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.referenceis the exception because it already had its own answer: a non-stringreferencefails the row withREFERENCE_INVALID_CHARS, not with the message above, and the value echoed inorders[].referenceandorders[].order_data.referencekeeps the JSON type you sent. Model those two as “any JSON value”;labels[].referenceon the confirm response is always a string. - JSON
nullmeans “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 genericVALIDATION_ERRORand the message “Service type is required”, as there is noSERVICE_TYPE_REQUIREDcode; and an optional field simply stays blank. Do not branch on a*_REQUIREDsuffix alone. The value echoed inorder_datais the normalised"", not yournull. - Every JSON number must be finite and no wider than a signed 64-bit
integer.
NaNandInfinityare not valid JSON numbers, and an integer above9223372036854775807cannot be stored: such a value is reported on its own path and replaced bynullin the row. In a string-typed field thatnullis then normalised to""by the rule above, so an out-of-range"zip"echoes as""rather than asnull. This applies anywhere in the row, including keys we do not document. - The four
numberfields 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 withVALIDATION_ERRORonpackage.<field>: a JSONtrue/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 writesNaNinto an empty weight column, and it used to take the whole request down with a 500. confirm_duplicateis read for truthiness, not as a boolean. It is documentedbooleanand 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 JSONtrue/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
| Field | Type | Description |
|---|---|---|
| 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 zip — name, 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
| Field | Type | Description |
|---|---|---|
| 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, CO → Canon 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
| Field | Type | Description |
|---|---|---|
| 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.
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_BALANCE—details: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 atresets_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.kryptoship.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);
// 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" }
// 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 ... */ } }
// 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.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
| batch_idrequired | string | Batch ID from the validate response (e.g., batch_6615a3f2...) |
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:
| Field | Type | Description |
|---|---|---|
| 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.kryptoship.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}"); }
// 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" }
// 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" }
// 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" }
// 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.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
| order_idrequired | string | Order ID from the confirm response |
Order Statuses
| Status | Description |
|---|---|
| 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. |
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.
| Field | Type | Description |
|---|---|---|
| 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. |
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)”).
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.
curl https://app.kryptoship.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");
{
"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"
}{
"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.kryptoship.com/storage/<token>/",
"csv_url": "https://app.kryptoship.com/storage/<token>/",
"invoice_url": "https://app.kryptoship.com/storage/<token>/"
},
"error": null,
"message": "OK"
}{
"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.kryptoship.com/storage/<token>/",
"csv_url": "https://app.kryptoship.com/storage/<token>/",
"invoice_url": "https://app.kryptoship.com/storage/<token>/"
},
"error": null,
"message": "OK"
}// 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.kryptoship.com/storage/<token>/", "csv_url": "https://app.kryptoship.com/storage/<token>/", "invoice_url": "https://app.kryptoship.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 answeringpendinguntil the hourly cleanup deletes it and the path starts answering404— and the response carries noexpires_atto tell you apart from a live batch.pendingon a batch you never successfully confirmed can therefore be terminal:POST /orders/confirm/{batch_id}would now answer400 BATCH_EXPIRED. Bound your poll (the samples use a 5-minute budget); if you never saw a 200 or a 409 from confirm, reconcile withGET /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 samebatch_id(a409proves 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 realorder_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 replayingPOST /orders/confirm/{batch_id}then returns200with a new order instead of409. So the two correct responses to this state are keep polling and retry the samebatch_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. Readsummaryto see why.
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.
Order Statuses
| Status | Meaning |
|---|---|
| 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 |
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.kryptoship.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");
{
"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.kryptoship.com/storage/<token>/",
"csv_url": "https://app.kryptoship.com/storage/<token>/",
"invoice_url": "https://app.kryptoship.com/storage/<token>/"
}
},
"error": null,
"message": "OK"
}// 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.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
| 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. |
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.
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.kryptoship.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.kryptoship.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);
// 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.
Response Includes
labels— Per-label breakdown:index,submitted_index,reference,service_type, and (once processed)tracking_number,status, anderror. See Label Objectlabels_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 iscompletedorpartial— 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:GETthem with noAuthorizationheader — 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 bare404, and a storage failure a bare503, both with a zero-byte body and noerror.codeto parse. Re-fetch the order to mint a fresh link.error_data— The label-generation error on a failed order:{code, message, details},nullotherwise. Itscodecomes from a separate vocabulary — see Label Generation Errors
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.kryptoship.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);
{
"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.kryptoship.com/storage/<token>/",
"csv_url": "https://app.kryptoship.com/storage/<token>/",
"invoice_url": "https://app.kryptoship.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
- Validate —
POST /orders/validatewith your orders. Each order is individually validated (address, dimensions, pricing, dedup). You get back abatch_idand 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). - Review — Check the response. Fix any
failedorders. Reviewcorrectedaddresses. Verify theestimated_total_price. - Confirm —
POST /orders/confirm/{batch_id}. Allpassedandcorrectedrows are created as a single bulk order, charged once. You get back oneorder_id; rows that failed validation are returned inrejected. - Poll — Use
GET /orders/{order_id}/orGET /batches/{batch_id}/to check when labels are ready and read per-label tracking numbers. - Download — When an order is
completedorpartial, 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).partialis 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 plainGETand noAuthorizationheader — 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 bare404and a storage failure a bare503, both with an empty body and noerror.code. Re-fetch the order to mint a fresh link.
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:
| Result | Meaning | Action |
|---|---|---|
| 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.
| Constraint | Limit | Error 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
| Value | Description | Weight 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 |
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:
| Status | Meaning |
|---|---|
| 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 Avenue → 350 5TH AVE, Suite 4100 → STE 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
| Field | Limit | Error 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 |
| 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 City → Canon 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
| Code | HTTP | When |
|---|---|---|
| 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
| Code | HTTP | When |
|---|---|---|
| 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
| Code | HTTP | When |
|---|---|---|
| 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
| Code | HTTP | When |
|---|---|---|
| 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
| Code | HTTP | When |
|---|---|---|
| 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
| Code | HTTP | When |
|---|---|---|
| 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
| Code | HTTP | When |
|---|---|---|
| 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": {} } }
| Code | HTTP | When |
|---|---|---|
| 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
| Resource | Endpoint | Strategy |
|---|---|---|
| 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.