On this page

PackageSave API

A RESTful API for managing shipping and logistics data.

Authentication

The PackageSave API uses JWT tokens for authentication. You can obtain your API token from your team's account settings page.

Using your API token

Include your token in the Authorization header on every request:

⚠️ Security Note

Keep your API token secure and never share it publicly. Anyone with this token can access your team's data. For your security, tokens are accepted only in the Authorization header — never as a URL query parameter (which would be logged and cached).

Authorization Header

Authorization: Bearer YOUR_JWT_TOKEN

Quick start

Buy your first label in five steps. Each step links to the full endpoint reference below.

1. Get your API token

Copy it from your account settings page and send it on every request in the Authorization header.

2. Create an order

Post the recipient + packages to POST /api/v1/orders. Rate fetching starts automatically in the background. Save the returned id (the order number).

3. Poll for rates

Poll GET /api/v1/orders/:id/shipping_rates every few seconds until status is "ready". A "failed" status tells you why no rates could be fetched.

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/orders/PS-8JBH6B/shipping_rates"

4. Purchase a label

Pick a rate id and post it to POST /api/v1/orders/:id/purchase. A 402 means the purchase couldn't be paid for — branch on its code field.

curl -X POST \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"shipping_rate_id": 789}' \
  "https://www.packagesave.com/api/v1/orders/PS-8JBH6B/purchase"

5. Download the label

Poll GET /api/v1/orders/:id until label_available is true, then fetch the PDF from GET /api/v1/orders/:id/label.

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/orders/PS-8JBH6B/label" -o label.pdf

Next steps

Payments & balance

Buying a label (POST /api/v1/orders/:id/purchase) always requires a payment method on file. Teams are billed in one of two ways:

  • Weekly billing (default) — labels are charged on your weekly statement. No balance is required up front.
  • Funds balance — your team must hold enough account credit to cover a label's estimated total (including tax) before purchase. Add funds on the Payments page in the web app.

Read your team's mode and balance from GET /api/v1/current:

Field Type Description
wallet_required boolean true when your team must hold a funds balance before buying a label. false for weekly-billed teams.
account_credit object Available credit per currency as money strings, e.g. { "CAD": "50.00" }. This is the live balance the next purchase draws against.
auto_topup_enabled boolean true when auto top-up is armed, so your balance replenishes automatically and purchases won't block on a low balance.

💡 Tip

If your team is on a funds balance, enable auto top-up on the Payments page so your balance replenishes automatically and purchases never block. Adding funds is done in the web app — there is no add-funds API endpoint.

When a balance-funded team has insufficient balance, the purchase endpoint returns 402 Payment Required with a machine-readable code and the exact shortfall, so you can top up the right amount. See the purchase endpoint error responses.

To reconcile what was billed against your shipments, see Shipments & invoices.

International & customs

An international order (ship-from and ship-to in different countries) needs customs data before a label can be purchased. The commercial invoice is generated automatically from this data at label creation. The flow is the same as a domestic order, with customs attached up front:

Customs line items

One entry per distinct product in the shipment. Each line item takes:

Field Required Description
customs_description Yes What the item is, in plain words (max 35 characters). Generic descriptions like "Package contents" are rejected at purchase.
harmonized_code Yes Harmonized System (HS) tariff code — 6 to 15 alphanumeric characters; dots and dashes are accepted (e.g. 6109.10).
country_of_origin Yes Where the item was manufactured — ISO 3166 two-letter code (e.g. CA, US, CN).
unit_value Yes Per-unit value, greater than zero, in the order's customs_currency.
quantity Yes Whole number of units, greater than zero.
merchant_product_id / manufacturer_product_id / standardized_product_id No Product identifiers (each max 35 characters): your SKU, the manufacturer's part number, and a GTIN/EAN/UPC. EU-bound consumer shipments valued at EUR 150 or less should carry them — mandatory on the commercial invoice from November 1, 2026.

A maximum of 100 line items per order. Total declared customs value (sum of unit_value × quantity) is capped at $50,000 per international shipment — a higher total is rejected with 422 at create and at purchase.

Order-level customs fields

All optional — sensible defaults apply when omitted:

Field Type Description
reason_for_export string One of SALE (default), GIFT, SAMPLE, REPAIR, RETURN, INTERCOMPANYDATA, OTHER.
customs_currency string USD or CAD — the currency your unit_values are declared in. Defaults: Canada→US orders declare in USD (US customs expects USD declarations); all other routes declare in the origin country's currency. Echoed on every order response.
customs_declaration_statement string Free-text declaration printed on the commercial invoice (max 75 characters).
customs_comments string Free-text comments printed on the commercial invoice (max 150 characters).
consignee_type string consumer (default) or business — the B2C/B2B indicator EU customs asks for on EU-bound shipments.

