Skip to content
On this page

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

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

  1. Generate the draft-only token on your account settings page.
  2. Download and install the skill in Claude Code.
  3. 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.

macOS and Linux:
read -rs API_DRAFT_TOKEN && export API_DRAFT_TOKEN
claude
Windows, PowerShell 7.1 or later:
$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_rates on "expired".
  • results.csv with 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:

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

  1. Register an HTTPS endpoint and store the signing secret from that one response.
  2. Verify the signature on every POST before you trust the body.
  3. Answer 2xx within 10 seconds: queue the work, then reply.
  4. 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.

Ruby
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
Node
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 2xx within 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-Id and treat a repeat as a no-op.
  • We do not follow redirects. A 3xx is 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 Gone and 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 429 rate_limited with Retry-After: 60.
  • A draft-only token stops at 500 open drafts created through the API: the next create answers 422 with the new code draft_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_rates is accepted only for a pending draft. Any other status answers 422 with the new code order_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_scope on GET /api/v1/current: "drafts" for a draft-only token, null for a full one.
  • A new 403 code, insufficient_scope, when a draft-only token calls an endpoint outside its scope.
  • A new expired status on GET /api/v1/orders/:id/shipping_rates when the stored rates are no longer current. It used to answer pending forever; call refresh_shipping_rates once 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-Key on order create and purchase, so a retried request replays its stored response instead of shipping twice.
  • X-Request-Id on every response, and Retry-After on every 429.
  • A tracking endpoint: GET /api/v1/orders/:id/tracking returns the stored scan history.
  • Package signature and unit options on create (delivery_confirmation, unit_of_measurement, unit_value, and the package-level customs fields), plus return_label.
  • This OpenAPI document, at https://www.packagesave.com/api/v1/openapi.json.

2026-09-07, hardening round #

  • Every response is JSON, whatever your Accept header says, including errors raised before a controller runs.
  • Clearer 422s for malformed shapes: a wrong-typed filter, a non-object customer, a non-array packages each name what went wrong instead of failing opaquely.
  • GET /api/v1/shipping_addresses/:id documented (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 purchased status
  • 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 delivered or returned

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 (or ebay / 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. null means 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, and invoice_id fills in then.
  • 3. Pull the statement: GET /api/v1/invoices/:id. Its line_items carry order_number for 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.