On this page
- Authentication
- Quick start
- Payments & balance
- International & customs
- Shipments & invoices
- Errors & rate limits
- GET/api/v1/current
- GET/api/v1/customers
- GET/api/v1/shipping_addresses
- POST/api/v1/shipping_addresses
- GET/api/v1/invoices
- GET/api/v1/invoices/:id
- GET/api/v1/orders
- POST/api/v1/orders
- GET/api/v1/orders/:id
- GET/api/v1/orders/:id/shipping_rates
- POST/api/v1/orders/:id/refresh_shipping_rates
- POST/api/v1/orders/:id/purchase
- GET/api/v1/orders/:id/label
- POST/api/v1/orders/:id/void_shipment
PackageSave API
A RESTful API for managing shipping and logistics data.
Authentication
The PackageSave API uses JWT tokens for authentication. You can obtain your API token from your team's account settings page.
Using your API token
Include your token in the Authorization header on every request:
⚠️ Security Note
Keep your API token secure and never share it publicly. Anyone with this token can access your team's data. For your security, tokens are accepted only in the Authorization header — never as a URL query parameter (which would be logged and cached).
Authorization Header
Authorization: Bearer YOUR_JWT_TOKEN
Quick start
Buy your first label in five steps. Each step links to the full endpoint reference below.
1. Get your API token
Copy it from your account settings page and send it on every request in the Authorization header.
2. Create an order
Post the recipient + packages to POST /api/v1/orders. Rate fetching starts automatically in the background. Save the returned id (the order number).
3. Poll for rates
Poll GET /api/v1/orders/:id/shipping_rates every few seconds until status is "ready". A "failed" status tells you why no rates could be fetched.
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders/PS-8JBH6B/shipping_rates"
4. Purchase a label
Pick a rate id and post it to POST /api/v1/orders/:id/purchase. A 402 means the purchase couldn't be paid for — branch on its code field.
curl -X POST \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"shipping_rate_id": 789}' \
"https://www.packagesave.com/api/v1/orders/PS-8JBH6B/purchase"
5. Download the label
Poll GET /api/v1/orders/:id until label_available is true, then fetch the PDF from GET /api/v1/orders/:id/label.
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders/PS-8JBH6B/label" -o label.pdf
Next steps
- List and track your shipments — filter by platform, delivery status, or purchase window.
- Reconcile shipments with your weekly statements — tie every charged dollar back to an order.
- Ship internationally — attach customs data at order creation.
Payments & balance
Buying a label (POST /api/v1/orders/:id/purchase) always requires a payment method on file. Teams are billed in one of two ways:
- Weekly billing (default) — labels are charged on your weekly statement. No balance is required up front.
- Funds balance — your team must hold enough account credit to cover a label's estimated total (including tax) before purchase. Add funds on the Payments page in the web app.
Read your team's mode and balance from GET /api/v1/current:
| Field | Type | Description |
|---|---|---|
| wallet_required | boolean | true when your team must hold a funds balance before buying a label. false for weekly-billed teams. |
| account_credit | object | Available credit per currency as money strings, e.g. { "CAD": "50.00" }. This is the live balance the next purchase draws against. |
| auto_topup_enabled | boolean | true when auto top-up is armed, so your balance replenishes automatically and purchases won't block on a low balance. |
💡 Tip
If your team is on a funds balance, enable auto top-up on the Payments page so your balance replenishes automatically and purchases never block. Adding funds is done in the web app — there is no add-funds API endpoint.
When a balance-funded team has insufficient balance, the purchase endpoint returns 402 Payment Required with a machine-readable code and the exact shortfall, so you can top up the right amount. See the purchase endpoint error responses.
To reconcile what was billed against your shipments, see Shipments & invoices.
International & customs
An international order (ship-from and ship-to in different countries) needs customs data before a label can be purchased. The commercial invoice is generated automatically from this data at label creation. The flow is the same as a domestic order, with customs attached up front:
- 1. Create with customs —
POST /api/v1/ordersaccepts a top-levelcustoms_line_itemsarray plus the order-level fields below. Customs can't be edited via the API after creation, so send it complete. - 2. Poll rates —
GET /api/v1/orders/:id/shipping_rates, exactly as for domestic orders. - 3. Purchase —
POST /api/v1/orders/:id/purchasevalidates customs synchronously (see the pre-flight checks below) before creating the label.
Customs line items
One entry per distinct product in the shipment. Each line item takes:
| Field | Required | Description |
|---|---|---|
| customs_description | Yes | What the item is, in plain words (max 35 characters). Generic descriptions like "Package contents" are rejected at purchase. |
| harmonized_code | Yes | Harmonized System (HS) tariff code — 6 to 15 alphanumeric characters; dots and dashes are accepted (e.g. 6109.10). |
| country_of_origin | Yes | Where the item was manufactured — ISO 3166 two-letter code (e.g. CA, US, CN). |
| unit_value | Yes | Per-unit value, greater than zero, in the order's customs_currency. |
| quantity | Yes | Whole number of units, greater than zero. |
| merchant_product_id / manufacturer_product_id / standardized_product_id | No | Product identifiers (each max 35 characters): your SKU, the manufacturer's part number, and a GTIN/EAN/UPC. EU-bound consumer shipments valued at EUR 150 or less should carry them — mandatory on the commercial invoice from November 1, 2026. |
A maximum of 100 line items per order. Total declared customs value (sum of unit_value × quantity) is capped at $50,000 per international shipment — a higher total is rejected with 422 at create and at purchase.
Order-level customs fields
All optional — sensible defaults apply when omitted:
| Field | Type | Description |
|---|---|---|
| reason_for_export | string | One of SALE (default), GIFT, SAMPLE, REPAIR, RETURN, INTERCOMPANYDATA, OTHER. |
| customs_currency | string | USD or CAD — the currency your unit_values are declared in. Defaults: Canada→US orders declare in USD (US customs expects USD declarations); all other routes declare in the origin country's currency. Echoed on every order response. |
| customs_declaration_statement | string | Free-text declaration printed on the commercial invoice (max 75 characters). |
| customs_comments | string | Free-text comments printed on the commercial invoice (max 150 characters). |
| consignee_type | string | consumer (default) or business — the B2C/B2B indicator EU customs asks for on EU-bound shipments. |
Recipient tax ID
Some destinations ask for the recipient's tax ID on the customs entry (for example, a US EIN/SSN/ITIN on US-bound formal entries, or South Korea's PCCC on personal imports). Pass it on the order's shipping_address: tax_id (max 15 characters) plus an optional tax_id_type qualifier. When the type is omitted, a country-appropriate default is inferred. It prints on the commercial invoice.
| tax_id_type value | Meaning |
|---|---|
| EIN | EIN (US) |
| SSN | SSN (US) |
| GST_HST | GST/HST (Canada) |
| CRA_BN | CRA Business Number (Canada) |
| VAT | VAT (EU) |
| EORI | EORI (UK/EU) |
| RFC | RFC (Mexico) |
| CNPJ | CNPJ (Brazil — business) |
| CPF | CPF (Brazil — individual) |
| CUIT | CUIT (Argentina — business) |
| CUIL | CUIL (Argentina — individual) |
| RUT | RUT (Chile) |
| RUC | RUC (Peru/Ecuador) |
| NIT | NIT (Colombia) |
| PCCC | PCCC (South Korea) |
| OTHER | Other |
Purchase pre-flight checks
POST /api/v1/orders/:id/purchase validates international orders synchronously and returns 422 with a machine-readable code instead of failing in the background:
| code | Meaning |
|---|---|
| customs_info_incomplete | The order has no complete customs data — provide customs_line_items when creating the order. |
| customs_value_exceeds_limit | Total declared customs value is over the $50,000 limit. |
| international_contact_info_incomplete | International shipments need a recipient email and phone — set shipping_address.email and shipping_address.phone. |
Each returns a 422 body you can branch on:
{
"error": "Customs information is incomplete. International orders need customs line items with a description, harmonized (HS) code, country of origin, positive unit value and quantity. Provide customs_line_items when creating the order, or complete the customs step in the web app.",
"code": "customs_info_incomplete"
}
Example: Canada→US order with customs
curl -X POST https://www.packagesave.com/api/v1/orders \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"customer": { "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com" },
"shipping_address": {
"line1": "350 5th Ave", "city": "New York", "state": "NY",
"postal_code": "10118", "country": "US",
"email": "jane@example.com", "phone": "+1 212 555 0100"
},
"packages": [{ "weight": 1.2, "length": 30, "width": 20, "height": 10, "value": 120 }],
"customs_line_items": [
{
"customs_description": "Cotton t-shirt",
"harmonized_code": "6109.10",
"country_of_origin": "CA",
"unit_value": 30.00,
"quantity": 4,
"merchant_product_id": "TSHIRT-BLK-M"
}
],
"reason_for_export": "SALE",
"customs_comments": "Order 1042 from our web store"
}'
The response echoes everything back so you can verify what will print on the commercial invoice (abbreviated):
{
"order": {
"id": "PS-8JBH6B",
"status": "pending",
"reason_for_export": "SALE",
"customs_currency": "USD",
"customs_declaration_statement": null,
"customs_comments": "Order 1042 from our web store",
"consignee_type": "consumer",
"customs_line_items": [
{
"id": 501,
"customs_description": "Cotton t-shirt",
"harmonized_code": "6109.10",
"country_of_origin": "CA",
"unit_value": "30.0",
"quantity": 4,
"position": 0,
"merchant_product_id": "TSHIRT-BLK-M",
"manufacturer_product_id": null,
"standardized_product_id": null
}
]
}
}
💡 Delivered Duty Paid (DDP)
Teams enrolled in the Canadian DDP program get DDP applied automatically at label creation on Canada-bound imports — duties bill to the configured importer account and the commercial invoice carries DDP terms. It is derived from your team's configuration and the route; there are no per-order API fields for it. Return-service rates are automatically hidden for DDP forward imports, and purchasing one is blocked with ddp_forward_requires_forward_rate when a forward rate exists.
⚠️ Not available via the API
Uploading your own customs documents, CERS Proof of Report entry for Canadian exports, and editing customs data after order creation are web-app-only. To change customs on an unpurchased order via the API, create a new order instead (or edit the existing order in the web app).
Errors & rate limits
Errors return a JSON body with a human-readable error message. Where a client may need to branch, a stable machine-readable code is included — branch on code, never on the message text.
| Status | code | Meaning |
|---|---|---|
| 401 | — | Missing, invalid, revoked, or expired token (expired tokens say "Token expired"). Regenerate on the account settings page. |
| 402 | payment_method_required / payment_overdue / insufficient_prepaid_balance | The purchase couldn't be paid for — see the purchase endpoint and Payments. |
| 403 | team_not_verified | Your token is valid but your team's account isn't verified yet (or verification was revoked). Verification is completed in the web app. |
| 404 | — | Resource not found (or it belongs to another team). |
| 422 | customs_info_incomplete / customs_value_exceeds_limit / international_contact_info_incomplete (purchase only) | Validation failure — the body carries an errors object keyed by field, or an error string (e.g. expired rate, order not in a purchasable state). International purchases add the customs codes — see International & customs. |
| 429 | — | Rate limited. Voids: 10 per 5 minutes. Rate refreshes: 30 per minute. Back off and retry. |
| 503 | — | Retryable carrier-side state (void of a label UPS is still processing) — the body includes retryable: true and retry_after_seconds. |
💡 Async failures
Purchasing runs in the background, so an order can fail after a successful 200 — poll GET /api/v1/orders/:id; a failed order carries failed_reason, failed_at, and a structured failure_details.
GET/api/v1/current
Returns information about the current authenticated team.
Response
{
"team_current": {
"id": 1,
"name": "Acme Corp",
"wallet_required": false,
"account_credit": {
"CAD": "50.00"
},
"auto_topup_enabled": true,
"address": {
"id": 1,
"name": "Headquarters",
"line1": "123 Main St",
"line2": null,
"line3": null,
"city": "Toronto",
"state": "ON",
"postal_code": "M5H 2N2",
"country": "CA",
"primary": true,
"residential": false,
"full_address": "123 Main St, Toronto, ON, M5H 2N2, CA"
}
}
}
Example Request
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
https://www.packagesave.com/api/v1/current
GET/api/v1/customers
Returns a paginated list of customers for the authenticated team.
Parameters
| Parameter | Type | Description |
|---|---|---|
page |
integer | Page number (default: 1) |
per_page |
integer | Items per page (1-100, default: 20) |
q |
string | Search customers by name or email |
Response
{
"customers": [
{
"id": 1,
"first_name": "John",
"last_name": "Doe",
"company_name": "Acme Inc",
"email": "john@acme.com",
"phone_number": "+1-555-0123",
"name": "John Doe",
"created_at": "2023-01-15T10:30:00Z",
"updated_at": "2023-01-15T10:30:00Z"
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total_count": 45,
"total_pages": 3
}
}
Example Requests
Basic listing
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/customers"
With pagination
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/customers?page=2&per_page=10"
With search
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/customers?q=john@acme.com"
GET/api/v1/shipping_addresses
Returns sender addresses for the authenticated team. Use these addresses when creating orders via the API.
Parameters
| Parameter | Type | Description |
|---|---|---|
type |
string | "sender" (default) or "all" |
primary |
boolean | "true" to only return primary address |
page |
integer | Page number (default: 1) |
per_page |
integer | Items per page (1-100, default: 20) |
Response
{
"shipping_addresses": [
{
"id": 1,
"name": "Warehouse",
"line1": "123 Main St",
"line2": null,
"line3": null,
"city": "Toronto",
"state": "ON",
"postal_code": "M5H 2N2",
"country": "CA",
"primary": true,
"residential": false,
"tax_id": null,
"full_address": "123 Main St, Toronto, ON, M5H 2N2, CA"
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total_count": 3,
"total_pages": 1
}
}
Example Request
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/shipping_addresses"
POST/api/v1/shipping_addresses
Creates a new sender address for the team.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
shipping_address.name |
string | Yes | Address name (e.g., "Warehouse") |
shipping_address.line1 |
string | Yes | Street address |
shipping_address.city |
string | Yes | City |
shipping_address.state |
string | Yes | State/Province code |
shipping_address.postal_code |
string | Yes | ZIP/Postal code |
shipping_address.country |
string | Yes | Country code (CA, US) |
shipping_address.primary |
boolean | No | Set as primary address |
shipping_address.line2 / line3 |
string | No | Additional address lines (suite, unit, etc.) |
shipping_address.email |
string | No | Contact email for this sender address |
shipping_address.phone |
string | No | Contact phone for this sender address |
shipping_address.residential |
boolean | No | Whether the address is residential (default: false) |
shipping_address.tax_id |
string | No | Merchant Tax ID for this sender address (printed on international customs paperwork). Up to 15 alphanumeric characters. |
shipping_address.tax_id_type |
string | No | Qualifier for the Tax ID (e.g. "EIN", "GST_HST", "VAT"). Inferred from the address country when omitted. |
Example Request
curl -X POST \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"shipping_address": {
"name": "Warehouse",
"line1": "123 Main St",
"city": "Toronto",
"state": "ON",
"postal_code": "M5H 2N2",
"country": "CA",
"primary": true
}
}' \
"https://www.packagesave.com/api/v1/shipping_addresses"
GET/api/v1/orders
Lists your team's orders, newest purchase first (draft orders sort by creation time). Paginated; filterable by lifecycle status, delivery status, sales platform, purchase window, and free-text search. Archived and merged orders are excluded.
Parameters
| Parameter | Type | Description |
|---|---|---|
| page | integer | Page number (default 1). |
| per_page | integer | Orders per page (default 20, maximum 100). |
| status | string | Order lifecycle status: pending, processing, created, purchased, completed, voided, cancelled, failed. |
| delivery_status | string | Carrier tracking state: label_created, in_transit, at_customs, customs_cleared, out_for_delivery, at_access_point, delivered, exception, returned. |
| source | string | Sales platform: shopify, ebay, or manual. |
| purchased_after / purchased_before | string | ISO8601 date or timestamp bounds on the label purchase time. Orders that haven't been purchased yet are excluded when either bound is set. |
| q | string | Free-text search across customer name/email, tracking number, and order number. |
An unknown filter value returns 422 with code: "invalid_filter" and a message listing the accepted values — filters are never silently ignored.
Response
Each order carries the full order object (same shape as GET /api/v1/orders/:id), including the correlation fields — source, shopify_order_id, ebay_order_id, shopify_order_name, purchased_at, delivery_status, total (the final charge including tax), tracking_numbers (one per package), and invoice_id (the weekly statement the label charge was billed on; null until the weekly billing run). Abbreviated:
{
"orders": [
{
"id": "PS-8JBH6B",
"order_number": "PS-8JBH6B",
"status": "purchased",
"delivery_status": "in_transit",
"purchased_at": "2026-07-08T14:03:22Z",
"total": "27.61",
"currency": "CAD",
"source": "shopify",
"shopify_order_id": "5723201847311",
"ebay_order_id": null,
"shopify_order_name": "#1042",
"tracking_number": "1Z0JA0676704591639",
"tracking_numbers": ["1Z0JA0676704591639"],
"invoice_id": 2187
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total_count": 134,
"total_pages": 7
}
}
Example Requests
# Shopify shipments purchased since July 1
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders?source=shopify&purchased_after=2026-07-01"
# Find an order by tracking number
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders?q=1Z0JA0676704591639"
POST/api/v1/orders
Creates a new order with customer and shipping information. Automatically enqueues a background job to fetch shipping rates.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| customer | object | Yes | Customer information |
| customer.first_name | string | Yes | Customer first name |
| customer.last_name | string | Yes | Customer last name |
| customer.email | string | No | Customer email (used for lookup) |
| customer.phone_number | string | No | Customer phone number |
| customer.company_name | string | No | Customer company name |
| shipping_address | object | Yes | Shipping address information |
| shipping_address.name | string | No | Recipient name printed on the label. Defaults to the customer's name if omitted. |
| shipping_address.contact_name | string | No | Optional contact person; prints as the UPS Attention line. |
| shipping_address.line1 | string | Yes | Street address line 1 |
| shipping_address.line2 | string | No | Street address line 2 (apt, suite, etc.) |
| shipping_address.line3 | string | No | Street address line 3 (additional info) |
| shipping_address.city | string | Yes | City name |
| shipping_address.state | string | Yes | State/Province code (e.g., CA, ON) |
| shipping_address.postal_code | string | Yes | ZIP/Postal code |
| shipping_address.country | string | Yes | Country code (US, CA) |
| shipping_address.residential | boolean | No | Whether address is residential (default: false) |
| packages | array | Yes | Array of package objects |
| packages[].weight | decimal | Yes | Package weight in kilograms |
| packages[].length | decimal | Yes | Package length in centimeters |
| packages[].width | decimal | Yes | Package width in centimeters |
| packages[].height | decimal | Yes | Package height in centimeters |
| packages[].value | decimal | Yes | Declared value for insurance and customs (in the order's customs currency — USD for shipments to the United States, otherwise the origin country's currency) |
| ship_from_address_id | integer | No | ID of sender address (from GET /api/v1/shipping_addresses). Falls back to team's primary address if not specified. |
| shipping_address.email | string | International | Recipient email — required at purchase for international shipments. |
| shipping_address.phone | string | International | Recipient phone — required at purchase for international shipments. |
| shipping_address.tax_id | string | No | Recipient/importer tax ID printed on the commercial invoice (max 15 characters). See International & customs. |
| shipping_address.tax_id_type | string | No | Qualifier for the recipient tax ID (e.g. EIN, VAT, PCCC). A country-appropriate default is inferred when omitted. |
| customs_line_items | array | International | Customs declaration line items — required before purchasing an international order. Fields and limits in International & customs. |
| customs_line_items[].customs_description | string | Yes* | What the item is (max 35 characters). |
| customs_line_items[].harmonized_code | string | Yes* | HS tariff code (6–15 alphanumeric; dots/dashes accepted). |
| customs_line_items[].country_of_origin | string | Yes* | Two-letter country code where the item was made. |
| customs_line_items[].unit_value | decimal | Yes* | Per-unit value > 0, in the order's customs_currency. |
| customs_line_items[].quantity | integer | Yes* | Units of this item > 0. |
| reason_for_export | string | No | SALE (default), GIFT, SAMPLE, REPAIR, RETURN, INTERCOMPANYDATA, OTHER. |
| customs_currency | string | No | USD or CAD. Defaults: USD for Canada→US, otherwise the origin country's currency. |
| customs_declaration_statement | string | No | Declaration printed on the commercial invoice (max 75 characters). |
| customs_comments | string | No | Comments printed on the commercial invoice (max 150 characters). |
| consignee_type | string | No | consumer (default) or business — B2C/B2B indicator for EU-bound shipments. |
* Required within each customs_line_items entry; the array itself is needed only for international orders.
Response
id is the order number (a string) — use it as :id in every order URL below.
{
"order": {
"id": "PS-8JBH6B",
"order_number": "PS-8JBH6B",
"status": "pending",
"tracking_number": null,
"shipment_id_number": null,
"rated_price": null,
"currency": null,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z",
"last_rated_at": null,
"residential_delivery": false,
"label_available": false,
"label_count": 0,
"voided_at": null,
"failed_reason": null,
"failed_at": null,
"failure_details": null,
"reason_for_export": "SALE",
"customs_currency": "CAD",
"customs_declaration_statement": null,
"customs_comments": null,
"consignee_type": "consumer",
"customs_line_items": [],
"purchased_at": null,
"delivery_status": null,
"total": null,
"source": "manual",
"shopify_order_id": null,
"ebay_order_id": null,
"shopify_order_name": null,
"tracking_numbers": [],
"invoice_id": null,
"customer": {
"id": 456,
"first_name": "John",
"last_name": "Doe",
"company_name": "Acme Corp",
"email": "john@example.com",
"phone_number": "+1-555-0123",
"name": "John Doe",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
},
"ship_to_address": {
"id": 789,
"name": "John Doe",
"line1": "123 Main St",
"line2": "Suite 100",
"line3": null,
"city": "Toronto",
"state": "ON",
"postal_code": "M5V 3A8",
"country": "CA",
"primary": false,
"residential": false,
"tax_id": null,
"tax_id_type": null,
"full_address": "123 Main St, Suite 100, Toronto, ON, M5V 3A8, CA"
},
"packages": [
{
"id": 101,
"weight": "2.5",
"length": "30.0",
"width": "20.0",
"height": "15.0",
"value": "100.0",
"description": "Test description",
"unit_of_measurement": "metric",
"has_label": false,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
},
{
"id": 102,
"weight": "1.2",
"length": "25.0",
"width": "15.0",
"height": "10.0",
"value": "50.0",
"description": "Test description",
"unit_of_measurement": "metric",
"has_label": false,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
]
}
}
Example Request
curl -X POST \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"customer": {
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone_number": "+1-555-0123",
"company_name": "Acme Corp"
},
"shipping_address": {
"name": "John Doe",
"line1": "123 Main St",
"line2": "Suite 100",
"line3": null,
"city": "Toronto",
"state": "ON",
"postal_code": "M5V 3A8",
"country": "CA",
"residential": false
},
"packages": [
{
"weight": 2.5,
"length": 30,
"width": 20,
"height": 15,
"value": 100.00
},
{
"weight": 1.2,
"length": 25,
"width": 15,
"height": 10,
"value": 50.00
}
]
}' \
"https://www.packagesave.com/api/v1/orders"
Shipping internationally? See the full example with customs_line_items in International & customs.
GET/api/v1/orders/:id
Retrieves details for a specific order including customer, shipping address, and packages.
URL Parameters
| Parameter | Type | Description |
|---|---|---|
| id | string | Order number (e.g. PS-8JBH6B, the id returned when creating the order). Numeric internal IDs are also accepted. |
Response
{
"order": {
"id": "PS-8JBH6B",
"order_number": "PS-8JBH6B",
"status": "purchased",
"tracking_number": "1Z999AA1234567890",
"shipment_id_number": "SHP-2024-0123",
"rated_price": "27.50",
"currency": "CAD",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T11:45:00Z",
"last_rated_at": "2024-01-15T10:31:00Z",
"residential_delivery": false,
"label_available": true,
"label_count": 2,
"voided_at": null,
"failed_reason": null,
"failed_at": null,
"failure_details": null,
"reason_for_export": "SALE",
"customs_currency": "CAD",
"customs_declaration_statement": null,
"customs_comments": null,
"consignee_type": "consumer",
"customs_line_items": [],
"purchased_at": "2024-01-15T11:45:00Z",
"delivery_status": "in_transit",
"total": "31.08",
"source": "manual",
"shopify_order_id": null,
"ebay_order_id": null,
"shopify_order_name": null,
"tracking_numbers": ["1Z999AA1234567890", "1Z999AA1234567891"],
"invoice_id": 2187,
"customer": {
"id": 456,
"first_name": "John",
"last_name": "Doe",
"company_name": "Acme Corp",
"email": "john@example.com",
"phone_number": "+1-555-0123",
"name": "John Doe",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
},
"ship_to_address": {
"id": 789,
"name": "John Doe",
"line1": "123 Main St",
"line2": "Suite 100",
"line3": null,
"city": "Toronto",
"state": "ON",
"postal_code": "M5V 3A8",
"country": "CA",
"primary": false,
"residential": false,
"tax_id": null,
"tax_id_type": null,
"full_address": "123 Main St, Suite 100, Toronto, ON, M5V 3A8, CA"
},
"packages": [
{
"id": 101,
"weight": "2.5",
"length": "30.0",
"width": "20.0",
"height": "15.0",
"value": "100.0",
"description": "Test description",
"unit_of_measurement": "metric",
"has_label": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
},
{
"id": 102,
"weight": "1.2",
"length": "25.0",
"width": "15.0",
"height": "10.0",
"value": "50.0",
"description": "Test description",
"unit_of_measurement": "metric",
"has_label": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
]
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| status | string | Order status: pending, processing, created, purchased, completed, voided, cancelled, failed |
| tracking_number | string | UPS tracking number (available after purchase) |
| shipment_id_number | string | Internal shipment identifier |
| rated_price | decimal | Final price after markup (available after rate selection) |
| currency | string | Currency code (CAD, USD) |
| last_rated_at | datetime | Timestamp of last rate fetch |
| residential_delivery | boolean | Whether delivery is to a residential address |
| purchased_at | datetime | When the label was purchased; null for drafts |
| delivery_status | string | Carrier tracking state (see the orders list for the value set); null before the first scan |
| total | decimal | The final charged amount including tax; null until purchase |
| source | string | Sales platform: shopify, ebay, or manual |
| shopify_order_id / ebay_order_id / shopify_order_name | string | The originating platform's own order identifiers; null on other sources |
| tracking_numbers | array | Per-package tracking numbers (multi-package shipments carry one per package) |
| invoice_id | integer | The weekly statement the label charge was billed on — resolve via GET /api/v1/invoices/:id; null until the weekly billing run |
Example Request
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders/123"
GET/api/v1/orders/:id/shipping_rates
Retrieves available shipping rates for an order. Returns "pending" status if rates are still being calculated.
Response (Ready)
Rates expire — check expires_at and refresh if the rate you want has lapsed. marked_up_price is the price your team pays; retail_price is the carrier's published price for comparison.
{
"status": "ready",
"shipping_rates": [
{
"id": 789,
"service_code": "03",
"service_name": "Ground",
"retail_price": "38.90",
"marked_up_price": "27.50",
"currency": "CAD",
"expires_at": "2024-01-16T10:31:00Z",
"transit_days": 3
}
]
}
Response (Pending)
{
"status": "pending",
"message": "Shipping rates are being calculated"
}
Response (Failed)
If the last rate fetch failed and produced no rates (for example, an undeliverable PO Box address), the endpoint reports the failure instead of staying "pending" forever. Fix the order details, then POST /api/v1/orders/:id/refresh_shipping_rates to try again.
{
"status": "failed",
"error": "UPS can't deliver to PO Box addresses."
}
Example Request
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders/123/shipping_rates"
POST/api/v1/orders/:id/refresh_shipping_rates
Invalidates existing shipping rates and re-enqueues the rate fetching job. Use this when order details have changed.
Response
{
"status": "success",
"message": "Shipping rates refresh initiated"
}
Example Request
curl -X POST \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders/123/refresh_shipping_rates"
POST/api/v1/orders/:id/purchase
Purchases a shipping label for the order using the selected shipping rate. Enqueues a background job to create the shipment.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
| shipping_rate_id | integer | Yes | ID of the selected shipping rate |
Response
order is the full order object (same shape as GET /api/v1/orders/:id), abbreviated here:
{
"status": "success",
"message": "Order purchase initiated",
"order": {
"id": "PS-8JBH6B",
"order_number": "PS-8JBH6B",
"status": "processing",
"rated_price": "27.50",
"currency": "CAD"
}
}
Example Request
curl -X POST \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"shipping_rate_id": 789}' \
"https://www.packagesave.com/api/v1/orders/123/purchase"
Error Responses
A 402 Payment Required means the purchase couldn't be paid for. Branch on the code field rather than the human-readable error message:
| Status | code | Meaning |
|---|---|---|
| 402 | payment_method_required | No payment method is on file. Add a card in the web app. |
| 402 | payment_overdue | A previous charge was declined. Update your card to resume shipping. |
| 402 | insufficient_prepaid_balance | Team without enough account credit. Includes estimated_total, available, shortfall, and currency. |
| 422 | — | Missing/invalid shipping_rate_id, expired rate, or order not in a purchasable state. |
| 422 | rate_not_access_point_compatible | The selected rate can't deliver to the order's UPS Access Point. Choose a different rate from the current quote, or remove the Access Point from the order. |
| 422 | return_rate_value_capped | The selected rate is a UPS return-service label (is_return_rate: true), which caps declared value at $1,000 — below this shipment's declared value. Choose a forward rate from the quote. |
| 422 | ddp_forward_requires_forward_rate | Your team ships Canadian imports DDP, and the selected rate is a return-service label that can't carry duties prepaid. Choose a forward rate from the quote. |
| 422 | customs_info_incomplete | International order without complete customs data — provide customs_line_items when creating the order. See International & customs. |
| 422 | customs_value_exceeds_limit | Total declared customs value is over the $50,000 limit for international shipments. |
| 422 | international_contact_info_incomplete | International shipments need a recipient email and phone — set shipping_address.email and shipping_address.phone when creating the order. |
An insufficient balance returns the shortfall so you can top up the exact amount:
{
"error": "Insufficient balance for this label — it needs about $42.10 CAD including tax and your balance is $30.00 CAD. Add at least $12.10 CAD on the Payments page to continue. Unspent funds are refundable anytime (less card-processing fees).",
"code": "insufficient_prepaid_balance",
"estimated_total": "42.10",
"available": "30.00",
"shortfall": "12.10",
"currency": "CAD"
}
⚠️ Async failures
Purchase enqueues a background job, so an order can still fail after a 200 (for example, balance consumed by a concurrent purchase). Poll GET /api/v1/orders/:id — a failed order exposes failed_reason and a structured failure_details (with category and, for a balance shortfall, add_funds_amount).
GET/api/v1/orders/:id/label
Downloads the shipping label for a purchased order. Returns PDF by default (recommended for printing), or PNG/base64 for programmatic access.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
label_format |
string | "pdf" (default), "png", or "base64" |
package_index |
integer | 0-based index for PNG format with multi-package orders (default: 0) |
label_size |
string | PDF page size: "4x6", "6x4", "5x7", "letter", or "6x4_letter". Defaults to your team's label size preference; invalid values fall back to it. |
Response Formats
PDF (default)
Returns a PDF with all labels (one page per package) at your team's label size — or the label_size you pass. Best for thermal printers.
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders/123/label" \
-o label.pdf
PNG
Returns a single label as PNG image. Use package_index for multi-package orders.
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders/123/label?label_format=png" \
-o label.png
Base64 JSON
Returns raw base64 data for each package. Useful for direct thermal printer integration.
{
"labels": [
{
"package_index": 0,
"tracking_number": "1Z999AA1234567890",
"image_format": "gif",
"image_data": "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7..."
}
]
}
Tip: Check label_available first
The order response includes label_available: true/false and label_count fields. Check these before calling the label endpoint to avoid 404 errors while the shipment is still processing.
Error Responses
// No label available yet
{ "error": "No label available. Order may still be processing." }
// Invalid package index
{ "error": "Package index 2 not found. Order has 1 package(s)." }
// Invalid label_format
{ "error": "Invalid label_format. Use 'pdf', 'png', or 'base64'." }
POST/api/v1/orders/:id/void_shipment
Voids a purchased shipment. If UPS confirms the void, the shipping charge is credited back to your account balance and the order status becomes "voided".
URL Parameters
| Parameter | Type | Description |
|---|---|---|
| id | integer | Order ID |
Requirements
- Order must be in "purchased" status
- Order must have a shipment ID number
- Shipment must not have been delivered
Response (Success)
order is the full order object, abbreviated here:
{
"status": "success",
"message": "Shipment has been successfully voided",
"order": {
"id": "PS-8JBH6B",
"order_number": "PS-8JBH6B",
"status": "voided",
"tracking_number": "1Z999AA1234567890",
"shipment_id_number": "1Z999AA1234567890",
"voided_at": "2024-01-15T12:00:00Z",
"label_available": false
}
}
Response (Retryable — 503)
If UPS is still processing the freshly created label, the void can't complete yet. Retry after the indicated delay:
{
"error": "UPS is still processing this label. Retry in 15 minutes.",
"retryable": true,
"retry_after_seconds": 900
}
Response (Error)
{
"error": "Failed to void shipment. Please try again or contact support."
}
What Happens When Voiding
- The shipment is cancelled with UPS (only after UPS confirms the void)
- Shipping label files are deleted; tracking numbers are kept for reference
- A negative balance transaction credits the order value back to your account balance
- Order status is updated to "voided" and the voided_at timestamp is set
Example Request
curl -X POST \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/orders/123/void_shipment"
GET/api/v1/invoices
Lists your team's weekly statements, newest first — the same set shown on the Payments → Statements page in the web app. Read-only: there are no billing mutations in the API.
Parameters
| Parameter | Type | Description |
|---|---|---|
| page | integer | Page number (default 1) |
| per_page | integer | Statements per page (default 20, maximum 100) |
Response Fields
| Field | Description |
|---|---|
| id | The invoice id — matches the invoice_id on orders. |
| statement_number | The weekly statement label shown in the web app (e.g. #276). null for consolidated/memo invoices. |
| status | draft, created, open, paid, void, or uncollectible. |
| start_date / invoice_date | The billing week this statement covers (Sunday through Saturday). |
| total_amount | The amount actually charged, in currency. |
| amount_billed_before_credit / credit_applied_total | What the week's charges added up to before credit, and the credit consumed. A statement fully covered by credit has total_amount 0.00. |
Response
{
"invoices": [
{
"id": 2187,
"statement_number": "#276",
"status": "paid",
"currency": "CAD",
"total_amount": "213.47",
"amount_billed_before_credit": "238.47",
"credit_applied_total": "25.0",
"start_date": "2026-06-28",
"invoice_date": "2026-07-04",
"paid_at": "2026-07-05T04:12:09Z",
"memo": null
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total_count": 41,
"total_pages": 3
}
}
Example Request
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/invoices?per_page=50"
GET/api/v1/invoices/:id
A single statement with its per-transaction line_items, oldest first. Every line follows the ledger sign: type: "charge" for positive amounts (label purchases, adjustments) and type: "credit" for negative amounts (void refunds, account credits, applied funds). Lines that belong to an order carry its order_number and tracking_number for the reverse join; other lines (credits, rollovers) carry null.
URL Parameters
| Parameter | Type | Description |
|---|---|---|
| id | integer | The invoice id — as returned by the statements list or an order's invoice_id. |
Response
{
"invoice": {
"id": 2187,
"statement_number": "#276",
"status": "paid",
"currency": "CAD",
"total_amount": "213.47",
"amount_billed_before_credit": "238.47",
"credit_applied_total": "25.0",
"start_date": "2026-06-28",
"invoice_date": "2026-07-04",
"paid_at": "2026-07-05T04:12:09Z",
"memo": null,
"line_items": [
{
"id": 90411,
"amount": "27.61",
"currency": "CAD",
"type": "charge",
"description": "Shipping label purchase - Order #12536",
"order_number": "PS-8JBH6B",
"tracking_number": "1Z0JA0676704591639",
"created_at": "2026-06-30T18:22:41Z"
},
{
"id": 90502,
"amount": "-19.84",
"currency": "CAD",
"type": "credit",
"description": "Shipping label void refund - Order #12401",
"order_number": "PS-3KDPQR",
"tracking_number": "1Z0JA0676704580021",
"created_at": "2026-07-02T09:15:03Z"
}
]
}
}
Returns 404 for an unknown id or another team's invoice. The detail endpoint resolves every invoice_id an order reports — including invoices the statements list hides (bookkeeping entries).
Example Request
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
"https://www.packagesave.com/api/v1/invoices/2187"
Shipments & invoices
Reconciling shipments against your weekly statements — across sales platforms — takes three calls:
- 1. List shipments by platform —
GET /api/v1/orders?source=shopify(orebay/manual). Each order carries its platform ids (shopify_order_id,shopify_order_name,ebay_order_id) so you can match it to your store's records. - 2. Read each order's
invoice_id— the weekly statement its label charge was billed on.nullmeans the charge hasn't been swept onto a statement yet (billing runs weekly, on the Saturday week boundary). - 3. Pull the statement —
GET /api/v1/invoices/:id. Itsline_itemscarryorder_numberfor the reverse join, so you can tie every charged dollar back to a shipment.
💡 Voids land on the current statement
When you void a label, the refund appears as a negative credit line on the statement current at void time — which can be a later statement than the original charge. The order's invoice_id always names the statement that carried the charge; look for the void's credit line by order_number on subsequent statements.