Skip to content

API documentation

Postage API

The Postage API lets your own server or plugin quote live shipping rates, build a cart and place orders that are paid from your account balance โ€” the same rails as the web store.

Every code example below is shown in cURL, PHP, Node.js, Python, Ruby, Java and Go โ€” pick your language with the tabs.

Authentication

Create a token under Account โ†’ API tokens. Choose only the permissions the integration needs โ€” a token can never do more than the abilities you grant it, nor more than your account can do. The token is shown once; store it securely.

Scopes (abilities)

  • rates:read โ€” quote shipping rates
  • carts:write โ€” create and modify carts
  • orders:read โ€” view orders, labels and tracking
  • orders:write โ€” place orders (spends account balance)
  • wallet:read โ€” view wallet balance and history

A request whose token lacks the required scope returns 403.

Conventions

  • Base URL https://your-postage-domain/api/v1 โ€” every path below is relative to it.
  • Auth send Authorization: Bearer <token> on every request.
  • Bodies may be form-encoded fields or JSON; responses are always JSON.
  • IDs are UUID strings. Money is decimal USD. Timestamps are ISO-8601.
  • Pagination list endpoints (/orders, /wallet) wrap results in data alongside links and meta (current_page, last_page, total). Pass ?page=2 to page through.

Check your token

A quick call that returns your account and the token's granted abilities.

curl https://your-postage-domain/api/v1/user \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://your-postage-domain/api/v1/']);
$res = $client->request('GET', 'user', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN', 'Accept' => 'application/json'],
]);
$data = json_decode($res->getBody());
const res = await fetch('https://your-postage-domain/api/v1/user', {
  method: 'GET',
  headers: { Authorization: 'Bearer YOUR_TOKEN', Accept: 'application/json' },
});
const data = await res.json();
import requests
res = requests.request("GET", "https://your-postage-domain/api/v1/user",
    headers={"Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json"})
data = res.json()
require "net/http"; require "json"; require "uri"
uri = URI("https://your-postage-domain/api/v1/user")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Accept"] = "application/json"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
data = JSON.parse(res.body)
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://your-postage-domain/api/v1/user"))
    .header("Authorization", "Bearer YOUR_TOKEN")
    .header("Accept", "application/json")
    .method("GET", HttpRequest.BodyPublishers.noBody())
    .build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
req, _ := http.NewRequest("GET", "https://your-postage-domain/api/v1/user", nil)
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Accept", "application/json")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))

Response

{ "id": 42, "name": "Jane", "email": "jane@example.com", "abilities": ["rates:read", "orders:write"] }
{ "message": "Unauthenticated." }

1. Quote rates

POST /rates โ€” requires rates:read. Returns live rates; each rate id can be added to a cart. Prices are the total you pay.

