Shipping Label 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. All request and response bodies are JSON.
Quick Start
# Verify your API key works curl https://app.example.com/gateway/v1/account/ \ -H "Authorization: Bearer gw_your_api_key_here"
import requests BASE_URL = "https://app.example.com/gateway/v1" API_KEY = "gw_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.example.com/gateway/v1"; const API_KEY = "gw_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.example.com/gateway/v1"; $apiKey = "gw_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.example.com/gateway/v1" API_KEY = "gw_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: true) do |http| http.request(request) end puts JSON.parse(response.body)
using System.Net.Http; var baseUrl = "https://app.example.com/gateway/v1"; var apiKey = "gw_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
All API keys are prefixed with gw_ followed by 48 random characters.
Send the key as a Bearer token:
Security Best Practices
- Never expose API keys in client-side code, public repos, or logs
- Use environment variables to store your key
- Rotate keys periodically — create a new key before revoking the old one
- Each key has an optional name for identification
401 Unauthorized.
curl https://app.example.com/gateway/v1/account/ \ -H "Authorization: Bearer gw_your_api_key_here" \ -H "Content-Type: application/json"
import requests headers = { "Authorization": "Bearer gw_your_api_key_here", "Content-Type": "application/json", } response = requests.get( "https://app.example.com/gateway/v1/account/", headers=headers, ) print(response.json())
const response = await fetch( "https://app.example.com/gateway/v1/account/", { headers: { "Authorization": "Bearer gw_your_api_key_here", "Content-Type": "application/json", }, } ); const data = await response.json(); console.log(data);
<?php $ch = curl_init("https://app.example.com/gateway/v1/account/"); curl_setopt($ch, CURLOPT_HTTPHEADER, [ "Authorization: Bearer gw_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.example.com/gateway/v1/account/") request = Net::HTTP::Get.new(uri) request["Authorization"] = "Bearer gw_your_api_key_here" request["Content-Type"] = "application/json" response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| http.request(request) end puts JSON.parse(response.body)
using System.Net.Http; var client = new HttpClient(); client.DefaultRequestHeaders.Add( "Authorization", "Bearer gw_your_api_key_here" ); var response = await client.GetAsync( "https://app.example.com/gateway/v1/account/" ); var json = await response.Content.ReadAsStringAsync(); Console.WriteLine(json);
{
"success": true,
"data": {
"balance": 142.50,
"daily_orders_used": 12,
"daily_orders_limit": 100,
"daily_spend_limit": 1000.0,
"max_bulk_labels": 100
},
"error": null,
"message": "OK"
}{
"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 follows the same envelope structure, making error handling predictable.
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.
HTTP Status Codes
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Resource created |
| 400 | Validation error — check error.details |
| 401 | Missing or invalid API key |
| 403 | Account suspended or insufficient permissions |
| 404 | Resource not found |
| 409 | Conflict — duplicate order, batch already confirmed |
| 422 | Insufficient balance or unprocessable |
| 429 | Rate limit exceeded — check Retry-After header |
| 500 | Internal server error |
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"details": {
"errors": [
{
"field": "ship_to.zip",
"code": "INVALID_ZIP",
"message": "ZIP code must be 5 or 9 digits"
}
]
}
},
"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"
}{
"success": false,
"data": null,
"error": {
"code": "RATE_LIMITED",
"details": {
"retry_after": 45
}
},
"message": "Too many requests"
}Rate Limits
The Gateway API enforces rate limits to ensure fair usage. Limits are tracked per API key.
When you exceed a limit, the API returns 429 Too Many Requests with a Retry-After header.
| Scope | Limit |
|---|---|
| General | 100 requests / minute |
| Order creation | 30 requests / minute |
Rate Limit Headers
Every response includes rate limit headers so you can track your usage:
| Header | Description |
|---|---|
| X-RateLimit-Limit | Maximum requests allowed in the window |
| X-RateLimit-Remaining | Requests remaining in current window |
| 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 (ERC-20) to this address to add credits to your balance.
Response Fields
| Field | Type | Description |
|---|---|---|
| address | string | Ethereum wallet address (0x...) |
| network | string | Network identifier (always Ethereum (ERC-20)) |
404.
curl https://app.example.com/gateway/v1/wallet/ \ -H "Authorization: Bearer gw_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: true) 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 | Available balance in USD credits |
| daily_orders_used | integer | Orders created today (UTC) |
| daily_orders_limit | integer | Maximum orders per day (default: 100) |
| daily_spend_limit | number | Maximum spend per order in USD (default: 1000) |
| max_bulk_labels | integer | Maximum labels per batch (default: 100) |
curl https://app.example.com/gateway/v1/account/ \ -H "Authorization: Bearer gw_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']}/{account['daily_orders_limit']}")
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}/${data.daily_orders_limit}`);
$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']}/{$account['daily_orders_limit']}\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: true) do |http| http.request(request) end account = JSON.parse(response.body)["data"] puts "Balance: $#{account['balance']}" puts "Orders today: #{account['daily_orders_used']}/#{account['daily_orders_limit']}"
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")}/{data.GetProperty("daily_orders_limit")}");
{
"success": true,
"data": {
"balance": 142.50,
"daily_orders_used": 12,
"daily_orders_limit": 100,
"daily_spend_limit": 1000.0,
"max_bulk_labels": 100
},
"error": null,
"message": "OK"
}Validate Orders
Submit a batch of orders for validation. The API checks addresses, verifies pricing,
detects duplicates, and confirms your balance covers the total cost.
Returns a batch_id that you use to confirm the orders.
Request Body
| Field | Type | Description |
|---|---|---|
| ordersrequired | array | Array of order objects (max 100 per batch) |
Order Object
| Field | Type | Description |
|---|---|---|
| ship_fromrequired | object | Sender address. See Address Object |
| ship_torequired | object | Recipient address. See Address Object |
| packagerequired | object | Package dimensions and weight. See Package Object |
| service_typerequired | string | priority_mail or ground_advantage |
| label_formatoptional | string | Label size. Default: 4x6 |
| referenceoptional | string | Your internal reference ID. Returned in all responses and stored with the order. |
| confirm_duplicateoptional | boolean | Set to true to bypass the 1-hour duplicate detection window. Default: false |
Address Object
| Field | Type | Description |
|---|---|---|
| namerequired | string | Full name (max 32 chars) |
| companyoptional | string | Company name (max 40 chars) |
| address1required | string | Street address line 1 (max 32 chars) |
| address2optional | string | Apt, suite, unit (max 32 chars) |
| cityrequired | string | City (max 24 chars) |
| staterequired | string | 2-letter state code (e.g., CA) |
| ziprequired | string | ZIP code (5 or 9 digits) |
Package Object
| Field | Type | Description |
|---|---|---|
| weight_ozrequired | number | Weight in ounces (1.6 – 1120 oz) |
| lengthrequired | number | Length in inches (0.1 – 108) |
| widthrequired | number | Width in inches (0.1 – 108) |
| heightrequired | number | Height in inches (0.1 – 108) |
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.
curl -X POST https://app.example.com/gateway/v1/orders/validate \ -H "Authorization: Bearer gw_your_api_key_here" \ -H "Content-Type: application/json" \ -d '{ "orders": [ { "reference": "ORDER-001", "ship_from": { "name": "John Smith", "address1": "123 Sender St", "city": "Los Angeles", "state": "CA", "zip": "90001" }, "ship_to": { "name": "Jane Doe", "address1": "456 Receiver Ave", "address2": "Apt 2B", "city": "New York", "state": "NY", "zip": "10001" }, "package": { "weight_oz": 12, "length": 10, "width": 8, "height": 4 }, "service_type": "priority_mail" } ] }'
import requests response = requests.post( f"{BASE_URL}/orders/validate", headers=headers, json={ "orders": [ { "reference": "ORDER-001", "ship_from": { "name": "John Smith", "address1": "123 Sender St", "city": "Los Angeles", "state": "CA", "zip": "90001", }, "ship_to": { "name": "Jane Doe", "address1": "456 Receiver Ave", "address2": "Apt 2B", "city": "New York", "state": "NY", "zip": "10001", }, "package": { "weight_oz": 12, "length": 10, "width": 8, "height": 4, }, "service_type": "priority_mail", } ] }, ) result = response.json() 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: "123 Sender St", city: "Los Angeles", state: "CA", zip: "90001", }, ship_to: { name: "Jane Doe", address1: "456 Receiver Ave", address2: "Apt 2B", city: "New York", state: "NY", zip: "10001", }, package: { weight_oz: 12, length: 10, width: 8, height: 4, }, service_type: "priority_mail", }, ], }), } ); const result = await response.json(); 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" => "123 Sender St", "city" => "Los Angeles", "state" => "CA", "zip" => "90001", ], "ship_to" => [ "name" => "Jane Doe", "address1" => "456 Receiver Ave", "address2" => "Apt 2B", "city" => "New York", "state" => "NY", "zip" => "10001", ], "package" => [ "weight_oz" => 12, "length" => 10, "width" => 8, "height" => 4, ], "service_type" => "priority_mail", ], ], ]); $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); 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: "123 Sender St", city: "Los Angeles", state: "CA", zip: "90001" }, ship_to: { name: "Jane Doe", address1: "456 Receiver Ave", address2: "Apt 2B", city: "New York", state: "NY", zip: "10001" }, package: { weight_oz: 12, length: 10, width: 8, height: 4 }, service_type: "priority_mail" } ] }.to_json response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| http.request(request) end result = JSON.parse(response.body) puts "Batch: #{result['data']['batch_id']}"
var payload = new { orders = new[] { new { reference = "ORDER-001", ship_from = new { name = "John Smith", address1 = "123 Sender St", city = "Los Angeles", state = "CA", zip = "90001" }, ship_to = new { name = "Jane Doe", address1 = "456 Receiver Ave", address2 = "Apt 2B", city = "New York", state = "NY", zip = "10001" }, package = new { weight_oz = 12, length = 10, width = 8, height = 4 }, service_type = "priority_mail" } } }; 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);
{
"success": true,
"data": {
"batch_id": "batch_6615a3f2e4b0c1d2e3f4a5b6",
"expires_at": "2025-04-10T15:30:00+00:00",
"summary": {
"total": 1,
"passed": 0,
"corrected": 1,
"failed": 0,
"estimated_total_price": 8.25
},
"orders": [
{
"index": 0,
"reference": "ORDER-001",
"result": "corrected",
"price": 8.25,
"verification": {
"ship_from": { "status": "verified" },
"ship_to": {
"status": "corrected",
"original": {
"address1": "456 Receiver Ave",
"city": "New York",
"state": "NY",
"zip": "10001"
},
"corrected": {
"address1": "456 Receiver Avenue",
"city": "New York",
"state": "NY",
"zip": "10001-6732"
}
}
},
"order_data": { /* ... corrected order data ... */ }
}
]
},
"error": null,
"message": "Validation complete"
}// An order within the batch that failed validation { "index": 2, "reference": "ORDER-003", "result": "failed", "error": "VALIDATION_ERROR", "details": { "errors": [ { "field": "ship_to.state", "code": "INVALID_STATE", "message": "Not a valid 2-char state code" } ] } }
Confirm Orders
Confirm a previously validated batch to create shipping labels. This deducts credits from your balance
and queues the orders for label generation. Only orders with passed or corrected results
will be created.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
| batch_idrequired | string | Batch ID from the validate response (e.g., batch_6615a3f2...) |
409 BATCH_ALREADY_CONFIRMED.
Response
Returns accepted orders (with order_id and pending status) and any rejected orders.
Orders start as pending and move to completed or failed once the label is generated.
curl -X POST https://app.example.com/gateway/v1/orders/confirm/batch_6615a3f2e4b0c1d2e3f4a5b6 \ -H "Authorization: Bearer gw_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, ) result = response.json() for order in result["data"]["accepted"]: print(f"Order {order['order_id']} created (ref: {order['reference']})")
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 result = await response.json(); result.data.accepted.forEach((order) => { console.log(`Order ${order.order_id} created`); });
$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); $result = json_decode(curl_exec($ch), true); foreach ($result["data"]["accepted"] as $order) { echo "Order {$order['order_id']} created\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: true) do |http| http.request(request) end result = JSON.parse(response.body) result["data"]["accepted"].each do |order| puts "Order #{order['order_id']} created" 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(); Console.WriteLine(json);
{
"success": true,
"data": {
"batch_id": "batch_6615a3f2e4b0c1d2e3f4a5b6",
"summary": {
"total_accepted": 1,
"total_rejected": 0
},
"accepted": [
{
"index": 0,
"reference": "ORDER-001",
"order_id": "6615b1c2d3e4f5a6b7c8d9e0",
"status": "pending",
"price": 8.25
}
],
"rejected": []
},
"error": null,
"message": "Orders created"
}{
"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 orders include a tracking number and proxy-wrapped download URLs for the label PDF.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
| order_idrequired | string | Order ID from the confirm response |
Order Statuses
| Status | Description |
|---|---|
| pending | Order created, waiting for processor pickup |
| processing | Processor is generating the label |
| completed | Label generated — includes tracking number and download URLs |
| failed | Label generation failed — see error_data for details. Create a new order. |
| cancelled | Order was cancelled by admin |
| refunded | Credits refunded to your balance |
curl https://app.example.com/gateway/v1/orders/6615b1c2d3e4f5a6b7c8d9e0/ \ -H "Authorization: Bearer gw_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, ) data = response.json()["data"] if data["status"] == "completed": print(f"Tracking: {data['order_details']['tracking_number']}") print(f"Label: {data['files']['label_pdf']}") break elif data["status"] == "failed": print(f"Failed: {data['error_data']}") 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}` } } ); const { data } = await res.json(); if (data.status === "completed") { console.log("Tracking:", data.order_details.tracking_number); console.log("Label:", data.files.label_pdf); break; } else if (data.status === "failed") { console.log("Failed:", data.error_data); break; } await new Promise((r) => setTimeout(r, interval)); elapsed += interval; interval = Math.min(interval * 1.5, 30000); }
$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); $data = json_decode(curl_exec($ch), true)["data"]; if ($data["status"] === "completed") { echo "Tracking: {$data['order_details']['tracking_number']}\n"; break; } elseif ($data["status"] === "failed") { echo "Failed\n"; break; } sleep($interval); $elapsed += $interval; $interval = min($interval * 1.5, 30); }
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: true) { |http| http.request(req) } data = JSON.parse(res.body)["data"] if data["status"] == "completed" puts "Tracking: #{data['order_details']['tracking_number']}" break elsif data["status"] == "failed" puts "Failed: #{data['error_data']}" break end sleep(interval) elapsed += interval interval = [interval * 1.5, 30].min end
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(); using var doc = JsonDocument.Parse(json); var status = doc.RootElement .GetProperty("data") .GetProperty("status") .GetString(); if (status == "completed" || status == "failed") { Console.WriteLine(json); break; } await Task.Delay(interval); elapsed += interval; interval = Math.Min((int)(interval * 1.5), 30000); }
{
"success": true,
"data": {
"order_id": "6615b1c2d3e4f5a6b7c8d9e0",
"reference": "ORDER-001",
"status": "pending",
"created_at": "2025-04-10T15:16:18+00:00",
"price": 8.25
},
"error": null,
"message": "OK"
}{
"success": true,
"data": {
"order_id": "6615b1c2d3e4f5a6b7c8d9e0",
"reference": "ORDER-001",
"status": "completed",
"created_at": "2025-04-10T15:16:18+00:00",
"completed_at": "2025-04-10T15:16:42+00:00",
"price": 8.25,
"order_details": {
"ship_from": {
"name": "John Smith",
"address1": "123 Sender St",
"city": "Los Angeles",
"state": "CA",
"zip": "90001"
},
"ship_to": {
"name": "Jane Doe",
"address1": "456 Receiver Avenue",
"address2": "Apt 2B",
"city": "New York",
"state": "NY",
"zip": "10001-6732"
},
"package": {
"weight_oz": 12,
"length": 10,
"width": 8,
"height": 4
},
"service_type": "priority_mail",
"tracking_number": "9400111899223100012345"
},
"files": {
"label_pdf": "/storage/<token>/"
}
},
"error": null,
"message": "OK"
}Get Batch Status
Poll the status of all orders in a confirmed batch. More efficient than polling each order individually.
The aggregate status reflects the overall batch state.
Aggregate Statuses
| Status | Meaning |
|---|---|
| pending | At least one order is still pending or processing |
| completed | All orders completed successfully |
| partial | Some orders completed, some failed |
| failed | All orders failed |
curl https://app.example.com/gateway/v1/batches/batch_6615a3f2e4b0c1d2e3f4a5b6/ \ -H "Authorization: Bearer gw_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, ) data = response.json()["data"] if data["status"] in ("completed", "partial", "failed"): print(f"Batch {data['status']}:") print(f" Summary: {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}` } } ); const { data } = await res.json(); if (["completed", "partial", "failed"].includes(data.status)) { console.log(`Batch ${data.status}:`, data.summary); break; } await new Promise((r) => setTimeout(r, interval)); elapsed += interval; }
$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); $data = json_decode(curl_exec($ch), true)["data"]; if (in_array($data["status"], ["completed", "partial", "failed"])) { echo "Batch {$data['status']}\n"; print_r($data["summary"]); break; } sleep($interval); $elapsed += $interval; }
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: true) { |http| http.request(req) } data = JSON.parse(res.body)["data"] if ["completed", "partial", "failed"].include?(data["status"]) puts "Batch #{data['status']}: #{data['summary']}" break end sleep(interval) elapsed += interval end
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(); using var doc = JsonDocument.Parse(json); var status = doc.RootElement .GetProperty("data") .GetProperty("status") .GetString(); if (status == "completed" || status == "partial" || status == "failed") { Console.WriteLine(json); break; } await Task.Delay(interval); elapsed += interval; }
{
"success": true,
"data": {
"batch_id": "batch_6615a3f2e4b0c1d2e3f4a5b6",
"status": "completed",
"summary": {
"total": 3,
"pending": 0,
"completed": 3,
"failed": 0,
"partial": 0
},
"orders": [
{
"order_id": "6615b1c2d3e4f5a6b7c8d9e0",
"reference": "ORDER-001",
"status": "completed",
"tracking_number": "9400111899223100012345",
"files": { "label_pdf": "https://..." }
},
/* ... more orders ... */
]
},
"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) |
| per_pageoptional | integer | Results per page (default: 20, max: 100) |
| statusoptional | string | Filter by status: pending, completed, failed, etc. |
| sort_orderoptional | string | asc or desc (default: desc) |
| date_fromoptional | string | ISO 8601 date filter (e.g., 2025-04-01) |
| date_tooptional | string | ISO 8601 end date (inclusive) |
| referenceoptional | string | Search by your reference ID (partial match) |
# List completed orders from the last 7 days curl "https://app.example.com/gateway/v1/orders/?status=completed&date_from=2025-04-03&per_page=50" \ -H "Authorization: Bearer gw_your_api_key_here"
response = requests.get( f"{BASE_URL}/orders/", headers=headers, params={ "status": "completed", "date_from": "2025-04-03", "per_page": 50, }, ) data = response.json() print(f"Total: {data['total']}, Page: {data['page']}") for order in data["items"]: print(f" {order['order_id']} — {order['status']}")
const params = new URLSearchParams({ status: "completed", date_from: "2025-04-03", per_page: "50", }); const response = await fetch( `${BASE_URL}/orders/?${params}`, { headers: { "Authorization": `Bearer ${API_KEY}` } } ); const data = await response.json(); 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", "date_from" => "2025-04-03", "per_page" => 50, ]); $ch = curl_init("$baseUrl/orders/?$query"); curl_setopt($ch, CURLOPT_HTTPHEADER, [ "Authorization: Bearer $apiKey", ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $data = json_decode(curl_exec($ch), true); echo "Total: {$data['total']}, Page: {$data['page']}\n"; foreach ($data["items"] as $order) { echo " {$order['order_id']} — {$order['status']}\n"; }
uri = URI("#{BASE_URL}/orders/") uri.query = URI.encode_www_form( status: "completed", date_from: "2025-04-03", per_page: 50 ) req = Net::HTTP::Get.new(uri) req["Authorization"] = "Bearer #{API_KEY}" res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } data = JSON.parse(res.body) 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=2025-04-03&per_page=50" ); var json = await response.Content.ReadAsStringAsync(); Console.WriteLine(json);
{
"items": [
{
"order_id": "6615b1c2d3e4f5a6b7c8d9e0",
"reference": "ORDER-001",
"status": "completed",
"created_at": "2025-04-10T15:16:18+00:00",
"price": 8.25
}
],
"total": 47,
"page": 1,
"per_page": 50
}Retrieve Past Order
Retrieve full details for any order, including complete shipment data, tracking number, file download URLs, error data, and event history. Unlike the status endpoint, this always includes full details regardless of order status.
Response Includes
order_details— Ship-from, ship-to, package, service type, tracking numberfiles— Proxy-wrapped download URLs (24-hour expiry by default, configurable per deployment) for label PDF, data CSV, etc.error_data— If order failed, contains the processor error detailsevents— Timeline of status changes
curl https://app.example.com/gateway/v1/orders/6615b1c2d3e4f5a6b7c8d9e0/details \ -H "Authorization: Bearer gw_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']}") print(f"Tracking: {data['order_details']['tracking_number']}") if data.get("files"): print(f"Label: {data['files']['label_pdf']}")
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); console.log("Tracking:", data.order_details.tracking_number); if (data.files) console.log("Label:", data.files.label_pdf);
$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"; echo "Tracking: {$data['order_details']['tracking_number']}\n"; if (isset($data["files"])) { echo "Label: {$data['files']['label_pdf']}\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: true) { |http| http.request(req) } data = JSON.parse(res.body)["data"] puts "Status: #{data['status']}" puts "Tracking: #{data['order_details']['tracking_number']}" puts "Label: #{data['files']['label_pdf']}" if data["files"]
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",
"reference": "ORDER-001",
"status": "completed",
"created_at": "2025-04-10T15:16:18+00:00",
"completed_at": "2025-04-10T15:16:42+00:00",
"price": 8.25,
"order_details": {
"ship_from": { /* ... */ },
"ship_to": { /* ... */ },
"package": { /* ... */ },
"service_type": "priority_mail",
"tracking_number": "9400111899223100012345"
},
"files": {
"label_pdf": "/storage/<token>/",
"data_csv": "/storage/<token>/"
},
"error_data": null,
"events": []
},
"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. - Review — Check the response. Fix any
failedorders. Reviewcorrectedaddresses. Verify theestimated_total_price. - Confirm —
POST /orders/confirm/{batch_id}. Onlypassedandcorrectedorders are created. Credits are deducted. You get backorder_idvalues. - Poll — Use
GET /orders/{order_id}/orGET /batches/{batch_id}/to check when labels are ready. - Download — When an order is
completed, the response includes proxy-wrapped download URLs for the label PDF (24-hour expiry by default, configurable per deployment).
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.
Pricing is locked at validation time, so confirming within the window guarantees the quoted price.
Failed Orders Are Terminal
If an order moves to failed status after confirmation (the label processor could not generate a label),
it cannot be retried or reprocessed. You must create a new order. The credits for failed orders are automatically
refunded to your balance.
Validation Results
Each order in the validation response has a result field with one of three values:
| Result | Meaning | Action |
|---|---|---|
| passed | All validation passed, address verified as-is | 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.
| Constraint | Limit | Error Code |
|---|---|---|
| Minimum weight | 0.1 lbs (1.6 oz) | WEIGHT_TOO_LIGHT |
| Maximum weight | 70 lbs (1120 oz) | WEIGHT_EXCEEDED |
| Maximum volume | 13,000 cubic inches | 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 |
2 * (width + height). The length + girth constraint
is length + 2 * (width + height) ≤ 165.
Address Verification
During validation, both ship-from and ship-to addresses are verified against USPS data via the StreetVerify service. The verification can produce three outcomes:
| Status | Meaning |
|---|---|
| verified | Address matches USPS records exactly |
| corrected | Address was auto-corrected (e.g., ZIP+4 added, abbreviation standardized). The response includes both original and corrected values. |
| failed | Address could not be verified — likely invalid or undeliverable |
| pending | Verification service temporarily unavailable. The order proceeds without verification. |
| skipped | Verification disabled for this direction in domain settings |
Address Field Limits
| Field | Limit | Error Code |
|---|---|---|
| Address line 1 | 32 chars, required | ADDR_LINE1_TOO_LONG |
| Address line 2 | 32 chars, optional | ADDR_LINE2_TOO_LONG |
| City | 24 chars, required | CITY_TOO_LONG |
| State | 2-letter code | INVALID_STATE |
| ZIP | 5 or 9 digits | INVALID_ZIP |
| Name | 32 chars max | NAME_TOO_LONG |
| Company | 40 chars, optional | COMPANY_TOO_LONG |
Error Codes
Complete reference of error codes returned by the Gateway API. All codes use UPPERCASE_SNAKE_CASE format.
Authentication Errors
| Code | HTTP | When |
|---|---|---|
| AUTH_MISSING | 401 | No Authorization header provided |
| AUTH_TOKEN_INVALID | 401 | API key is malformed, revoked, or does not exist |
| ACCOUNT_SUSPENDED | 403 | Account is deactivated |
| DOMAIN_NOT_ALLOWED | 403 | API key used on wrong domain |
Validation Errors
| Code | HTTP | When |
|---|---|---|
| VALIDATION_ERROR | 400 | One or more fields failed validation — see details.errors[] |
| ADDR_LINE1_REQUIRED | 400 | Address line 1 is missing |
| ADDR_LINE1_TOO_LONG | 400 | Address line 1 exceeds 32 characters |
| INVALID_STATE | 400 | Not a valid 2-letter US state code |
| INVALID_ZIP | 400 | ZIP code must be 5 or 9 digits |
| CITY_TOO_LONG | 400 | City exceeds 24 characters |
| NAME_TOO_LONG | 400 | Name exceeds 32 characters |
| COMPANY_TOO_LONG | 400 | Company exceeds 40 characters |
Order Errors
| Code | HTTP | When |
|---|---|---|
| WEIGHT_EXCEEDED | 400 | Package exceeds 70 lbs (1120 oz) |
| WEIGHT_TOO_LIGHT | 400 | Package under 0.1 lbs (1.6 oz) |
| VOLUME_EXCEEDED | 400 | Package exceeds 13,000 cubic inches |
| DIMENSION_EXCEEDED | 400 | A dimension exceeds 108 inches |
| LENGTH_GIRTH_EXCEEDED | 400 | Length + girth exceeds 165 inches |
| GROUND_ADVANTAGE_WEIGHT_EXCEEDED | 400 | Ground Advantage max is 15.99 oz |
| DUPLICATE_ORDER | 409 | Matching order within 1-hour window. Pass confirm_duplicate: true to bypass. |
| STATE_NOT_ALLOWED | 400 | Destination state not in user's allowlist |
| STATE_BLOCKED | 400 | Destination state in user's blocklist |
| ORDER_CREATION_FAILED | 422 | System failed to create the order |
Batch Errors
| Code | HTTP | When |
|---|---|---|
| BATCH_TOO_LARGE | 400 | Batch exceeds max labels per request (default: 100) |
| BATCH_EXPIRED | 400 | Batch expired (15-minute window). Re-validate. |
| BATCH_ALREADY_CONFIRMED | 409 | Batch was already confirmed |
Billing Errors
| Code | HTTP | When |
|---|---|---|
| INSUFFICIENT_BALANCE | 422 | Balance too low. Details include required_amount, current_balance, shortfall. |
| DAILY_ORDER_LIMIT_EXCEEDED | 422 | Exceeded daily order creation limit |
| SINGLE_ORDER_LIMIT_EXCEEDED | 422 | Order cost exceeds the per-order spend limit |
Address Verification Errors
| Code | HTTP | When |
|---|---|---|
| ADDRESS_VERIFICATION_FAILED | 400 | Address could not be verified — likely undeliverable |
General Errors
| Code | HTTP | When |
|---|---|---|
| RESOURCE_NOT_FOUND | 404 | Order or batch not found (or belongs to another account) |
| RATE_LIMITED | 429 | Too many requests — check Retry-After header |
| INTERNAL_SERVER_ERROR | 500 | Unexpected server error — contact support |
Webhooks
GET /orders/{id}/ and GET /batches/{id}/)
to check order status.