Recipient tax ID

Some destinations ask for the recipient's tax ID on the customs entry (for example, a US EIN/SSN/ITIN on US-bound formal entries, or South Korea's PCCC on personal imports). Pass it on the order's shipping_address: tax_id (max 15 characters) plus an optional tax_id_type qualifier. When the type is omitted, a country-appropriate default is inferred. It prints on the commercial invoice.

tax_id_type value Meaning
EIN EIN (US)
SSN SSN (US)
GST_HST GST/HST (Canada)
CRA_BN CRA Business Number (Canada)
VAT VAT (EU)
EORI EORI (UK/EU)
RFC RFC (Mexico)
CNPJ CNPJ (Brazil — business)
CPF CPF (Brazil — individual)
CUIT CUIT (Argentina — business)
CUIL CUIL (Argentina — individual)
RUT RUT (Chile)
RUC RUC (Peru/Ecuador)
NIT NIT (Colombia)
PCCC PCCC (South Korea)
OTHER Other

Purchase pre-flight checks

POST /api/v1/orders/:id/purchase validates international orders synchronously and returns 422 with a machine-readable code instead of failing in the background:

code Meaning
customs_info_incomplete The order has no complete customs data — provide customs_line_items when creating the order.
customs_value_exceeds_limit Total declared customs value is over the $50,000 limit.
international_contact_info_incomplete International shipments need a recipient email and phone — set shipping_address.email and shipping_address.phone.

Each returns a 422 body you can branch on:

{
  "error": "Customs information is incomplete. International orders need customs line items with a description, harmonized (HS) code, country of origin, positive unit value and quantity. Provide customs_line_items when creating the order, or complete the customs step in the web app.",
  "code": "customs_info_incomplete"
}

Example: Canada→US order with customs

curl -X POST https://www.packagesave.com/api/v1/orders \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "customer": { "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com" },
    "shipping_address": {
      "line1": "350 5th Ave", "city": "New York", "state": "NY",
      "postal_code": "10118", "country": "US",
      "email": "jane@example.com", "phone": "+1 212 555 0100"
    },
    "packages": [{ "weight": 1.2, "length": 30, "width": 20, "height": 10, "value": 120 }],
    "customs_line_items": [
      {
        "customs_description": "Cotton t-shirt",
        "harmonized_code": "6109.10",
        "country_of_origin": "CA",
        "unit_value": 30.00,
        "quantity": 4,
        "merchant_product_id": "TSHIRT-BLK-M"
      }
    ],
    "reason_for_export": "SALE",
    "customs_comments": "Order 1042 from our web store"
  }'

The response echoes everything back so you can verify what will print on the commercial invoice (abbreviated):

{
  "order": {
    "id": "PS-8JBH6B",
    "status": "pending",
    "reason_for_export": "SALE",
    "customs_currency": "USD",
    "customs_declaration_statement": null,
    "customs_comments": "Order 1042 from our web store",
    "consignee_type": "consumer",
    "customs_line_items": [
      {
        "id": 501,
        "customs_description": "Cotton t-shirt",
        "harmonized_code": "6109.10",
        "country_of_origin": "CA",
        "unit_value": "30.0",
        "quantity": 4,
        "position": 0,
        "merchant_product_id": "TSHIRT-BLK-M",
        "manufacturer_product_id": null,
        "standardized_product_id": null
      }
    ]
  }
}

💡 Delivered Duty Paid (DDP)

Teams enrolled in the Canadian DDP program get DDP applied automatically at label creation on Canada-bound imports — duties bill to the configured importer account and the commercial invoice carries DDP terms. It is derived from your team's configuration and the route; there are no per-order API fields for it. Return-service rates are automatically hidden for DDP forward imports, and purchasing one is blocked with ddp_forward_requires_forward_rate when a forward rate exists.

⚠️ Not available via the API

Uploading your own customs documents, CERS Proof of Report entry for Canadian exports, and editing customs data after order creation are web-app-only. To change customs on an unpurchased order via the API, create a new order instead (or edit the existing order in the web app).

Errors & rate limits

Errors return a JSON body with a human-readable error message. Where a client may need to branch, a stable machine-readable code is included — branch on code, never on the message text.

Status code Meaning
401 Missing, invalid, revoked, or expired token (expired tokens say "Token expired"). Regenerate on the account settings page.
402 payment_method_required / payment_overdue / insufficient_prepaid_balance The purchase couldn't be paid for — see the purchase endpoint and Payments.
403 team_not_verified Your token is valid but your team's account isn't verified yet (or verification was revoked). Verification is completed in the web app.
404 Resource not found (or it belongs to another team).
422 customs_info_incomplete / customs_value_exceeds_limit / international_contact_info_incomplete (purchase only) Validation failure — the body carries an errors object keyed by field, or an error string (e.g. expired rate, order not in a purchasable state). International purchases add the customs codes — see International & customs.
429 Rate limited. Voids: 10 per 5 minutes. Rate refreshes: 30 per minute. Back off and retry.
503 Retryable carrier-side state (void of a label UPS is still processing) — the body includes retryable: true and retry_after_seconds.