curl -X POST https://your-postage-domain/api/v1/rates \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -d package_type=Parcel \
  -d from_name=Acme \
  -d "from_street1=1 Market St" \
  -d "from_city=San Francisco" \
  -d from_state=CA \
  -d from_zip=94105 \
  -d from_country=US \
  -d to_name=Jane \
  -d "to_street1=5 Elm St" \
  -d to_city=Austin \
  -d to_state=TX \
  -d to_zip=78701 \
  -d to_country=US \
  -d length=10 \
  -d width=6 \
  -d height=4 \
  -d weight_oz=32
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://your-postage-domain/api/v1/']);
$res = $client->request('POST', 'rates', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN', 'Accept' => 'application/json'],
    'form_params' => ['package_type' => 'Parcel', 'from_name' => 'Acme', 'from_street1' => '1 Market St', 'from_city' => 'San Francisco', 'from_state' => 'CA', 'from_zip' => '94105', 'from_country' => 'US', 'to_name' => 'Jane', 'to_street1' => '5 Elm St', 'to_city' => 'Austin', 'to_state' => 'TX', 'to_zip' => '78701', 'to_country' => 'US', 'length' => '10', 'width' => '6', 'height' => '4', 'weight_oz' => '32'],
]);
$data = json_decode($res->getBody());
const res = await fetch('https://your-postage-domain/api/v1/rates', {
  method: 'POST',
  headers: { Authorization: 'Bearer YOUR_TOKEN', Accept: 'application/json' },
  body: new URLSearchParams({ package_type: 'Parcel', from_name: 'Acme', from_street1: '1 Market St', from_city: 'San Francisco', from_state: 'CA', from_zip: '94105', from_country: 'US', to_name: 'Jane', to_street1: '5 Elm St', to_city: 'Austin', to_state: 'TX', to_zip: '78701', to_country: 'US', length: '10', width: '6', height: '4', weight_oz: '32' }),
});
const data = await res.json();
import requests
res = requests.request("POST", "https://your-postage-domain/api/v1/rates",
    headers={"Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json"},
    data={"package_type": "Parcel", "from_name": "Acme", "from_street1": "1 Market St", "from_city": "San Francisco", "from_state": "CA", "from_zip": "94105", "from_country": "US", "to_name": "Jane", "to_street1": "5 Elm St", "to_city": "Austin", "to_state": "TX", "to_zip": "78701", "to_country": "US", "length": "10", "width": "6", "height": "4", "weight_oz": "32"})
data = res.json()
require "net/http"; require "json"; require "uri"
uri = URI("https://your-postage-domain/api/v1/rates")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Accept"] = "application/json"
req.set_form_data({ "package_type" => "Parcel", "from_name" => "Acme", "from_street1" => "1 Market St", "from_city" => "San Francisco", "from_state" => "CA", "from_zip" => "94105", "from_country" => "US", "to_name" => "Jane", "to_street1" => "5 Elm St", "to_city" => "Austin", "to_state" => "TX", "to_zip" => "78701", "to_country" => "US", "length" => "10", "width" => "6", "height" => "4", "weight_oz" => "32" })
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
data = JSON.parse(res.body)
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://your-postage-domain/api/v1/rates"))
    .header("Authorization", "Bearer YOUR_TOKEN")
    .header("Accept", "application/json")
    .header("Content-Type", "application/x-www-form-urlencoded")
    .method("POST", HttpRequest.BodyPublishers.ofString("package_type=Parcel&from_name=Acme&from_street1=1+Market+St&from_city=San+Francisco&from_state=CA&from_zip=94105&from_country=US&to_name=Jane&to_street1=5+Elm+St&to_city=Austin&to_state=TX&to_zip=78701&to_country=US&length=10&width=6&height=4&weight_oz=32"))
    .build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
form := url.Values{"package_type": {"Parcel"}, "from_name": {"Acme"}, "from_street1": {"1 Market St"}, "from_city": {"San Francisco"}, "from_state": {"CA"}, "from_zip": {"94105"}, "from_country": {"US"}, "to_name": {"Jane"}, "to_street1": {"5 Elm St"}, "to_city": {"Austin"}, "to_state": {"TX"}, "to_zip": {"78701"}, "to_country": {"US"}, "length": {"10"}, "width": {"6"}, "height": {"4"}, "weight_oz": {"32"}}
req, _ := http.NewRequest("POST", "https://your-postage-domain/api/v1/rates", strings.NewReader(form.Encode()))
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))

Response

{
  "data": [
    { "id": "9c1e...", "carrier": "USPS", "service": "Priority",
      "price": 9.04, "currency": "USD", "delivery_days": 2 }
  ]
}
{ "message": "The from zip field is required.", "errors": { "from_zip": ["The from zip field is required."] } }
{ "message": "Invalid ability provided." }

Rate request fields

  • package_type โ€” a supported type, e.g. Parcel, LargeParcel, FlatRateEnvelope, MediumFlatRateBox.
  • from_* / to_* โ€” name, street1, street2 (optional), city, state, zip, country (2-letter), phone (optional). US addresses use a 5-digit zip and 10-digit phone.
  • Parcel size (non-flat-rate only): length, width, height in inches, plus weight_oz (or weight_lbs).
  • International (differing countries) also require contents_type (merchandise/gift/documents/โ€ฆ), customs_description and customs_value.

