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.

Base URL https://app.{your-domain}/gateway/v1

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.

Generate API keys from Dashboard → Settings → API Keys. Each key is scoped to your account and domain. You can create multiple keys and revoke them individually.

Header Format

All API keys are prefixed with gw_ followed by 48 random characters. Send the key as a Bearer token:

Authorization: Bearer gw_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

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
If your key is compromised, revoke it immediately from the dashboard and create a new one. All requests with a revoked key will return 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);
200 — Authenticated response
{
  "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"
}
401 — Missing or invalid key
{
  "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

CodeMeaning
200Success
201Resource created
400Validation error — check error.details
401Missing or invalid API key
403Account suspended or insufficient permissions
404Resource not found
409Conflict — duplicate order, batch already confirmed
422Insufficient balance or unprocessable
429Rate limit exceeded — check Retry-After header
500Internal server error
Error response format
{
  "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"
}
Insufficient balance (422)
{
  "success": false,
  "data": null,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "details": {
      "required_amount": 45.50,
      "current_balance": 12.00,
      "shortfall": 33.50
    }
  },
  "message": "Insufficient balance"
}
Rate limited (429)
{
  "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.

ScopeLimit
General100 requests / minute
Order creation30 requests / minute

Rate Limit Headers

Every response includes rate limit headers so you can track your usage:

HeaderDescription
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)
Rate limit headers example
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1712150400
Content-Type: application/json
Handling rate limits (Python)
import time
import requests

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

        if response.status_code != 429:
            return response

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

    raise Exception("Rate limit exceeded")

Get Wallet

Retrieve the deposit wallet address for your account. Deposit USDC or USDT (ERC-20) to this address to add credits to your balance.

GET /gateway/v1/wallet/

Response Fields

FieldTypeDescription
address string Ethereum wallet address (0x...)
network string Network identifier (always Ethereum (ERC-20))
You must generate a wallet from the dashboard before this endpoint will return an address. If no wallet exists, the API returns 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);
200 — Response
{
  "success": true,
  "data": {
    "address": "0x1a2b3c4d5e6f7890abcdef1234567890abcdef12",
    "network": "Ethereum (ERC-20)"
  },
  "error": null,
  "message": "OK"
}

Account Info

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

GET /gateway/v1/account/

Response Fields

FieldTypeDescription
balance number 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")}");
200 — Response
{
  "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.

POST /gateway/v1/orders/validate

Request Body

FieldTypeDescription
ordersrequired array Array of order objects (max 100 per batch)

Order Object

FieldTypeDescription
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

FieldTypeDescription
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

FieldTypeDescription
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);
200 — Validation response
{
  "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"
}
Failed order in batch
// 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.

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

Path Parameters

ParameterTypeDescription
batch_idrequired string Batch ID from the validate response (e.g., batch_6615a3f2...)
Batches expire 15 minutes after validation. After expiry, you must re-validate. A batch can only be confirmed once — attempting to confirm again returns 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);
200 — Confirmation response
{
  "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"
}
400 — Batch expired
{
  "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.

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

Path Parameters

ParameterTypeDescription
order_idrequired string Order ID from the confirm response

Order Statuses

StatusDescription
pending Order created, waiting 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
Polling recommendation: Start polling every 2 seconds. Use exponential backoff (1.5x multiplier) up to a 30-second interval. Stop after 2 minutes and show a “check back later” message. For batches, use the Batch Status endpoint instead of polling each order individually.
curl https://app.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);
}
Pending response
{
  "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"
}
Completed response
{
  "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.

GET /gateway/v1/batches/{batch_id}/

Aggregate Statuses

StatusMeaning
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;
}
200 — Batch status response
{
  "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.

GET /gateway/v1/orders/

Query Parameters

ParameterTypeDescription
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);
200 — Paginated response
{
  "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.

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

Response Includes

  • order_details — Ship-from, ship-to, package, service type, tracking number
  • files — 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 details
  • events — 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);
200 — Full detail response
{
  "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

  1. ValidatePOST /orders/validate with your orders. Each order is individually validated (address, dimensions, pricing, dedup). You get back a batch_id and per-order results.
  2. Review — Check the response. Fix any failed orders. Review corrected addresses. Verify the estimated_total_price.
  3. ConfirmPOST /orders/confirm/{batch_id}. Only passed and corrected orders are created. Credits are deducted. You get back order_id values.
  4. Poll — Use GET /orders/{order_id}/ or GET /batches/{batch_id}/ to check when labels are ready.
  5. 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).
Tip: If an order is corrected, the corrected addresses are automatically applied when you confirm. You do not need to resubmit with corrected data.

Batch Expiry

Validated batches expire after 15 minutes. If the batch expires before you confirm, you will receive a BATCH_EXPIRED error. Simply re-validate your orders. 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:

ResultMeaningAction
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.

ConstraintLimitError 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

ValueDescriptionWeight Range
priority_mail USPS Priority Mail (1–3 business days) 1.6 – 1120 oz
ground_advantage USPS Ground Advantage (2–5 business days) 1.6 – 15.99 oz
Girth formula: 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:

StatusMeaning
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

FieldLimitError 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

CodeHTTPWhen
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

CodeHTTPWhen
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

CodeHTTPWhen
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

CodeHTTPWhen
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

CodeHTTPWhen
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

CodeHTTPWhen
ADDRESS_VERIFICATION_FAILED 400 Address could not be verified — likely undeliverable

General Errors

CodeHTTPWhen
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

Coming soon. Webhook support is planned for a future release. In the meantime, use the polling endpoints (GET /orders/{id}/ and GET /batches/{id}/) to check order status.