💡 Async failures

Purchasing runs in the background, so an order can fail after a successful 200 — poll GET /api/v1/orders/:id; a failed order carries failed_reason, failed_at, and a structured failure_details.

GET/api/v1/current

Returns information about the current authenticated team.

Response

{
  "team_current": {
    "id": 1,
    "name": "Acme Corp",
    "wallet_required": false,
    "account_credit": {
      "CAD": "50.00"
    },
    "auto_topup_enabled": true,
    "address": {
      "id": 1,
      "name": "Headquarters",
      "line1": "123 Main St",
      "line2": null,
      "line3": null,
      "city": "Toronto",
      "state": "ON",
      "postal_code": "M5H 2N2",
      "country": "CA",
      "primary": true,
      "residential": false,
      "full_address": "123 Main St, Toronto, ON, M5H 2N2, CA"
    }
  }
}

Example Request

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     https://www.packagesave.com/api/v1/current

GET/api/v1/customers

Returns a paginated list of customers for the authenticated team.

Parameters

Parameter Type Description
page integer Page number (default: 1)
per_page integer Items per page (1-100, default: 20)
q string Search customers by name or email

Response

{
  "customers": [
    {
      "id": 1,
      "first_name": "John",
      "last_name": "Doe",
      "company_name": "Acme Inc",
      "email": "john@acme.com",
      "phone_number": "+1-555-0123",
      "name": "John Doe",
      "created_at": "2023-01-15T10:30:00Z",
      "updated_at": "2023-01-15T10:30:00Z"
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 20,
    "total_count": 45,
    "total_pages": 3
  }
}

Example Requests

Basic listing

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/customers"

With pagination

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/customers?page=2&per_page=10"

With search

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/customers?q=john@acme.com"

GET/api/v1/shipping_addresses

Returns sender addresses for the authenticated team. Use these addresses when creating orders via the API.

Parameters

Parameter Type Description
type string "sender" (default) or "all"
primary boolean "true" to only return primary address
page integer Page number (default: 1)
per_page integer Items per page (1-100, default: 20)

Response

{
  "shipping_addresses": [
    {
      "id": 1,
      "name": "Warehouse",
      "line1": "123 Main St",
      "line2": null,
      "line3": null,
      "city": "Toronto",
      "state": "ON",
      "postal_code": "M5H 2N2",
      "country": "CA",
      "primary": true,
      "residential": false,
      "tax_id": null,
      "full_address": "123 Main St, Toronto, ON, M5H 2N2, CA"
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 20,
    "total_count": 3,
    "total_pages": 1
  }
}

Example Request

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/shipping_addresses"

POST/api/v1/shipping_addresses

Creates a new sender address for the team.

Request Body

Parameter Type Required Description
shipping_address.name string Yes Address name (e.g., "Warehouse")
shipping_address.line1 string Yes Street address
shipping_address.city string Yes City
shipping_address.state string Yes State/Province code
shipping_address.postal_code string Yes ZIP/Postal code
shipping_address.country string Yes Country code (CA, US)
shipping_address.primary boolean No Set as primary address
shipping_address.line2 / line3 string No Additional address lines (suite, unit, etc.)
shipping_address.email string No Contact email for this sender address
shipping_address.phone string No Contact phone for this sender address
shipping_address.residential boolean No Whether the address is residential (default: false)
shipping_address.tax_id string No Merchant Tax ID for this sender address (printed on international customs paperwork). Up to 15 alphanumeric characters.
shipping_address.tax_id_type string No Qualifier for the Tax ID (e.g. "EIN", "GST_HST", "VAT"). Inferred from the address country when omitted.

Example Request

curl -X POST \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "shipping_address": {
      "name": "Warehouse",
      "line1": "123 Main St",
      "city": "Toronto",
      "state": "ON",
      "postal_code": "M5H 2N2",
      "country": "CA",
      "primary": true
    }
  }' \
  "https://www.packagesave.com/api/v1/shipping_addresses"

GET/api/v1/orders

Lists your team's orders, newest purchase first (draft orders sort by creation time). Paginated; filterable by lifecycle status, delivery status, sales platform, purchase window, and free-text search. Archived and merged orders are excluded.

Parameters

Parameter Type Description
page integer Page number (default 1).
per_page integer Orders per page (default 20, maximum 100).
status string Order lifecycle status: pending, processing, created, purchased, completed, voided, cancelled, failed.
delivery_status string Carrier tracking state: label_created, in_transit, at_customs, customs_cleared, out_for_delivery, at_access_point, delivered, exception, returned.
source string Sales platform: shopify, ebay, or manual.
purchased_after / purchased_before string ISO8601 date or timestamp bounds on the label purchase time. Orders that haven't been purchased yet are excluded when either bound is set.
q string Free-text search across customer name/email, tracking number, and order number.

An unknown filter value returns 422 with code: "invalid_filter" and a message listing the accepted values — filters are never silently ignored.

Response

Each order carries the full order object (same shape as GET /api/v1/orders/:id), including the correlation fields — source, shopify_order_id, ebay_order_id, shopify_order_name, purchased_at, delivery_status, total (the final charge including tax), tracking_numbers (one per package), and invoice_id (the weekly statement the label charge was billed on; null until the weekly billing run). Abbreviated:

{
  "orders": [
    {
      "id": "PS-8JBH6B",
      "order_number": "PS-8JBH6B",
      "status": "purchased",
      "delivery_status": "in_transit",
      "purchased_at": "2026-07-08T14:03:22Z",
      "total": "27.61",
      "currency": "CAD",
      "source": "shopify",
      "shopify_order_id": "5723201847311",
      "ebay_order_id": null,
      "shopify_order_name": "#1042",
      "tracking_number": "1Z0JA0676704591639",
      "tracking_numbers": ["1Z0JA0676704591639"],
      "invoice_id": 2187
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 20,
    "total_count": 134,
    "total_pages": 7
  }
}

Example Requests

# Shopify shipments purchased since July 1
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  "https://www.packagesave.com/api/v1/orders?source=shopify&purchased_after=2026-07-01"

# Find an order by tracking number
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  "https://www.packagesave.com/api/v1/orders?q=1Z0JA0676704591639"

POST/api/v1/orders

Creates a new order with customer and shipping information. Automatically enqueues a background job to fetch shipping rates.

Request Body

Parameter Type Required Description
customer object Yes Customer information
customer.first_name string Yes Customer first name
customer.last_name string Yes Customer last name
customer.email string No Customer email (used for lookup)
customer.phone_number string No Customer phone number
customer.company_name string No Customer company name
shipping_address object Yes Shipping address information
shipping_address.name string No Recipient name printed on the label. Defaults to the customer's name if omitted.
shipping_address.contact_name string No Optional contact person; prints as the UPS Attention line.
shipping_address.line1 string Yes Street address line 1
shipping_address.line2 string No Street address line 2 (apt, suite, etc.)
shipping_address.line3 string No Street address line 3 (additional info)
shipping_address.city string Yes City name
shipping_address.state string Yes State/Province code (e.g., CA, ON)
shipping_address.postal_code string Yes ZIP/Postal code
shipping_address.country string Yes Country code (US, CA)
shipping_address.residential boolean No Whether address is residential (default: false)
packages array Yes Array of package objects
packages[].weight decimal Yes Package weight in kilograms
packages[].length decimal Yes Package length in centimeters
packages[].width decimal Yes Package width in centimeters
packages[].height decimal Yes Package height in centimeters
packages[].value decimal Yes Declared value for insurance and customs (in the order's customs currency — USD for shipments to the United States, otherwise the origin country's currency)
ship_from_address_id integer No ID of sender address (from GET /api/v1/shipping_addresses). Falls back to team's primary address if not specified.
shipping_address.email string International Recipient email — required at purchase for international shipments.
shipping_address.phone string International Recipient phone — required at purchase for international shipments.
shipping_address.tax_id string No Recipient/importer tax ID printed on the commercial invoice (max 15 characters). See International & customs.
shipping_address.tax_id_type string No Qualifier for the recipient tax ID (e.g. EIN, VAT, PCCC). A country-appropriate default is inferred when omitted.
customs_line_items array International Customs declaration line items — required before purchasing an international order. Fields and limits in International & customs.
customs_line_items[].customs_description string Yes* What the item is (max 35 characters).
customs_line_items[].harmonized_code string Yes* HS tariff code (6–15 alphanumeric; dots/dashes accepted).
customs_line_items[].country_of_origin string Yes* Two-letter country code where the item was made.
customs_line_items[].unit_value decimal Yes* Per-unit value > 0, in the order's customs_currency.
customs_line_items[].quantity integer Yes* Units of this item > 0.
reason_for_export string No SALE (default), GIFT, SAMPLE, REPAIR, RETURN, INTERCOMPANYDATA, OTHER.
customs_currency string No USD or CAD. Defaults: USD for Canada→US, otherwise the origin country's currency.
customs_declaration_statement string No Declaration printed on the commercial invoice (max 75 characters).
customs_comments string No Comments printed on the commercial invoice (max 150 characters).
consignee_type string No consumer (default) or business — B2C/B2B indicator for EU-bound shipments.

* Required within each customs_line_items entry; the array itself is needed only for international orders.

Response

id is the order number (a string) — use it as :id in every order URL below.

{
  "order": {
    "id": "PS-8JBH6B",
    "order_number": "PS-8JBH6B",
    "status": "pending",
    "tracking_number": null,
    "shipment_id_number": null,
    "rated_price": null,
    "currency": null,
    "created_at": "2024-01-15T10:30:00Z",
    "updated_at": "2024-01-15T10:30:00Z",
    "last_rated_at": null,
    "residential_delivery": false,
    "label_available": false,
    "label_count": 0,
    "voided_at": null,
    "failed_reason": null,
    "failed_at": null,
    "failure_details": null,
    "reason_for_export": "SALE",
    "customs_currency": "CAD",
    "customs_declaration_statement": null,
    "customs_comments": null,
    "consignee_type": "consumer",
    "customs_line_items": [],
    "purchased_at": null,
    "delivery_status": null,
    "total": null,
    "source": "manual",
    "shopify_order_id": null,
    "ebay_order_id": null,
    "shopify_order_name": null,
    "tracking_numbers": [],
    "invoice_id": null,
    "customer": {
      "id": 456,
      "first_name": "John",
      "last_name": "Doe",
      "company_name": "Acme Corp",
      "email": "john@example.com",
      "phone_number": "+1-555-0123",
      "name": "John Doe",
      "created_at": "2024-01-15T10:30:00Z",
      "updated_at": "2024-01-15T10:30:00Z"
    },
    "ship_to_address": {
      "id": 789,
      "name": "John Doe",
      "line1": "123 Main St",
      "line2": "Suite 100",
      "line3": null,
      "city": "Toronto",
      "state": "ON",
      "postal_code": "M5V 3A8",
      "country": "CA",
      "primary": false,
      "residential": false,
      "tax_id": null,
      "tax_id_type": null,
      "full_address": "123 Main St, Suite 100, Toronto, ON, M5V 3A8, CA"
    },
    "packages": [
      {
        "id": 101,
        "weight": "2.5",
        "length": "30.0",
        "width": "20.0",
        "height": "15.0",
        "value": "100.0",
        "description": "Test description",
        "unit_of_measurement": "metric",
        "has_label": false,
        "created_at": "2024-01-15T10:30:00Z",
        "updated_at": "2024-01-15T10:30:00Z"
      },
      {
        "id": 102,
        "weight": "1.2",
        "length": "25.0",
        "width": "15.0",
        "height": "10.0",
        "value": "50.0",
        "description": "Test description",
        "unit_of_measurement": "metric",
        "has_label": false,
        "created_at": "2024-01-15T10:30:00Z",
        "updated_at": "2024-01-15T10:30:00Z"
      }
    ]
  }
}

Example Request

curl -X POST \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "customer": {
      "first_name": "John",
      "last_name": "Doe",
      "email": "john@example.com",
      "phone_number": "+1-555-0123",
      "company_name": "Acme Corp"
    },
    "shipping_address": {
      "name": "John Doe",
      "line1": "123 Main St",
      "line2": "Suite 100",
      "line3": null,
      "city": "Toronto",
      "state": "ON",
      "postal_code": "M5V 3A8",
      "country": "CA",
      "residential": false
    },
    "packages": [
      {
        "weight": 2.5,
        "length": 30,
        "width": 20,
        "height": 15,
        "value": 100.00
      },
      {
        "weight": 1.2,
        "length": 25,
        "width": 15,
        "height": 10,
        "value": 50.00
      }
    ]
  }' \
  "https://www.packagesave.com/api/v1/orders"

Shipping internationally? See the full example with customs_line_items in International & customs.

GET/api/v1/orders/:id

Retrieves details for a specific order including customer, shipping address, and packages.

URL Parameters

Parameter Type Description
id string Order number (e.g. PS-8JBH6B, the id returned when creating the order). Numeric internal IDs are also accepted.

Response

{
  "order": {
    "id": "PS-8JBH6B",
    "order_number": "PS-8JBH6B",
    "status": "purchased",
    "tracking_number": "1Z999AA1234567890",
    "shipment_id_number": "SHP-2024-0123",
    "rated_price": "27.50",
    "currency": "CAD",
    "created_at": "2024-01-15T10:30:00Z",
    "updated_at": "2024-01-15T11:45:00Z",
    "last_rated_at": "2024-01-15T10:31:00Z",
    "residential_delivery": false,
    "label_available": true,
    "label_count": 2,
    "voided_at": null,
    "failed_reason": null,
    "failed_at": null,
    "failure_details": null,
    "reason_for_export": "SALE",
    "customs_currency": "CAD",
    "customs_declaration_statement": null,
    "customs_comments": null,
    "consignee_type": "consumer",
    "customs_line_items": [],
    "purchased_at": "2024-01-15T11:45:00Z",
    "delivery_status": "in_transit",
    "total": "31.08",
    "source": "manual",
    "shopify_order_id": null,
    "ebay_order_id": null,
    "shopify_order_name": null,
    "tracking_numbers": ["1Z999AA1234567890", "1Z999AA1234567891"],
    "invoice_id": 2187,
    "customer": {
      "id": 456,
      "first_name": "John",
      "last_name": "Doe",
      "company_name": "Acme Corp",
      "email": "john@example.com",
      "phone_number": "+1-555-0123",
      "name": "John Doe",
      "created_at": "2024-01-15T10:30:00Z",
      "updated_at": "2024-01-15T10:30:00Z"
    },
    "ship_to_address": {
      "id": 789,
      "name": "John Doe",
      "line1": "123 Main St",
      "line2": "Suite 100",
      "line3": null,
      "city": "Toronto",
      "state": "ON",
      "postal_code": "M5V 3A8",
      "country": "CA",
      "primary": false,
      "residential": false,
      "tax_id": null,
      "tax_id_type": null,
      "full_address": "123 Main St, Suite 100, Toronto, ON, M5V 3A8, CA"
    },
    "packages": [
      {
        "id": 101,
        "weight": "2.5",
        "length": "30.0",
        "width": "20.0",
        "height": "15.0",
        "value": "100.0",
        "description": "Test description",
        "unit_of_measurement": "metric",
        "has_label": true,
        "created_at": "2024-01-15T10:30:00Z",
        "updated_at": "2024-01-15T10:30:00Z"
      },
      {
        "id": 102,
        "weight": "1.2",
        "length": "25.0",
        "width": "15.0",
        "height": "10.0",
        "value": "50.0",
        "description": "Test description",
        "unit_of_measurement": "metric",
        "has_label": true,
        "created_at": "2024-01-15T10:30:00Z",
        "updated_at": "2024-01-15T10:30:00Z"
      }
    ]
  }
}

Response Fields

Field Type Description
status string Order status: pending, processing, created, purchased, completed, voided, cancelled, failed
tracking_number string UPS tracking number (available after purchase)
shipment_id_number string Internal shipment identifier
rated_price decimal Final price after markup (available after rate selection)
currency string Currency code (CAD, USD)
last_rated_at datetime Timestamp of last rate fetch
residential_delivery boolean Whether delivery is to a residential address
purchased_at datetime When the label was purchased; null for drafts
delivery_status string Carrier tracking state (see the orders list for the value set); null before the first scan
total decimal The final charged amount including tax; null until purchase
source string Sales platform: shopify, ebay, or manual
shopify_order_id / ebay_order_id / shopify_order_name string The originating platform's own order identifiers; null on other sources
tracking_numbers array Per-package tracking numbers (multi-package shipments carry one per package)
invoice_id integer The weekly statement the label charge was billed on — resolve via GET /api/v1/invoices/:id; null until the weekly billing run

Example Request

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/orders/123"

GET/api/v1/orders/:id/shipping_rates

Retrieves available shipping rates for an order. Returns "pending" status if rates are still being calculated.

Response (Ready)

Rates expire — check expires_at and refresh if the rate you want has lapsed. marked_up_price is the price your team pays; retail_price is the carrier's published price for comparison.

{
  "status": "ready",
  "shipping_rates": [
    {
      "id": 789,
      "service_code": "03",
      "service_name": "Ground",
      "retail_price": "38.90",
      "marked_up_price": "27.50",
      "currency": "CAD",
      "expires_at": "2024-01-16T10:31:00Z",
      "transit_days": 3
    }
  ]
}

Response (Pending)

{
  "status": "pending",
  "message": "Shipping rates are being calculated"
}

Response (Failed)

If the last rate fetch failed and produced no rates (for example, an undeliverable PO Box address), the endpoint reports the failure instead of staying "pending" forever. Fix the order details, then POST /api/v1/orders/:id/refresh_shipping_rates to try again.

{
  "status": "failed",
  "error": "UPS can't deliver to PO Box addresses."
}

Example Request

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/orders/123/shipping_rates"

POST/api/v1/orders/:id/refresh_shipping_rates

Invalidates existing shipping rates and re-enqueues the rate fetching job. Use this when order details have changed.

Response

{
  "status": "success",
  "message": "Shipping rates refresh initiated"
}

Example Request

curl -X POST \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  "https://www.packagesave.com/api/v1/orders/123/refresh_shipping_rates"

POST/api/v1/orders/:id/purchase

Purchases a shipping label for the order using the selected shipping rate. Enqueues a background job to create the shipment.

Request Body

Parameter Type Required Description
shipping_rate_id integer Yes ID of the selected shipping rate

Response

order is the full order object (same shape as GET /api/v1/orders/:id), abbreviated here:

{
  "status": "success",
  "message": "Order purchase initiated",
  "order": {
    "id": "PS-8JBH6B",
    "order_number": "PS-8JBH6B",
    "status": "processing",
    "rated_price": "27.50",
    "currency": "CAD"
  }
}

Example Request

curl -X POST \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"shipping_rate_id": 789}' \
  "https://www.packagesave.com/api/v1/orders/123/purchase"