2. Create a cart

POST /carts โ€” requires carts:write. API carts are independent of your web-store cart.

curl -X POST https://your-postage-domain/api/v1/carts \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://your-postage-domain/api/v1/']);
$res = $client->request('POST', 'carts', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN', 'Accept' => 'application/json'],
]);
$data = json_decode($res->getBody());
const res = await fetch('https://your-postage-domain/api/v1/carts', {
  method: 'POST',
  headers: { Authorization: 'Bearer YOUR_TOKEN', Accept: 'application/json' },
});
const data = await res.json();
import requests
res = requests.request("POST", "https://your-postage-domain/api/v1/carts",
    headers={"Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json"})
data = res.json()
require "net/http"; require "json"; require "uri"
uri = URI("https://your-postage-domain/api/v1/carts")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Accept"] = "application/json"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
data = JSON.parse(res.body)
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://your-postage-domain/api/v1/carts"))
    .header("Authorization", "Bearer YOUR_TOKEN")
    .header("Accept", "application/json")
    .method("POST", HttpRequest.BodyPublishers.noBody())
    .build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
req, _ := http.NewRequest("POST", "https://your-postage-domain/api/v1/carts", nil)
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Accept", "application/json")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))

Response

{ "data": { "id": "b7a2...", "source": "api", "currency": "USD", "subtotal": 0, "items": [] } }
{ "message": "Invalid ability provided." }

3. Add a rate to the cart

POST /carts/<cart_id>/items โ€” add a quoted rate by id.

curl -X POST https://your-postage-domain/api/v1/carts/<cart_id>/items \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -d rate_id=<rate_id>
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://your-postage-domain/api/v1/']);
$res = $client->request('POST', 'carts/<cart_id>/items', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN', 'Accept' => 'application/json'],
    'form_params' => ['rate_id' => '<rate_id>'],
]);
$data = json_decode($res->getBody());
const res = await fetch('https://your-postage-domain/api/v1/carts/<cart_id>/items', {
  method: 'POST',
  headers: { Authorization: 'Bearer YOUR_TOKEN', Accept: 'application/json' },
  body: new URLSearchParams({ rate_id: '<rate_id>' }),
});
const data = await res.json();
import requests
res = requests.request("POST", "https://your-postage-domain/api/v1/carts/<cart_id>/items",
    headers={"Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json"},
    data={"rate_id": "<rate_id>"})
data = res.json()
require "net/http"; require "json"; require "uri"
uri = URI("https://your-postage-domain/api/v1/carts/<cart_id>/items")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Accept"] = "application/json"
req.set_form_data({ "rate_id" => "<rate_id>" })
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
data = JSON.parse(res.body)
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://your-postage-domain/api/v1/carts/<cart_id>/items"))
    .header("Authorization", "Bearer YOUR_TOKEN")
    .header("Accept", "application/json")
    .header("Content-Type", "application/x-www-form-urlencoded")
    .method("POST", HttpRequest.BodyPublishers.ofString("rate_id=<rate_id>"))
    .build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
form := url.Values{"rate_id": {"<rate_id>"}}
req, _ := http.NewRequest("POST", "https://your-postage-domain/api/v1/carts/<cart_id>/items", strings.NewReader(form.Encode()))
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))

Response

{
  "data": {
    "id": "b7a2...", "subtotal": 9.04,
    "items": [ { "id": "c1d2...", "carrier": "USPS", "service": "Priority", "price": 9.04 } ]
  }
}
{ "message": "Not found." }

Manage the cart

  • GET /api/v1/carts/<cart_id> โ€” the cart with its items and running subtotal.
  • DELETE /api/v1/carts/<cart_id>/items/<item_id> โ€” remove an item.

4. Place the order

