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 ratescarts:writeโ create and modify cartsorders:readโ view orders, labels and trackingorders: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 indataalongsidelinksandmeta(current_page,last_page,total). Pass?page=2to 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,heightin inches, plusweight_oz(orweight_lbs). - International (differing countries) also require
contents_type(merchandise/gift/documents/โฆ),customs_descriptionandcustoms_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
completewith aTESTโฆ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 sameIdempotency-Keyis still in progress.422 Unprocessableโ validation failed, or the balance is too low (withavailableandrequired).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 createdorder.paidโ an order was paid and queued for fulfilmentorder.completedโ all labels are bought and ready to downloadorder.refundedโ one or more labels failed and were refundedtracking.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
}