Error Responses

A 402 Payment Required means the purchase couldn't be paid for. Branch on the code field rather than the human-readable error message:

Status code Meaning
402 payment_method_required No payment method is on file. Add a card in the web app.
402 payment_overdue A previous charge was declined. Update your card to resume shipping.
402 insufficient_prepaid_balance Team without enough account credit. Includes estimated_total, available, shortfall, and currency.
422 Missing/invalid shipping_rate_id, expired rate, or order not in a purchasable state.
422 rate_not_access_point_compatible The selected rate can't deliver to the order's UPS Access Point. Choose a different rate from the current quote, or remove the Access Point from the order.
422 return_rate_value_capped The selected rate is a UPS return-service label (is_return_rate: true), which caps declared value at $1,000 — below this shipment's declared value. Choose a forward rate from the quote.
422 ddp_forward_requires_forward_rate Your team ships Canadian imports DDP, and the selected rate is a return-service label that can't carry duties prepaid. Choose a forward rate from the quote.
422 customs_info_incomplete International order without complete customs data — provide customs_line_items when creating the order. See International & customs.
422 customs_value_exceeds_limit Total declared customs value is over the $50,000 limit for international shipments.
422 international_contact_info_incomplete International shipments need a recipient email and phone — set shipping_address.email and shipping_address.phone when creating the order.