POST /orders โ€” requires orders:write. Turns the cart into a paid order, debiting your balance. Send an Idempotency-Key so a retry never charges twice.

curl -X POST https://your-postage-domain/api/v1/orders \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: <uuid>" \
  -d cart_id=<cart_id> \
  -d reference=PO-1001
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://your-postage-domain/api/v1/']);
$res = $client->request('POST', 'orders', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN', 'Accept' => 'application/json', 'Idempotency-Key' => '<uuid>'],
    'form_params' => ['cart_id' => '<cart_id>', 'reference' => 'PO-1001'],
]);
$data = json_decode($res->getBody());
const res = await fetch('https://your-postage-domain/api/v1/orders', {
  method: 'POST',
  headers: { Authorization: 'Bearer YOUR_TOKEN', Accept: 'application/json', 'Idempotency-Key': '<uuid>' },
  body: new URLSearchParams({ cart_id: '<cart_id>', reference: 'PO-1001' }),
});
const data = await res.json();
import requests
res = requests.request("POST", "https://your-postage-domain/api/v1/orders",
    headers={"Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json", "Idempotency-Key": "<uuid>"},
    data={"cart_id": "<cart_id>", "reference": "PO-1001"})
data = res.json()
require "net/http"; require "json"; require "uri"
uri = URI("https://your-postage-domain/api/v1/orders")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Accept"] = "application/json"
req["Idempotency-Key"] = "<uuid>"
req.set_form_data({ "cart_id" => "<cart_id>", "reference" => "PO-1001" })
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
data = JSON.parse(res.body)
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://your-postage-domain/api/v1/orders"))
    .header("Authorization", "Bearer YOUR_TOKEN")
    .header("Accept", "application/json")
    .header("Content-Type", "application/x-www-form-urlencoded")
    .header("Idempotency-Key", "<uuid>")
    .method("POST", HttpRequest.BodyPublishers.ofString("cart_id=<cart_id>&reference=PO-1001"))
    .build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
form := url.Values{"cart_id": {"<cart_id>"}, "reference": {"PO-1001"}}
req, _ := http.NewRequest("POST", "https://your-postage-domain/api/v1/orders", strings.NewReader(form.Encode()))
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Idempotency-Key", "<uuid>")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))

Response

{
  "data": {
    "id": "a41f...", "number": 1042, "status": "ordering", "source": "api", "test": false,
    "reference": "PO-1001", "subtotal": 9.04, "fee": 0.53, "total": 9.57,
    "items": [ { "id": "...", "carrier": "USPS", "service": "Priority", "status": "pending",
                 "tracking_code": null, "label_url": null } ]
  }
}
{ "message": "Insufficient account balance for this order.", "available": 5.00, "required": 9.57 }
{ "message": "A request with this Idempotency-Key is already in progress." }
{ "message": "Not found." }

Sandbox / test mode

Build and verify your whole integration without spending money. Create a Test token (Account โ†’ API tokens โ†’ Mode: Test). Orders placed with a test token:

  • are never charged to your wallet balance;
  • never buy a real label โ€” items return complete with a TESTโ€ฆ tracking code and a sample label;
  • still fire the same webhooks (payloads carry "test": true).

Test orders are marked "test": true in responses. Switch to a live token when you're ready to buy real labels.

Get an order

GET /orders/<order_id> โ€” requires orders:read. Poll it until items are complete and carry a tracking_code and label_url. GET /orders lists them (paginated).

curl https://your-postage-domain/api/v1/orders/<order_id> \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://your-postage-domain/api/v1/']);
$res = $client->request('GET', 'orders/<order_id>', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN', 'Accept' => 'application/json'],
]);
$data = json_decode($res->getBody());
const res = await fetch('https://your-postage-domain/api/v1/orders/<order_id>', {
  method: 'GET',
  headers: { Authorization: 'Bearer YOUR_TOKEN', Accept: 'application/json' },
});
const data = await res.json();
import requests
res = requests.request("GET", "https://your-postage-domain/api/v1/orders/<order_id>",
    headers={"Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json"})
