On this page
- Getting Started
- Authentication
- Quick start
- AI assistants
- Payments & balance
- International & customs
- Errors & rate limits
- Webhooks
- Idempotency
- Request ids
- OpenAPI
- Changelog
- Endpoints
- GET/api/v1/current
- GET/api/v1/customers
- GET/api/v1/shipping_addresses
- GET/api/v1/shipping_addresses/:id
- POST/api/v1/shipping_addresses
- 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
- GET/api/v1/orders/:id/tracking
- POST/api/v1/orders/:id/void_shipment
- POST/api/v1/webhook_endpoints/:id/ping
- POST/api/v1/webhook_endpoints
- GET/api/v1/webhook_endpoints
- PATCH/api/v1/webhook_endpoints/:id
- DELETE/api/v1/webhook_endpoints/:id
- POST/api/v1/webhook_endpoints/:id/rotate_secret
- GET/api/v1/webhook_endpoints/:id/deliveries
- POST/api/v1/webhook_deliveries/:id/redeliver
- GET/api/v1/invoices
- GET/api/v1/invoices/:id
- Shipments & invoices
PackageSave API
A RESTful API for managing shipping and logistics data.
Create a shipment, buy a label, and get told when it moves.
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_API_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_API_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_API_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_API_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.
Using the API with an AI assistant
An AI assistant such as Claude can turn a spreadsheet of customers and packages into draft shipments on PackageSave and report the UPS cost for each customer, without ever being able to buy a label.
The skill runs in Claude Code on your own computer, where the token stays in your terminal. The hosted chat in the Claude apps is not supported, because it cannot see that token.
Three steps
- Generate the draft-only token on your account settings page.
- Download and install the skill in Claude Code.
- Give the assistant your spreadsheet and your packing rules.
Draft-only, by design
Drafts never charge you. A draft-only token that calls an endpoint outside its scope gets 403 with code: "insufficient_scope", so an assistant holding it cannot spend money on your account.
1. Generate the draft-only token
On your account settings page, click Generate under Draft-only token for AI assistants. Copy the token right away: it is shown once and is valid for 30 days.
Never give an assistant your full API token. To stop every draft-only token at once, click Revoke draft-only tokens on the same page; your main API token keeps working.
Can call
- GET/api/v1/current
- GET/api/v1/shipping_addresses
- POST/api/v1/orders
- GET/api/v1/orders/:id/shipping_rates
- POST/api/v1/orders/:id/refresh_shipping_rates
Rates and re-quotes reach only drafts the API created that are still pending.
A draft-only token can create at most 20 draft shipments a minute and 500 open drafts, and it cannot read your customers or past orders.
Cannot call (403 insufficient_scope)
- GET/api/v1/customers
- GET/api/v1/shipping_addresses/:id
- GET/api/v1/orders
- GET/api/v1/orders/:id
- GET/api/v1/orders/:id/tracking
- POST/api/v1/orders/:id/purchase
- POST/api/v1/orders/:id/void_shipment
- GET/api/v1/orders/:id/label
- POST/api/v1/shipping_addresses
- GET/api/v1/invoices
- GET/api/v1/webhook_endpoints
- Every other webhook and invoice endpoint, and any endpoint added later until it is allowed on purpose.
2. Download and install the skill
Download the skill (zip)Two files, SKILL.md and scripts/quote_drafts.py, already pointed at https://www.packagesave.com. No token inside.
Install
Unzip it into ~/.claude/skills/, so the folder is ~/.claude/skills/shipping-quotes/. Then paste these lines into a terminal and press Enter. On macOS and Linux the terminal waits without a prompt; on Windows it asks for the token. Paste the token (it is hidden as you type), press Enter, and Claude Code starts in that same terminal. The script reads the token from this environment variable, never from the command line or the chat.
read -rs API_DRAFT_TOKEN && export API_DRAFT_TOKEN
claude
$env:API_DRAFT_TOKEN = Read-Host -MaskInput "Draft token"
claude
3. Give the assistant your spreadsheet
Give it your spreadsheet and your packing rules; it first checks that GET /api/v1/current reports "token_scope": "drafts" (a full token reports null), the same check you can run yourself:
curl -H "Authorization: Bearer YOUR_DRAFT_TOKEN" \
"https://www.packagesave.com/api/v1/current"
What to expect
- One draft per customer, or one per 50 packages when a customer has more; re-running the same rows never makes a second draft, a run after you edit a row makes a new one.
- Rates polled until ready, and refreshed once through
refresh_shipping_rateson"expired". results.csvwith the cheapest price per order in its own currency, and totals per currency: CAD is never added to USD.- It never buys, voids or downloads a label, never changes an existing customer, and never invents weights or HS codes.
Afterwards: clean up the drafts
Open the Draft filter on your dashboard, select the drafts you no longer need and choose Delete (up to 200 at a time).
Payments & balance
Buying a label (POST /api/v1/orders/:id/purchase) needs a card on file, unless your team is invoiced manually or is on a funds balance. Teams are billed in one of three ways:
- Weekly billing (default): labels are charged to your card on your weekly statement. No balance is required up front, and a card must be on file.
- 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.
- Manual billing: arranged with us directly. Labels still appear on a statement, and no card is required, so a purchase never returns
payment_method_required.
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, and a maximum of 50 packages (both are refused with a 422 rather than truncated). 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. |
| direct_delivery_only | boolean | UPS Direct Delivery Only (default false): the package delivers only to the label address; the receiver can't reroute it or redirect it to a UPS Access Point pickup. UPS adds a small accessorial fee (about $2.00), included in the quoted rates. Can't be combined with Access Point delivery. |
| merchant_reference | string | Your own reference for this shipment: your order number, a PO, or a short note (max 35 characters). Searchable through the q filter on GET /orders, and printed on the label as a reference number when the shipment has no sales-channel order name. |
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 17 characters; values longer than 15 print on the commercial invoice but are not sent in UPS's structured tax field) plus an optional tax_id_type qualifier. When the type is omitted, a country-appropriate default is inferred. It prints on the commercial invoice. A recipient that holds two identifiers (an EU business with both an EORI and a VAT number) also takes secondary_tax_id plus a required secondary_tax_id_type (a different type from the main one); both IDs print 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) |
| KR_BRN | Business Registration Number (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. Each customs line item needs a specific description (not a generic term like 'clothing' or 'merchandise'), a valid HS code, country of origin, unit value, and quantity. Update the order with complete customs_line_items before purchasing.",
"code": "customs_info_incomplete"
}
Example: Canada→US order with customs
curl -X POST https://www.packagesave.com/api/v1/orders \
-H "Authorization: Bearer YOUR_API_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 always hidden for DDP forward imports, and purchasing one is blocked with ddp_forward_requires_forward_rate.
Teams enrolled in Export DDP get the same treatment on forward exports to non-Canadian destinations: duties, taxes and brokerage bill to the team's own UPS account. It is likewise derived from your team's configuration and the route (there are no per-order API fields for it) and purchasing a return-service rate is blocked with export_ddp_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.
Every response from this API is JSON, whatever your Accept header says. An error never comes back as an HTML page.
| Status | code | Meaning |
|---|---|---|
| 400 | malformed_json | The request body could not be parsed as JSON. Check the payload and the Content-Type header. |
| 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 / account_closed | Your token is valid but your team's account isn't verified yet (or verification was revoked). Verification is completed in the web app. A closed account answers account_closed; contact support to reopen it. |
| 403 | insufficient_scope | A draft-only token called an endpoint outside its scope (buy, void, label, invoices, webhooks or adding a sender). Use the full API token for that call. |
| 404 | — | Resource not found (or it belongs to another team). |
| 422 | invalid_filter / parameter_missing / invalid_shape / 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. Every 422 code is listed below. |
| 429 | — | Rate limited. Voids: 10 per 5 minutes. Rate refreshes: 30 per minute. Every 429 carries a Retry-After header in seconds and the code rate_limited: wait that long, then retry. Every pool is listed below. |
| 503 | — | Retryable carrier-side state (void of a label UPS is still processing). The body includes retryable: true and retry_after_seconds. |
| 500 | label_unreadable | The stored label image could not be read. Ask for label_format=pdf, or contact support with the order number. |
Every rate limit
Each pool is counted per team (the provisioning pool, which runs before any team exists, is counted per source address). Spending one pool never affects another.
| Endpoint | Limit | Retry-After |
|---|---|---|
| POST /orders/:id/void_shipment | 10 per 5 minutes | 300 |
| POST /orders/:id/refresh_shipping_rates | 30 per minute | 60 |
| GET /orders/:id/label | 120 per minute | 60 |
| GET /orders/:id/tracking | 240 per minute | 60 |
| POST /orders, draft-only token | 20 per minute | 60 |
| POST /shipping_addresses | 60 per 10 minutes | 600 |
| POST /admin/provision | 20 per hour, per source address | 3600 |
Creating orders with a full token is not throttled: bulk creation is a core use of this API. A draft-only token is limited to 20 creates a minute and 500 open drafts. Regenerating your token from the account page is limited to 5 per 10 minutes.
Every machine code
The full set, so you can write one handler and cover the API. Codes are stable: a message may be reworded, a code will not.
Auth and account
| code | Status | Meaning |
|---|---|---|
| team_not_verified | 403 | Your team's account isn't verified yet. Verification happens in the web app. |
| account_closed | 403 | The account is closed. Contact support to reopen it. |
| insufficient_scope | 403 | A draft-only token called an endpoint outside its scope. It can create and quote draft shipments only. |
| order_not_refreshable | 422 | Only a pending draft can be re-quoted. Once a purchase has started, the order keeps the rates it was bought on. |
Payments
| code | Status | Meaning |
|---|---|---|
| payment_method_required | 402 | The team needs a card on file before buying a label. |
| payment_overdue | 402 | An earlier charge was declined, so buying is paused. Raised by the shared payment gate, which also guards order creation. |
| insufficient_prepaid_balance | 402 | A balance-funded team is short. The body carries estimated_total, available, shortfall and currency. |
Request shape
| code | Status | Meaning |
|---|---|---|
| malformed_json | 400 | The request body is not valid JSON. |
| parameter_missing | 422 | A required top-level parameter is absent. The body names it in param. |
| invalid_shape | 422 | A parameter arrived as an array or a scalar where an object was expected. |
| invalid_filter | 422 | A list filter carried a value the endpoint doesn't accept. The message names the accepted values. |
Order and shipment validation
| code | Status | Meaning |
|---|---|---|
| invalid_shipping_rate_id | 422 | shipping_rate_id must be a string or an integer id. |
| package_dimensions_required | 422 | An imported order still carries a placeholder box; enter its real size and weight first. |
| letter_packaging_retired | 422 | The order still carries UPS Letter packaging, which UPS retired. Set the package's dimensions and retry. |
| shipper_phone_required | 422 | An international shipment needs a phone number on the ship-from address (or on the team). |
| access_point_contact_info_incomplete | 422 | Access Point delivery needs a recipient email or phone so UPS can send the pickup notice. |
| return_declared_values_required | 422 | Each package needs a declared value greater than $0 for the customs declaration on this label. |
| rate_not_access_point_compatible | 422 | The selected rate can't deliver to the order's Access Point. |
| return_rate_value_capped | 422 | The selected return-service rate caps declared value at $1,000, below this shipment's value. |
| ddp_forward_requires_forward_rate | 422 | A Canadian DDP forward import must ship on a forward rate, or the duties won't be prepaid. |
| global_checkout_requires_forward_rate | 422 | The order's prepaid duties guarantee can't attach to a return-service rate. |
| direct_delivery_only_requires_forward_rate | 422 | UPS rejects Direct Delivery Only on a return-service rate. Choose a forward rate. |
| export_ddp_requires_forward_rate | 422 | Duties on this export are billed to your UPS account, which a return-service rate can't carry. |
| invalid_delivery_confirmation | 422 | A package's delivery_confirmation is not 1, 2 or 3. |
| invalid_unit_of_measurement | 422 | A package's unit_of_measurement is not metric or imperial. |
| return_label_requires_canadian_destination | 422 | UPS return labels deliver to Canada only; this order ships elsewhere. |
| label_unreadable | 500 | The stored label image could not be read. Ask for label_format=pdf. |
Customs
| code | Status | Meaning |
|---|---|---|
| customs_info_incomplete | 422 | An international order is missing customs data. Send customs_line_items. |
| customs_value_exceeds_limit | 422 | Declared customs value is over the $50,000 limit. |
| international_contact_info_incomplete | 422 | An international shipment needs a recipient email and phone. |
Rate limits
| code | Status | Meaning |
|---|---|---|
| rate_limited | 429 | A pool is spent. The Retry-After header says how many seconds to wait. |
| draft_limit_reached | 422 | This account holds 500 draft shipments created through the API. Delete drafts from the Draft filter on your dashboard, then try again. |
Idempotency
| code | Status | Meaning |
|---|---|---|
| invalid_idempotency_key | 422 | The Idempotency-Key header is not an accepted key, or the request was multipart. |
| idempotency_key_reused | 422 | That key was already used for a different request body. Mint a new key. |
| idempotency_conflict | 409 | A request with that key is still running. Retry after the seconds in Retry-After. |
Webhooks
| code | Status | Meaning |
|---|---|---|
| invalid_webhook_url | 422 | The URL is not https, carries credentials, uses a non-standard port, or does not resolve. |
| unsafe_webhook_url | 422 | The URL points at a private network or at us. Use a publicly reachable address. |
| invalid_webhook_events | 422 | The events list is empty, is not a list, or names a type that does not exist. The message lists the valid types. |
| webhook_endpoint_limit | 422 | The team already has 5 endpoints. Remove one to add another. |
| redelivery_in_flight | 409 | A retry of that delivery is already in flight. Try again in a moment. |
| webhook_endpoint_disabled | 409 | A redelivery was requested on an endpoint that is turned off. Turn it on, then redeliver. |
| invalid_webhook_endpoint | 422 | The endpoint record failed validation. The body's errors key names the fields. |
A failed void carries one more shape: alongside the error sentence, the body may include a code naming the failure category (for example route for a label UPS will never void) and a headline, the short plain-language sentence we show merchants. Use them to tell "UPS will not void this" from "try again in a moment"; both keys are omitted when UPS gave us nothing to translate.
Failures that arrive after a 200
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. Or let us tell you: subscribe to order.purchase_failed with a webhook.
Webhooks
Register an HTTPS URL and we POST a signed JSON event to it every time one of your shipments moves. That is the alternative to polling GET /api/v1/orders/:id, and it is the only way to learn about the things that happen after a request has already returned 200: rates arriving, a background purchase failing, a scan on the way to delivery.
A team can have up to 5 endpoints. Each one carries its own signing secret and its own list of event types, so you can send purchase events to your order system and delivery events somewhere else. Add and manage them from the API below, or from the Webhooks section of your account page.
Four steps to a working receiver
- Register an HTTPS endpoint and store the signing secret from that one response.
- Verify the signature on every POST before you trust the body.
- Answer 2xx within 10 seconds: queue the work, then reply.
- Deduplicate on
X-Webhook-Event-Id, because an event can arrive more than once.
Events #
Subscribe to the types you want. ping is not subscribable: it is the test event, and it is always delivered to the endpoint you send it to.
| type | Fires when |
|---|---|
| order.rates_ready | A rate fetch finished and the order has rates you can buy. |
| order.rates_failed | A rate fetch could not produce rates. The order's last_rate_fetch_error says what to fix. |
| order.purchased | A label exists. Also fires in the rare case where the label was bought but our billing step did not finish, in which case failed_reason is set on the order in the payload. |
| order.purchase_failed | The purchase did not produce a label. No label exists, so this order can be fixed and bought again. |
| order.voided | UPS confirmed the void. |
| order.tracking_updated | The delivery status changed. Carries previous_delivery_status beside delivery_status, so you can build a timeline without storing one. |
| order.delivered | The same transition as above, when it lands on delivered. Subscribe to this alone if landing is all you care about. |
| order.returned | The same transition, when UPS returns the shipment to its sender. |
| ping | You asked us to send a test event. Always delivered, whatever the endpoint subscribed to. |
The envelope #
Every event has the same shape. data.order is the same object GET /api/v1/orders/:id returns, captured at the moment the event happened: a redelivery days later sends that same snapshot, not a fresh read.
{
"id": "evt_9f1c2b7a4d6e8f0a1b2c3d4e",
"type": "order.tracking_updated",
"created_at": "2026-09-07T14:22:31Z",
"api_version": "v1",
"data": {
"previous_delivery_status": "label_created",
"delivery_status": "in_transit",
"order": {
"id": "PS-A1B2C3",
"status": "purchased",
"delivery_status": "in_transit",
"tracking_numbers": ["1Z0JA0670300123456"]
}
}
}
Headers #
| Header | Value |
|---|---|
| X-Webhook-Event | The event type, the same string as type in the body. |
| X-Webhook-Event-Id | The event id. Store it and ignore an id you have already processed. |
| X-Webhook-Timestamp | Unix seconds at the moment we sent this attempt. It is part of what is signed. |
| X-Webhook-Signature | v1= followed by the hex HMAC-SHA256 of timestamp.body, keyed by your signing secret. |
Verifying the signature #
Sign the exact raw body you received, before any JSON parsing or re-encoding. Compare in constant time, and reject a timestamp more than 5 minutes old so a captured request cannot be replayed at you later.
require "openssl"
SECRET = ENV.fetch("WEBHOOK_SIGNING_SECRET")
TOLERANCE = 300 # seconds
def verified?(request)
timestamp = request.headers["X-Webhook-Timestamp"].to_s
signature = request.headers["X-Webhook-Signature"].to_s
body = request.raw_post
return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE
expected = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{timestamp}.#{body}")
OpenSSL.secure_compare(expected, signature)
end
const crypto = require("crypto");
const SECRET = process.env.WEBHOOK_SIGNING_SECRET;
const TOLERANCE = 300; // seconds
// rawBody must be the unparsed request body, e.g. express.raw({ type: "*/*" })
function verified(headers, rawBody) {
const timestamp = headers["x-webhook-timestamp"] || "";
const signature = headers["x-webhook-signature"] || "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE) return false;
const expected =
"v1=" +
crypto.createHmac("sha256", SECRET).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Delivery and retries #
- Answer with any
2xxwithin 10 seconds. Do the work afterwards: queue the event and reply, rather than replying when the work is done. - An event can arrive more than once. Deduplicate on
X-Webhook-Event-Idand treat a repeat as a no-op. - We do not follow redirects. A
3xxis recorded as a failed delivery. - HTTPS on port 443 only. We refuse a URL that resolves to a private or loopback address, at registration and again before every send.
- Answer
410 Goneand we turn the endpoint off immediately.
A 408, a 429, any 5xx, a timeout or a connection error is retried 8 times: after 30 seconds, then 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 6 hours and 12 hours. That is about 21 hours of retrying. Any other 4xx is not retried, because the same request would fail the same way.
Endpoints that stay unreachable get turned off
After 25 failed deliveries in a row we disable the endpoint, and the team gets a notification in the app. A successful delivery resets that counter. Turn it back on from the account page once the receiver is ready.
Testing your receiver #
Send a ping to an endpoint and we deliver a real, signed event to it with the same headers as everything else. Use it to check your signature verification before any shipment depends on it.
POST/api/v1/webhook_endpoints/:id/ping #
curl -X POST https://www.packagesave.com/api/v1/webhook_endpoints/42/ping \
-H "Authorization: Bearer YOUR_API_TOKEN"
# 202 Accepted
{ "ping": { "event_id": "evt_1a2b3c", "webhook_endpoint_id": 42, "queued": true } }
POST/api/v1/webhook_endpoints #
Creates an endpoint. The response carries signing_secret, and it is the only response that ever will: store it when you receive it. If you lose it, rotate for a new one.
curl -X POST https://www.packagesave.com/api/v1/webhook_endpoints \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhook_endpoint": {
"url": "https://hooks.example.com/shipping-events",
"description": "Order system",
"events": ["order.purchased", "order.tracking_updated", "order.delivered"]
}
}'
# 201 Created
{
"webhook_endpoint": {
"id": 42,
"url": "https://hooks.example.com/shipping-events",
"events": ["order.purchased", "order.tracking_updated", "order.delivered"],
"enabled": true,
"signing_secret": "whsec_kQ2f...",
"consecutive_failures": 0,
"last_delivered_at": null
}
}
GET/api/v1/webhook_endpoints #
Lists your endpoints under a webhook_endpoints key. GET /api/v1/webhook_endpoints/:id returns one under webhook_endpoint. Neither carries the signing secret.
PATCH/api/v1/webhook_endpoints/:id #
Changes the URL, the description, the event list or enabled. Sending {"webhook_endpoint": {"enabled": false}} pauses an endpoint without losing its history.
DELETE/api/v1/webhook_endpoints/:id #
Removes the endpoint and answers 204. Its delivery history goes with it, so pause with enabled: false when you want to keep the log.
POST/api/v1/webhook_endpoints/:id/rotate_secret #
Mints a new signing secret and returns it once. The previous secret stops verifying immediately, so deploy the new one first if you can.
GET/api/v1/webhook_endpoints/:id/deliveries #
The last 20 attempts for that endpoint, newest first, one row per attempt with the response status, the error and how long it took.
{
"deliveries": [
{
"id": 9001,
"event_id": "evt_9f1c2b7a4d6e8f0a1b2c3d4e",
"event_type": "order.tracking_updated",
"attempt": 2,
"status": "delivered",
"response_status": 200,
"duration_ms": 143,
"delivered_at": "2026-09-07T14:24:03Z"
}
]
}
POST/api/v1/webhook_deliveries/:id/redeliver #
Queues a fresh attempt of that same stored event, against the endpoint it belongs to. Answers 202; the new attempt shows up as another row in that endpoint's deliveries.
Errors #
| code | Status | Meaning |
|---|---|---|
| invalid_webhook_url | 422 | The URL is not https, carries credentials, uses a non-standard port, or does not resolve. |
| unsafe_webhook_url | 422 | The URL points at a private network or at us. Use a publicly reachable address. |
| invalid_webhook_events | 422 | The events list is empty, is not a list, or names a type that does not exist. The message lists the valid types. |
| webhook_endpoint_limit | 422 | The team already has 5 endpoints. Remove one to add another. |
| invalid_webhook_endpoint | 422 | The endpoint record failed validation. The body's errors key names the fields. |
| redelivery_in_flight | 409 | A retry of that delivery is already in flight. Try again in a moment. |
| webhook_endpoint_disabled | 409 | A redelivery was requested on an endpoint that is turned off. Turn it on, then redeliver. |
Idempotency
Send an Idempotency-Key header on a write and a retry is safe: for 24 hours the same key on the same body returns the same answer, run once. That is what a request which times out leaves you needing, because a timeout tells you nothing about whether the write happened.
The header is optional. Without it nothing changes, so your existing integration keeps working exactly as it does today.
Where it applies #
| Endpoint | Why it matters |
|---|---|
| POST /api/v1/orders | A blind retry creates a second order and a second rate fetch. |
| POST /api/v1/orders/:id/purchase | A blind retry cannot tell you which attempt bought the label. |
Sending a key #
Mint one key per logical request (a UUID is ideal) and re-send that same key on every retry of it. A key may be 1-255 characters of letters, digits and the symbols _ - : .
curl -X POST https://www.packagesave.com/api/v1/orders \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-d '{ "customer": { "email": "buyer@example.com" } }'
What you get back #
| Situation | Response |
|---|---|
| First time this key is seen | The request runs. Its response is stored for 24 hours. |
| Retry, same key, same body | The stored response, byte for byte, with Idempotent-Replayed: true. |
| Retry while the first attempt is still running | 409 idempotency_conflict |
| Same key, different body | 422 idempotency_key_reused |
| A key that is not the accepted shape, or a multipart request | 422 invalid_idempotency_key |
A replay is the stored response byte for byte, with one header added:
HTTP/1.1 201 Created
Content-Type: application/json
Idempotent-Replayed: true
X-Request-Id: 6f1c0b2a-3d4e-4f50-9a1b-2c3d4e5f6071
{ "order": { "id": "PS-8JBH6B", "status": "pending" } }
A 409 carries Retry-After: 2: wait, then retry the same key. It resolves to the stored response once the first attempt finishes.
Mint a new key after you change the request
Any 4xx is an answer, so it is stored and replayed like a success: a 422, and a 402 when your balance was too low. When you fix what the response named (correct the request, add funds), send the request again under a NEW key: re-using the old one with a different body is refused with idempotency_key_reused. Server errors (5xx), 409 and 429 are never stored, so those retries always re-run.
Keys are scoped to your team and expire 24 hours after their first use. After that the same key string is free to use again.
Request ids
Every response carries an X-Request-Id header, and every error body repeats it as request_id. Quote it to support and we can find the exact request in our logs. Log it beside your own request record and one string ties both sides of an integration together.
{
"error": "status must be one of: pending, processing, created, purchased, completed, failed, voided, cancelled",
"code": "invalid_filter",
"request_id": "4b1a9f2c-0f2e-4f0a-9b0d-7c2a1f4c5d6e"
}
Sending your own X-Request-Id header is supported: we echo the value you sent instead of generating one, so your trace id and ours are the same string.
OpenAPI document
Everything on this page also exists as a machine-readable OpenAPI 3.1 document: every endpoint, every request body, every response schema, every machine code, the webhook event envelope and the Idempotency-Key header. It is served without authentication, so your tooling can fetch it before you hold a token.
The URL #
https://www.packagesave.com/api/v1/openapi.json
Import it #
Import the URL into Postman or Insomnia to get a ready-made collection: every endpoint, its parameters and its example bodies, without copying anything from this page. Generate a client in your own language with openapi-generator, pointing it at the same URL.
# Save it
curl https://www.packagesave.com/api/v1/openapi.json -o openapi.json
# Or generate a client straight from the URL
npx @openapitools/openapi-generator-cli generate \
-i https://www.packagesave.com/api/v1/openapi.json \
-g typescript-fetch \
-o ./client
Keeping your client current
The document is cached for five minutes and carries an ETag, so a conditional request costs nothing when nothing changed. Re-generate your client after a changelog entry that adds an endpoint you want.
Changelog
Dated entries, newest first: what the API gained, and when.
Compatibility
New keys are added to responses without notice, so ignore any key you do not recognise and a new field can never break your integration. Removing or renaming anything gets a new path version and a dated entry here, with at least 90 days of notice before the old one stops.
2026-10-06, Draft-only tokens and the AI skill #
- A draft-only token is made when you click Generate on the account settings page, shown once, and valid for 30 days. Revoke draft-only tokens ends them all without touching your main API token.
- The AI skill lists your sender addresses itself (
--list-senders), talks only to the host it was downloaded from, never follows a redirect, and stops on an expired token or the draft limit instead of retrying.
2026-10-06, Draft-only token limits #
- A draft-only token can create 20 orders a minute. The next one answers
429rate_limitedwithRetry-After: 60. - A draft-only token stops at 500 open drafts created through the API: the next create answers
422with the new codedraft_limit_reached. Delete drafts, then try again. - A full token is unchanged: its creates are not limited.
2026-10-06, Re-quotes #
POST /api/v1/orders/:id/refresh_shipping_ratesis accepted only for a pending draft. Any other status answers422with the new codeorder_not_refreshable, and the order keeps its rates.
2026-10-05, AI assistants #
- A draft-only token on the account settings page, for AI assistants: it can create and quote draft shipments and never buy, void or download labels.
token_scopeonGET /api/v1/current:"drafts"for a draft-only token,nullfor a full one.- A new
403code,insufficient_scope, when a draft-only token calls an endpoint outside its scope. - A new
expiredstatus onGET /api/v1/orders/:id/shipping_rateswhen the stored rates are no longer current. It used to answerpendingforever; callrefresh_shipping_ratesonce and poll again. - Account page: the two tokens sit side by side, masked by default with Show, and this section is a three-step guide.
2026-09-07, platform round #
- Outbound webhooks: signed, retried events for rates, purchase, void and every delivery-status change, with a delivery log and replay.
Idempotency-Keyon order create and purchase, so a retried request replays its stored response instead of shipping twice.X-Request-Idon every response, andRetry-Afteron every429.- A tracking endpoint:
GET /api/v1/orders/:id/trackingreturns the stored scan history. - Package signature and unit options on create (
delivery_confirmation,unit_of_measurement,unit_value, and the package-level customs fields), plusreturn_label. - This OpenAPI document, at
https://www.packagesave.com/api/v1/openapi.json.
2026-09-07, hardening round #
- Every response is JSON, whatever your
Acceptheader says, including errors raised before a controller runs. - Clearer
422s for malformed shapes: a wrong-typed filter, a non-objectcustomer, a non-arraypackageseach name what went wrong instead of failing opaquely. GET /api/v1/shipping_addresses/:iddocumented (it always existed, but was absent from this page).- Rate-limit pools keyed per account, so two integrators behind one egress address no longer throttle each other.
GET/api/v1/current
Returns information about the current authenticated team, plus its primary sender address.
Wrapping: this response nests everything under a team_current root key. Across the API, a list is wrapped in a plural key (orders, invoices, shipping_addresses, customers) beside a pagination object, and a single order, customer or invoice is wrapped in its singular key (order, customer, invoice). The two shipping-address reads are the exception: a single address is returned bare, with no wrapper key.
Response
{
"team_current": {
"id": 1,
"name": "Acme Corp",
"wallet_required": false,
"account_credit": {
"CAD": "50.00"
},
"auto_topup_enabled": true,
"token_scope": null,
"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,
"tax_id": null,
"tax_id_type": null,
"secondary_tax_id": null,
"secondary_tax_id_type": null,
"full_address": "123 Main St, Toronto, ON, M5H 2N2, CA"
}
}
}
Example Request
curl -H "Authorization: Bearer YOUR_API_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_API_TOKEN" \
"https://www.packagesave.com/api/v1/customers"
With pagination
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
"https://www.packagesave.com/api/v1/customers?page=2&per_page=10"
With search
curl -H "Authorization: Bearer YOUR_API_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,
"tax_id_type": null,
"secondary_tax_id": null,
"secondary_tax_id_type": 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_API_TOKEN" \
"https://www.packagesave.com/api/v1/shipping_addresses"
GET/api/v1/shipping_addresses/:id
Fetches one of your team's addresses by id. Use it to re-read a sender address you saved earlier, or to look up any address the API has handed you an id for. Returns 404 for an unknown id or another team's address.
Response
A single address comes back on its own, with no wrapper key:
{
"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,
"tax_id_type": null,
"secondary_tax_id": null,
"secondary_tax_id_type": null,
"full_address": "123 Main St, Toronto, ON, M5H 2N2, CA"
}
Example Request
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
"https://www.packagesave.com/api/v1/shipping_addresses/1"
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. |
Response
201 Created with the saved address on its own, no wrapper key. Keep the id: it is what ship_from_address_id takes when you create an order. A validation failure returns 422 with an errors object keyed by field.
{
"id": 42,
"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,
"tax_id_type": null,
"secondary_tax_id": null,
"secondary_tax_id_type": null,
"full_address": "123 Main St, Toronto, ON, M5H 2N2, CA"
}
Example Request
curl -X POST \
-H "Authorization: Bearer YOUR_API_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 orders are excluded, and so is every member of a multi-piece shipment: those are folded into their lead order, which carries all of the tracking numbers.
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. Those eight are the whole listable set: a multi-piece member folded into a lead order is never listed, so asking for merged returns 422 rather than an empty page. |
| 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, amazon, walmart, best_buy, or manual. manual means an order that carries no sales-channel id at all, so orders you created through this API or in the web app. |
| 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, the five channel ids (shopify_order_id, ebay_order_id, amazon_order_id, walmart_order_id, best_buy_order_id), shopify_order_name, purchased_at, delivery_status, total (the final charge including tax), tracking_numbers (one per package), alternate_tracking_numbers (the return leg, once UPS assigns one), 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,
"amazon_order_id": null,
"walmart_order_id": null,
"best_buy_order_id": null,
"shopify_order_name": "#1042",
"tracking_number": "1Z0JA0676704591639",
"tracking_numbers": ["1Z0JA0676704591639"],
"alternate_tracking_numbers": [],
"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_API_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_API_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, 50 at most. A longer array is refused with a 422, never truncated. |
| 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 UPS coverage and customs (in the order's customs currency: USD for shipments to the United States, otherwise the origin country's currency) |
| packages[].unit_value | decimal | No | Per-unit declared value. Read first for the customs value when the order carries no customs_line_items. |
| packages[].unit_of_measurement | string | No | metric (kilograms + centimetres, the default) or imperial (pounds + inches). The numbers you send are stored as sent and echoed back with this unit, so nothing is converted behind your back. |
| packages[].delivery_confirmation | string | No | Signature option: 1 delivery confirmation, 2 signature required, 3 adult signature. Adult signature is dropped on international shipments and no signature option rides a return label, both because UPS refuses them there; the stored value is echoed back on the package. |
| packages[].customs_description | string | No | What is in the box, for customs, when you ship one kind of item per package and send no customs_line_items. Name the goods specifically: a generic term is refused at purchase. |
| packages[].harmonized_code | string | No | HS code for the package-level customs declaration. |
| packages[].country_of_origin | string | No | Two-letter country where the goods were made. |
| return_label | boolean | No | Creates a UPS return label instead of a forward shipment. Canada-bound only: any other destination is refused with return_label_requires_canadian_destination. A return label covers up to $1,000 and carries no signature option. |
| 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 17 characters; 16 or 17 characters print on the invoice only, UPS's structured field takes 15). See International & customs. |
| shipping_address.tax_id_type | string | No | Qualifier for the recipient tax ID (e.g. EIN, VAT, PCCC, KR_BRN). A country-appropriate default is inferred when omitted. |
| shipping_address.secondary_tax_id | string | No | An additional recipient ID printed beside the main one on the commercial invoice (max 17 characters), for a recipient that holds both an EORI and a VAT number. Requires tax_id. |
| shipping_address.secondary_tax_id_type | string | With secondary_tax_id |
Qualifier for the additional ID, same values as tax_id_type; must differ from the main type. Never inferred. |
| 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",
"direct_delivery_only": false,
"merchant_reference": "SO-4521",
"customs_line_items": [],
"global_checkout": null,
"purchased_at": null,
"delivery_status": null,
"total": null,
"source": "manual",
"shopify_order_id": null,
"ebay_order_id": null,
"amazon_order_id": null,
"walmart_order_id": null,
"best_buy_order_id": null,
"shopify_order_name": null,
"tracking_numbers": [],
"alternate_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,
"secondary_tax_id": null,
"secondary_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_API_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",
"direct_delivery_only": false,
"merchant_reference": "SO-4521",
"customs_line_items": [],
"global_checkout": null,
"purchased_at": "2024-01-15T11:45:00Z",
"delivery_status": "in_transit",
"total": "31.08",
"source": "manual",
"shopify_order_id": null,
"ebay_order_id": null,
"amazon_order_id": null,
"walmart_order_id": null,
"best_buy_order_id": null,
"shopify_order_name": null,
"tracking_numbers": ["1Z999AA1234567890", "1Z999AA1234567891"],
"alternate_tracking_numbers": [],
"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,
"secondary_tax_id": null,
"secondary_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, amazon, walmart, best_buy, or manual |
| shopify_order_id / ebay_order_id / amazon_order_id / walmart_order_id / best_buy_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) |
| alternate_tracking_numbers | array | Tracking numbers UPS assigned to the return leg after a return to sender (UPS's "alternate tracking number"); empty until UPS reports one |
| 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 |
| global_checkout | object | Read-only UPS Global Checkout state; null unless the order carries a guaranteed-duties quote. See below. |
| direct_delivery_only | boolean | UPS delivers to the label address only: the receiver can't reroute the package or redirect it to an Access Point. Set it when creating the order. A UPS return-service rate can't carry it, so those rates are left out of the quote. |
| merchant_reference | string | Your own reference for this shipment (max 35 characters), or null. Searchable through the q filter on GET /orders. |
Global Checkout (guaranteed duties)
When your buyer prepays guaranteed duties through UPS Global Checkout at Shopify checkout, we capture the quote on import and attach it to the label at purchase. Every order response carries a read-only global_checkout object: null on orders without a quote. It is not writable: the quote is captured from the Shopify order, and the outcome is recorded from UPS's own ship response.
| Field | Type | Description |
|---|---|---|
| quote_id | string | The Global Checkout quote captured from the sales channel. |
| attached | boolean | Whether the quote was sent with the label request. null until the label ships. |
| guarantee_applied | boolean | true only when UPS confirmed the guaranteed landed cost on the label. false means UPS bills actual border fees to the shipping account: including when the quote was attached but UPS found a mismatch between the label and the checkout quote. null until the label ships. |
| skip_reason | string | Present when the quote was deliberately not attached: return_service_rate, not_international, not_enrolled, or not_rolled_out. |
| at | datetime | When the outcome was recorded (label creation time). null until the label ships. |
A quoted order that hasn't shipped yet reports the quote with the outcome fields still null:
"global_checkout": {
"quote_id": "lc-767870b6a9ad41f092c888ed144d3979",
"attached": null,
"guarantee_applied": null,
"skip_reason": null,
"at": null
}
After the label ships with the guarantee confirmed:
"global_checkout": {
"quote_id": "lc-767870b6a9ad41f092c888ed144d3979",
"attached": true,
"guarantee_applied": true,
"skip_reason": null,
"at": "2026-08-14T15:04:05Z"
}
Example Request
curl -H "Authorization: Bearer YOUR_API_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. Returns "expired" when the stored rates are no longer current: call refresh_shipping_rates once, then poll again.
Rates are quoted from every UPS account that can ship the lane (the same accounts the web app compares), and the list shows one row per service level: the cheapest option for that service, sorted cheapest first. Purchasing a rate ships on the account that quoted it.
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. residential_charge is the residential surcharge already included in that price, and is_return_rate marks a UPS return-service label, which caps declared value at $1,000 and can't carry prepaid duties.
{
"status": "ready",
"shipping_rates": [
{
"id": 789,
"service_code": "03",
"service_name": "Ground",
"service_description": "UPS Standard",
"retail_price": "38.90",
"marked_up_price": "27.50",
"currency": "CAD",
"expires_at": "2024-01-16T10:31:00Z",
"residential_charge": "0.0",
"transit_days": 3,
"is_return_rate": false,
"created_at": "2024-01-15T10:31:00Z"
}
]
}
Shipment protection (protection)
When a shipment carries an InsureShield® Shipping Protection election (made on the web, on an enabled account), each rate also carries protection: insureshield when that rate's label is protected by InsureShield® Shipping Protection, ups_declared_value when the label carries UPS declared value instead, or none when the election was declined and the label ships with UPS's standard liability only. A rate that ships on our US account carries UPS declared value regardless of the election and adds a protection_note saying so. Both keys are omitted when no election exists.
{
"status": "ready",
"shipping_rates": [
{
"id": 789,
"service_code": "03",
"service_name": "Ground",
"marked_up_price": "27.50",
"currency": "CAD",
"is_return_rate": false,
"protection": "insureshield"
},
{
"id": 790,
"service_code": "11",
"service_name": "Standard",
"marked_up_price": "24.10",
"currency": "CAD",
"is_return_rate": false,
"protection": "ups_declared_value",
"protection_note": "UPS declared value applies on this rate: it ships on our US account."
}
]
}
Hidden rates (notes)
Some orders can't ship on a UPS return-service rate: a Canadian DDP forward import, a Direct Delivery Only shipment, an order carrying a Global Checkout duties guarantee, or an Export DDP shipment whose duties are billed to your own UPS account. When a forward rate is available, those return-service rates are left out of the listing and the purchase endpoint rejects them. The response then carries a notes array saying how many rates were left out and why, so a shorter list is never a mystery. The key is omitted when nothing was hidden, and a return-only quote is returned in full so you are never left without a buyable rate. Canadian DDP forward imports are the exception: return-service rates are never offered there, so the listing comes back empty with a note (POST /orders/:id/refresh_shipping_rates re-quotes).
{
"status": "ready",
"shipping_rates": [ ... ],
"notes": [
"1 UPS return-service rate hidden: this order's UPS Global Checkout duties guarantee only applies to forward shipments."
]
}
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_API_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_API_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_API_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 | global_checkout_requires_forward_rate | The order carries a prepaid duties guarantee (captured at checkout), and the selected rate is a return-service label (is_return_rate: true) that the guarantee can't attach to. Return-service rates are hidden from the quote when a forward rate exists; choose a forward rate. |
| 422 | direct_delivery_only_requires_forward_rate | The order is Direct Delivery Only, which UPS rejects on a return-service rate. Choose a forward rate from the quote. |
| 422 | export_ddp_requires_forward_rate | Duties on this export are billed to your own UPS account, which a return-service rate can't carry. 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. |
Purchase also runs the pre-flight checks a label needs before UPS sees it: a real package size (package_dimensions_required), a sender phone on an international shipment (shipper_phone_required), a contactable recipient for an Access Point (access_point_contact_info_incomplete), declared values on a return label (return_declared_values_required), retired UPS Letter packaging (letter_packaging_retired), and a usable shipping_rate_id (invalid_shipping_rate_id). Each is a 422; every code on the API is listed in Errors & rate limits.
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 after purchase
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_API_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_API_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
// 404: no label available yet
{ "error": "No label available. Order may still be processing." }
// 404: invalid package index
{ "error": "Package index 2 not found. Order has 1 package(s) (valid indices: 0-0)." }
// 422: invalid label_format
{ "error": "Invalid label_format. Use 'pdf', 'png', or 'base64'." }
// 500: the stored label bytes could not be decoded
{ "error": "The stored label image could not be read.", "code": "label_unreadable" }
// 500: the multi-page PDF could not be built
{ "error": "Failed to generate label PDF" }
GET/api/v1/orders/:id/tracking
The full scan history for a shipment: every carrier activity, the per-piece breakdown of a multi-package shipment, and when the status last changed. GET /api/v1/orders/:id gives you the current delivery_status; this gives you how it got there.
Served from the scan history we already hold, refreshed by our own polling. It never calls the carrier on your behalf, so it is safe to poll: 240 requests per minute.
Example Request
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
"https://www.packagesave.com/api/v1/orders/PS-8JBH6B/tracking"
Response
{
"tracking": {
"order_id": "PS-8JBH6B",
"delivery_status": "delivered",
"delivery_status_updated_at": "2026-09-05T18:04:11.000Z",
"latest_activity_at": "2026-09-05T14:02:00Z",
"tracking_numbers": ["1Z0JA0670000000001", "1Z0JA0670000000002"],
"alternate_tracking_numbers": [],
"packages": [
{
"tracking_number": "1Z0JA0670000000001",
"status": "delivered",
"description": "Delivered",
"timestamp": "2026-09-05T14:02:00Z",
"primary": true
},
{
"tracking_number": "1Z0JA0670000000002",
"status": "delivered",
"description": "Delivered",
"timestamp": "2026-09-05T13:47:00Z"
}
],
"activities": [
{
"at": "2026-09-05T14:02:00Z",
"status_code": "011",
"description": "Delivered",
"location": "Toronto, ON, CA"
},
{
"at": "2026-09-05T06:11:00Z",
"status_code": "OT",
"description": "Out For Delivery Today",
"location": "Mississauga, ON, CA"
}
]
}
}
Fields
| Field | Meaning |
|---|---|
| delivery_status | The same value GET /api/v1/orders/:id serves. null until the carrier reports anything. |
| delivery_status_updated_at | When we last WROTE that status. Poll this to answer "has anything moved since I last looked?" |
| latest_activity_at | The newest scan's own timestamp, in the carrier's local time, exactly as it reported it. |
| alternate_tracking_numbers | The return-leg numbers UPS assigns after a return to sender. Empty until then. |
| packages | Per-piece status for a multi-package shipment (tracking_number, status, description, timestamp). Exactly one piece carries primary: true: the one the order-level delivery_status came from. Empty for a single-package shipment. |
| activities | The timeline, newest first: at, status_code, description, location. Any of the four may be null when the carrier omitted it. |
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
- The order is in
purchasedstatus - It hasn't been voided already
- It isn't a member of a multi-piece shipment: void the lead order instead, which voids every piece
- The package isn't sitting at a UPS Access Point
- The package isn't
deliveredorreturned
Anything short of those five is refused before we call UPS, with a message naming the reason. Everything else is attempted: UPS is the authority on whether a label can still be voided, and an in-transit tracking state is often a scan we can still cancel behind.
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."
}
Response (UPS Already Has the Package: 422)
Once UPS has scanned the package, the label can't be voided. This answer is final, so don't retry it:
{
"error": "UPS already has this package, so the label can't be voided.",
"code": "route",
"headline": "UPS already has this package, so the label can't be voided.",
"retryable": false
}
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_API_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,
"created_at": "2026-07-05T04:05:11Z",
"updated_at": "2026-07-05T04:12:09Z"
}
],
"pagination": {
"current_page": 1,
"per_page": 20,
"total_count": 41,
"total_pages": 3
}
}
Example Request
curl -H "Authorization: Bearer YOUR_API_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,
"created_at": "2026-07-05T04:05:11Z",
"updated_at": "2026-07-05T04:12:09Z",
"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_API_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: a statement closes on the Saturday week boundary and is generated the following Sunday at 12:05am Eastern, andinvoice_idfills in then. - 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.