An insufficient balance returns the shortfall so you can top up the exact amount:

{
  "error": "Insufficient balance for this label — it needs about $42.10 CAD including tax and your balance is $30.00 CAD. Add at least $12.10 CAD on the Payments page to continue. Unspent funds are refundable anytime (less card-processing fees).",
  "code": "insufficient_prepaid_balance",
  "estimated_total": "42.10",
  "available": "30.00",
  "shortfall": "12.10",
  "currency": "CAD"
}

⚠️ Async failures

Purchase enqueues a background job, so an order can still fail after a 200 (for example, balance consumed by a concurrent purchase). Poll GET /api/v1/orders/:id — a failed order exposes failed_reason and a structured failure_details (with category and, for a balance shortfall, add_funds_amount).

GET/api/v1/orders/:id/label

Downloads the shipping label for a purchased order. Returns PDF by default (recommended for printing), or PNG/base64 for programmatic access.

Query Parameters

Parameter Type Description
label_format string "pdf" (default), "png", or "base64"
package_index integer 0-based index for PNG format with multi-package orders (default: 0)
label_size string PDF page size: "4x6", "6x4", "5x7", "letter", or "6x4_letter". Defaults to your team's label size preference; invalid values fall back to it.

Response Formats

PDF (default)

Returns a PDF with all labels (one page per package) at your team's label size — or the label_size you pass. Best for thermal printers.

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/orders/123/label" \
     -o label.pdf