data = res.json()
require "net/http"; require "json"; require "uri"
uri = URI("https://your-postage-domain/api/v1/orders/<order_id>")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Accept"] = "application/json"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
data = JSON.parse(res.body)
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://your-postage-domain/api/v1/orders/<order_id>"))
    .header("Authorization", "Bearer YOUR_TOKEN")
    .header("Accept", "application/json")
    .method("GET", HttpRequest.BodyPublishers.noBody())
    .build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
req, _ := http.NewRequest("GET", "https://your-postage-domain/api/v1/orders/<order_id>", nil)
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Accept", "application/json")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))

Response

{
  "data": {
    "id": "a41f...", "number": 1042, "status": "complete", "test": false, "total": 9.57,
    "items": [ { "carrier": "USPS", "service": "Priority", "status": "complete",
                 "tracking_code": "9400...", "label_url": "https://.../label" } ]
  }
}
{ "message": "Not found." }

Wallet

GET /wallet โ€” requires wallet:read. Current balance plus the ledger (top-ups, order payments, refunds).

curl https://your-postage-domain/api/v1/wallet \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/json"
<?php
$client = new GuzzleHttp\Client(['base_uri' => 'https://your-postage-domain/api/v1/']);
$res = $client->request('GET', 'wallet', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN', 'Accept' => 'application/json'],
]);
$data = json_decode($res->getBody());
const res = await fetch('https://your-postage-domain/api/v1/wallet', {
  method: 'GET',
  headers: { Authorization: 'Bearer YOUR_TOKEN', Accept: 'application/json' },
});
const data = await res.json();
import requests
res = requests.request("GET", "https://your-postage-domain/api/v1/wallet",
    headers={"Authorization": "Bearer YOUR_TOKEN", "Accept": "application/json"})
data = res.json()
require "net/http"; require "json"; require "uri"
uri = URI("https://your-postage-domain/api/v1/wallet")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
req["Accept"] = "application/json"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
data = JSON.parse(res.body)
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://your-postage-domain/api/v1/wallet"))
    .header("Authorization", "Bearer YOUR_TOKEN")
    .header("Accept", "application/json")
    .method("GET", HttpRequest.BodyPublishers.noBody())
    .build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
req, _ := http.NewRequest("GET", "https://your-postage-domain/api/v1/wallet", nil)
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
req.Header.Set("Accept", "application/json")
res, _ := http.DefaultClient.Do(req)
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))

Response

{
  "balance": 40.43,
  "currency": "USD",
  "data": [
    { "type": "order_payment", "amount": -9.57, "balance_after": 40.43, "description": "Order #1042" }
  ]
}
{ "message": "Invalid ability provided." }

Order & item statuses

An order's status moves through pending (awaiting payment) โ†’ ordering (paid, queued) โ†’ processing (buying labels) โ†’ complete (labels ready). It becomes refund if one or more labels failed.

Each item has its own status: pending โ†’ purchased (rate bought, tracking assigned) โ†’ shipment (label image ready) โ†’ complete, or refund on failure.

Download a label

GET /orders/<order_id>/items/<item_id>/label โ€” requires orders:read. Streams the label image (PNG); sandbox orders return a sample. Each item's label_url points here.

curl https://your-postage-domain/api/v1/orders/<order_id>/items/<item_id>/label \
  -H "Authorization: Bearer YOUR_TOKEN" -o label.png
<?php
$client = new GuzzleHttp\Client();
$client->get('https://your-postage-domain/api/v1/orders/<order_id>/items/<item_id>/label', [
    'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'],
    'sink' => 'label.png',
]);
import { writeFileSync } from 'fs';
const res = await fetch('https://your-postage-domain/api/v1/orders/<order_id>/items/<item_id>/label', { headers: { Authorization: 'Bearer YOUR_TOKEN' } });
writeFileSync('label.png', Buffer.from(await res.arrayBuffer()));
import requests
res = requests.get("https://your-postage-domain/api/v1/orders/<order_id>/items/<item_id>/label", headers={"Authorization": "Bearer YOUR_TOKEN"})
open("label.png", "wb").write(res.content)
require "net/http"; require "uri"
uri = URI("https://your-postage-domain/api/v1/orders/<order_id>/items/<item_id>/label")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer YOUR_TOKEN"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
File.binwrite("label.png", res.body)
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder()
    .uri(URI.create("https://your-postage-domain/api/v1/orders/<order_id>/items/<item_id>/label"))
    .header("Authorization", "Bearer YOUR_TOKEN")
    .build();
client.send(req, HttpResponse.BodyHandlers.ofFile(Path.of("label.png")));
req, _ := http.NewRequest("GET", "https://your-postage-domain/api/v1/orders/<order_id>/items/<item_id>/label", nil)
req.Header.Set("Authorization", "Bearer YOUR_TOKEN")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
out, _ := os.Create("label.png")
io.Copy(out, res.Body)

Full flow

The whole sequence โ€” quote, cart, add rate, place order โ€” end to end. Set POSTAGE_TOKEN in your environment first.

BASE=https://your-postage-domain/api/v1
AUTH="Authorization: Bearer $POSTAGE_TOKEN"

RATE=$(curl -s -X POST $BASE/rates -H "$AUTH" \
  -d package_type=Parcel -d from_name=Acme -d from_zip=94105 -d from_country=US \
  -d to_name=Jane -d to_zip=78701 -d to_country=US \
  -d length=10 -d width=6 -d height=4 -d weight_oz=32 | jq -r '.data[0].id')

CART=$(curl -s -X POST $BASE/carts -H "$AUTH" | jq -r '.data.id')
curl -s -X POST $BASE/carts/$CART/items -H "$AUTH" -d rate_id=$RATE > /dev/null
curl -s -X POST $BASE/orders -H "$AUTH" -H "Idempotency-Key: $(uuidgen)" \
  -d cart_id=$CART -d reference=PO-1001 | jq '.data.number'
<?php
$client = new GuzzleHttp\Client([
    'base_uri' => 'https://your-postage-domain/api/v1/',
    'headers'  => ['Authorization' => 'Bearer '.getenv('POSTAGE_TOKEN'), 'Accept' => 'application/json'],
]);
$shipment = ['package_type' => 'Parcel', 'from_name' => 'Acme', 'from_zip' => '94105', 'from_country' => 'US',
             'to_name' => 'Jane', 'to_zip' => '78701', 'to_country' => 'US',
             'length' => 10, 'width' => 6, 'height' => 4, 'weight_oz' => 32];
$rates = json_decode($client->post('rates', ['form_params' => $shipment])->getBody());
$cart  = json_decode($client->post('carts')->getBody())->data;
$client->post("carts/{$cart->id}/items", ['form_params' => ['rate_id' => $rates->data[0]->id]]);
$order = json_decode($client->post('orders', [
    'headers' => ['Idempotency-Key' => bin2hex(random_bytes(16))],
    'form_params' => ['cart_id' => $cart->id, 'reference' => 'PO-1001'],
])->getBody());
echo $order->data->number, PHP_EOL;
const BASE = 'https://your-postage-domain/api/v1';
const H = { Authorization: `Bearer ${process.env.POSTAGE_TOKEN}`, Accept: 'application/json', 'Content-Type': 'application/json' };
const api = (path, body, extra = {}) =>
  fetch(`${BASE}/${path}`, { method: 'POST', headers: { ...H, ...extra }, body: body && JSON.stringify(body) }).then(r => r.json());

const shipment = { package_type: 'Parcel', from_name: 'Acme', from_zip: '94105', from_country: 'US',
  to_name: 'Jane', to_zip: '78701', to_country: 'US', length: 10, width: 6, height: 4, weight_oz: 32 };