PNG

Returns a single label as PNG image. Use package_index for multi-package orders.

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/orders/123/label?label_format=png" \
     -o label.png

Base64 JSON

Returns raw base64 data for each package. Useful for direct thermal printer integration.

{
  "labels": [
    {
      "package_index": 0,
      "tracking_number": "1Z999AA1234567890",
      "image_format": "gif",
      "image_data": "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7..."
    }
  ]
}

Tip: Check label_available first

The order response includes label_available: true/false and label_count fields. Check these before calling the label endpoint to avoid 404 errors while the shipment is still processing.

Error Responses

// No label available yet
{ "error": "No label available. Order may still be processing." }

// Invalid package index
{ "error": "Package index 2 not found. Order has 1 package(s)." }

// Invalid label_format
{ "error": "Invalid label_format. Use 'pdf', 'png', or 'base64'." }

POST/api/v1/orders/:id/void_shipment

Voids a purchased shipment. If UPS confirms the void, the shipping charge is credited back to your account balance and the order status becomes "voided".

URL Parameters

Parameter Type Description
id integer Order ID

Requirements

  • Order must be in "purchased" status
  • Order must have a shipment ID number
  • Shipment must not have been delivered

Response (Success)

order is the full order object, abbreviated here:

{
  "status": "success",
  "message": "Shipment has been successfully voided",
  "order": {
    "id": "PS-8JBH6B",
    "order_number": "PS-8JBH6B",
    "status": "voided",
    "tracking_number": "1Z999AA1234567890",
    "shipment_id_number": "1Z999AA1234567890",
    "voided_at": "2024-01-15T12:00:00Z",
    "label_available": false
  }
}