const { data: rates } = await api('rates', shipment);
const { data: cart }  = await api('carts');
await api(`carts/${cart.id}/items`, { rate_id: rates[0].id });
const { data: order } = await api('orders', { cart_id: cart.id, reference: 'PO-1001' }, { 'Idempotency-Key': crypto.randomUUID() });
console.log(order.number);
import os, uuid, requests
BASE = "https://your-postage-domain/api/v1"
s = requests.Session()
s.headers.update({"Authorization": f"Bearer {os.environ['POSTAGE_TOKEN']}", "Accept": "application/json"})

shipment = {"package_type": "Parcel", "from_name": "Acme", "from_zip": "94105", "from_country": "US",
  "to_name": "Jane", "to_zip": "78701", "to_country": "US", "length": 10, "width": 6, "height": 4, "weight_oz": 32}
rates = s.post(f"{BASE}/rates", data=shipment).json()["data"]
cart  = s.post(f"{BASE}/carts").json()["data"]
s.post(f"{BASE}/carts/{cart['id']}/items", data={"rate_id": rates[0]["id"]})
order = s.post(f"{BASE}/orders", data={"cart_id": cart["id"], "reference": "PO-1001"},
               headers={"Idempotency-Key": str(uuid.uuid4())}).json()["data"]
print(order["number"])
require "net/http"; require "json"; require "uri"; require "securerandom"
BASE, TOKEN = "https://your-postage-domain/api/v1", ENV["POSTAGE_TOKEN"]

def api(path, form = nil, extra = {})
  uri = URI("#{BASE}/#{path}")
  req = Net::HTTP::Post.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"; req["Accept"] = "application/json"
  extra.each { |k, v| req[k] = v }
  req.set_form_data(form) if form
  JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
end

rate = api("rates", { package_type: "Parcel", from_name: "Acme", from_zip: "94105", from_country: "US",
  to_name: "Jane", to_zip: "78701", to_country: "US", length: 10, width: 6, height: 4, weight_oz: 32 })["data"].first
cart = api("carts")["data"]
api("carts/#{cart['id']}/items", { rate_id: rate["id"] })
order = api("orders", { cart_id: cart["id"], reference: "PO-1001" }, { "Idempotency-Key" => SecureRandom.uuid })["data"]
puts order["number"]
// Uses a JSON library of your choice to read ids from each response body.
var client = HttpClient.newHttpClient();
var BASE = "https://your-postage-domain/api/v1/";
var TOKEN = System.getenv("POSTAGE_TOKEN");