Response (Retryable — 503)

If UPS is still processing the freshly created label, the void can't complete yet. Retry after the indicated delay:

{
  "error": "UPS is still processing this label. Retry in 15 minutes.",
  "retryable": true,
  "retry_after_seconds": 900
}

Response (Error)

{
  "error": "Failed to void shipment. Please try again or contact support."
}

What Happens When Voiding

  • The shipment is cancelled with UPS (only after UPS confirms the void)
  • Shipping label files are deleted; tracking numbers are kept for reference
  • A negative balance transaction credits the order value back to your account balance
  • Order status is updated to "voided" and the voided_at timestamp is set

Example Request

curl -X POST \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  "https://www.packagesave.com/api/v1/orders/123/void_shipment"

GET/api/v1/invoices

Lists your team's weekly statements, newest first — the same set shown on the Payments → Statements page in the web app. Read-only: there are no billing mutations in the API.

Parameters

Parameter Type Description
page integer Page number (default 1)
per_page integer Statements per page (default 20, maximum 100)

Response Fields

Field Description
id The invoice id — matches the invoice_id on orders.
statement_number The weekly statement label shown in the web app (e.g. #276). null for consolidated/memo invoices.
status draft, created, open, paid, void, or uncollectible.
start_date / invoice_date The billing week this statement covers (Sunday through Saturday).
total_amount The amount actually charged, in currency.
amount_billed_before_credit / credit_applied_total What the week's charges added up to before credit, and the credit consumed. A statement fully covered by credit has total_amount 0.00.

Response

{
  "invoices": [
    {
      "id": 2187,
      "statement_number": "#276",
      "status": "paid",
      "currency": "CAD",
      "total_amount": "213.47",
      "amount_billed_before_credit": "238.47",
      "credit_applied_total": "25.0",
      "start_date": "2026-06-28",
      "invoice_date": "2026-07-04",
      "paid_at": "2026-07-05T04:12:09Z",
      "memo": null
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 20,
    "total_count": 41,
    "total_pages": 3
  }
}

Example Request

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/invoices?per_page=50"

GET/api/v1/invoices/:id

A single statement with its per-transaction line_items, oldest first. Every line follows the ledger sign: type: "charge" for positive amounts (label purchases, adjustments) and type: "credit" for negative amounts (void refunds, account credits, applied funds). Lines that belong to an order carry its order_number and tracking_number for the reverse join; other lines (credits, rollovers) carry null.

URL Parameters

Parameter Type Description
id integer The invoice id — as returned by the statements list or an order's invoice_id.

Response

{
  "invoice": {
    "id": 2187,
    "statement_number": "#276",
    "status": "paid",
    "currency": "CAD",
    "total_amount": "213.47",
    "amount_billed_before_credit": "238.47",
    "credit_applied_total": "25.0",
    "start_date": "2026-06-28",
    "invoice_date": "2026-07-04",
    "paid_at": "2026-07-05T04:12:09Z",
    "memo": null,
    "line_items": [
      {
        "id": 90411,
        "amount": "27.61",
        "currency": "CAD",
        "type": "charge",
        "description": "Shipping label purchase - Order #12536",
        "order_number": "PS-8JBH6B",
        "tracking_number": "1Z0JA0676704591639",
        "created_at": "2026-06-30T18:22:41Z"
      },
      {
        "id": 90502,
        "amount": "-19.84",
        "currency": "CAD",
        "type": "credit",
        "description": "Shipping label void refund - Order #12401",
        "order_number": "PS-3KDPQR",
        "tracking_number": "1Z0JA0676704580021",
        "created_at": "2026-07-02T09:15:03Z"
      }
    ]
  }
}

Returns 404 for an unknown id or another team's invoice. The detail endpoint resolves every invoice_id an order reports — including invoices the statements list hides (bookkeeping entries).

Example Request

curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
     "https://www.packagesave.com/api/v1/invoices/2187"

Shipments & invoices

Reconciling shipments against your weekly statements — across sales platforms — takes three calls:

  • 1. List shipments by platformGET /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 (billing runs weekly, on the Saturday week boundary).
  • 3. Pull the statementGET /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.