BiFunction<String, String, String> post = (path, form) -> {
    try {
        var req = HttpRequest.newBuilder().uri(URI.create(BASE + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString(form)).build();
        return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
    } catch (Exception e) { throw new RuntimeException(e); }
};

String rates = post.apply("rates", "package_type=Parcel&from_name=Acme&from_zip=94105&from_country=US"
    + "&to_name=Jane&to_zip=78701&to_country=US&length=10&width=6&height=4&weight_oz=32");
String rateId = /* json: rates.data[0].id */ "";
String cart = post.apply("carts", "");
String cartId = /* json: cart.data.id */ "";
post.apply("carts/" + cartId + "/items", "rate_id=" + rateId);
System.out.println(post.apply("orders", "cart_id=" + cartId + "&reference=PO-1001"));
// Decode each response body with encoding/json to read the ids.
base, token := "https://your-postage-domain/api/v1/", os.Getenv("POSTAGE_TOKEN")
post := func(path, form string) []byte {
    req, _ := http.NewRequest("POST", base+path, strings.NewReader(form))
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    res, _ := http.DefaultClient.Do(req)
    body, _ := io.ReadAll(res.Body)
    return body
}

post("rates", "package_type=Parcel&from_name=Acme&from_zip=94105&from_country=US"+
    "&to_name=Jane&to_zip=78701&to_country=US&length=10&width=6&height=4&weight_oz=32")
// parse rateId, then:
post("carts", "")
// parse cartId, then:
post("carts/"+cartId+"/items", "rate_id="+rateId)
fmt.Println(string(post("orders", "cart_id="+cartId+"&reference=PO-1001")))

Errors, status codes & rate limits

Errors return a JSON body with a message. Common statuses:

  • 200 OK โ€” success (also an idempotent replay of a prior order).
  • 201 Created โ€” a cart or order was created.
  • 401 Unauthorized โ€” missing or invalid token.
  • 403 Forbidden โ€” the token lacks the required scope.
  • 404 Not Found โ€” the resource doesn't exist or isn't yours.
  • 409 Conflict โ€” an order with the same Idempotency-Key is still in progress.
  • 422 Unprocessable โ€” validation failed, or the balance is too low (with available and required).
  • 429 Too Many Requests โ€” rate limit exceeded.
  • 503 Service Unavailable โ€” the API is temporarily disabled.

Rate limits. The API allows 120 requests per minute per token. When exceeded you get 429 with a Retry-After header โ€” back off and retry.

Error responses

Every error returns a JSON body with a message. Pick a status to see its shape.

Response

{ "message": "Unauthenticated." }
{ "message": "Invalid ability provided." }
{ "message": "Not found." }
{ "message": "A request with this Idempotency-Key is already in progress." }
{ "message": "The from zip field is required.", "errors": { "from_zip": ["The from zip field is required."] } }
{ "message": "Insufficient account balance for this order.", "available": 5.00, "required": 9.57 }
{ "message": "Too Many Attempts." }
{ "message": "The API is temporarily unavailable." }

Webhooks

Register endpoints under Account โ†’ Webhooks. We POST a JSON body to your URL when something happens; pick the events you want (or receive all). Deliveries and their outcomes are shown on that page and can be resent.

Events

  • order.created โ€” an order was created
  • order.paid โ€” an order was paid and queued for fulfilment
  • order.completed โ€” all labels are bought and ready to download
  • order.refunded โ€” one or more labels failed and were refunded
  • tracking.updated โ€” a shipment tracking status changed

Payload & headers

Each request carries X-Postage-Event (the event type), X-Postage-Delivery (a unique id โ€” use it to de-duplicate retries) and X-Postage-Signature (sha256=<hmac> of the raw body).

{
  "id": "d4e5...", "event": "order.completed", "created_at": "2026-07-10T09:25:00+00:00",
  "data": { "id": "a41f...", "number": 1042, "status": "complete", "test": false, "total": 9.57 }
}

Delivery & retries

Respond 2xx quickly. A non-2xx response or timeout is retried up to 5 times with backoff (10s, 1m, 5m, 15m). Treat X-Postage-Delivery as an idempotency key on your side.

Verify a webhook signature

Compute an HMAC-SHA256 of the raw request body with your endpoint's signing secret and compare it to the X-Postage-Signature header, in constant time.

$payload  = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $payload, $ENDPOINT_SECRET);
if (! hash_equals($expected, $_SERVER['HTTP_X_POSTAGE_SIGNATURE'] ?? '')) {
    http_response_code(400);
    exit;
}
$event = json_decode($payload, true);
const crypto = require('crypto');
// rawBody = the exact bytes of the request body
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(req.headers['x-postage-signature'] || ''),
);
if (!ok) return res.status(400).end();
import hmac, hashlib
expected = "sha256=" + hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Postage-Signature", "")):
    abort(400)
require "openssl"
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, request.raw_post)
unless Rack::Utils.secure_compare(expected, request.env["HTTP_X_POSTAGE_SIGNATURE"].to_s)
  halt 400
end
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;

Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes()));
// signature = the X-Postage-Signature header
if (!MessageDigest.isEqual(expected.getBytes(), signature.getBytes())) {
    throw new SecurityException("bad webhook signature");
}
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "net/http"
)

mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Postage-Signature"))) {
    http.Error(w, "bad signature", http.StatusBadRequest)
    return
}
Open support chat