Skip to content

API ​

Navexa's API lets your own systems create and manage orders, buy and void shipping labels, read tracking and read stock, as well as quote rates and bin-pack items. A custom storefront, an ERP or a CRM can push its orders to Navexa and ship them without anyone opening the app. (The checkout rates endpoint stores call is separate: see Checkout rates.)

Base URL: https://api.navexa.co/api/v1/external

The full reference, as an OpenAPI 3 file you can load into Postman, Insomnia or a code generator, is at docs.navexa.co/openapi.yaml.

To have Navexa push order, label and tracking events to you instead of polling, see Webhooks. Orders you create through the API send order.created like any other order.

Authentication ​

  1. In Navexa, open API tokens in the menu (owners and admins) and choose the scopes the token needs. An expiry is optional.
  2. Copy the token when it is shown. It is shown once; if you lose it, revoke it and create another.
  3. Send it as a bearer token on every request:
bash
curl https://api.navexa.co/api/v1/external/orders \
  -H "Authorization: Bearer nfx_..."

Tokens start with nfx_. A token acts for the account it was created in, and only that account: another account's order, box or label id answers HTTP 404, the same as an id that doesn't exist.

ScopeAllows
orders:readListing and reading orders
orders:writeCreating, updating and cancelling orders
labels:writeQuoting, buying and voiding shipping labels (buying spends money)
tracking:readReading an order's shipments and tracking
inventory:readReading stock by SKU and warehouse
locationReading ship-from locations and warehouses
shipping:ratesPackages, bin-packing and rate quotes for parcels you describe
returns:labelsQuoting and buying customer return labels for Medusa returns (spends money)
orders:labelsReading a Medusa order's labels and what they cost

A missing, malformed, expired or revoked token gets HTTP 401; a token without the scope an endpoint needs gets HTTP 403. Errors are JSON: { "error": "...", "message": "...", "code": "..." }.

Test mode ​

Tick Test mode when you create a token to build and test an integration without spending money. A test-mode token:

  • quotes your boxes on EasyPost's test system: real carriers and services, test prices;
  • buys test labels: no postage is spent and nothing is charged. Test label ids start with atl_, and their tracking numbers never get carrier scans;
  • never changes the order: the order keeps no label, nothing prints at Pack Station and nothing is sent to your sales channel;
  • can void its test labels, and can only read and void test labels (a live label answers HTTP 409 WRONG_MODE).

Return labels and a Medusa order's labels work on live labels only. A test-mode token can't have the returns:labels or orders:labels scope, and those endpoints answer a test-mode token HTTP 409 WRONG_MODE.

Order endpoints are not sandboxed: orders you create with a test-mode token are real orders in your account. Ship them from a warehouse you use for testing, or cancel them when you're done.

A token's mode can't be changed. Make a separate live token for production.

Limits ​

  • Requests: 300 a minute per token. Past it, HTTP 429 with code RATE_LIMITED and a Retry-After header (seconds). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
  • Rate quotes: quoting asks the carriers for prices, so your account has a daily rating budget, shared by all its tokens: 100 carrier rating calls a day (UTC), plus 3 for every label the account bought in the last 24 hours, whether in Navexa or through the API. A box quoted in the last 24 hours (and unchanged since), or a parcel quoted in the last 30 minutes, comes from Navexa's rate cache and costs nothing. A quote that would pass the budget is refused with HTTP 429 RATING_BUDGET_EXCEEDED (its details say how much is used, the allowance and when it resets) until midnight UTC, or until the account buys more labels. Test-mode tokens have no rating budget.

If your integration needs higher limits, contact support.

Orders ​

Orders you create through the API are custom orders: they show in Navexa next to the orders you create there, and Navexa ships them like any other order.

Create an order ​

POST /orders (scope orders:write)

json
{
  "external_reference": "crm-1001",
  "customer_name": "Ada Lovelace",
  "customer_email": "[email protected]",
  "shipping_address": {
    "address_1": "1 Main St",
    "city": "Austin",
    "province": "TX",
    "country": "US",
    "zip": "78701"
  },
  "warehouse_id": "phl_...",
  "items": [{ "sku": "WID-001", "quantity": 2 }]
}
  • items names products by sku (or product_id, with variant_id when the product has several variants). Navexa packs them into your packages using the products' dimensions and weights. Or send packages instead: the boxes as you packed them, each a package_id from GET /packages or its dimensions, with weight_grams and quantity.
  • warehouse_id (from GET /warehouses) is where the order ships from. Or send a one-off ship_from_address.
  • external_reference is your own id for the order, unique in your account. Send it and the call is safe to retry: a reference you already used returns that order with HTTP 200 instead of creating a second one. A new order answers HTTP 201.
  • Optional: customer_phone, declared_value_cents, and signature_confirmation (NONE, SIGNATURE or ADULT_SIGNATURE; leave it out to use your account's setting).

The response is the order, with Navexa's order id (id, ord_...) and number (order_number, CUS-00001).

Read, update and cancel ​

EndpointDoes
GET /ordersYour orders, newest first. ?external_reference= finds one; limit (up to 100) and offset page
GET /orders/{orderId}One order with its items
PATCH /orders/{orderId}Changes the customer, ship-to address, warehouse_id, ship_from_address or declared_value_cents. Fields you leave out keep their value
DELETE /orders/{orderId}Cancels (deletes) the order. HTTP 204

Once an order has a label, PATCH and DELETE answer HTTP 409 ORDER_HAS_LABEL: void the label first. An order's items and packages can't be changed after it's created; cancel it and create it again.

A voided label's postage comes back to you when the carrier confirms the refund (the label's refund_status goes from submitted to refunded), and that can take days. Until then DELETE answers HTTP 409 LABEL_REFUND_PENDING, so the label stays on record for the refund: cancel the order after the refund completes. An order one of whose labels the carrier billed an adjustment on can't be deleted (HTTP 409 LABEL_ADJUSTED).

Labels ​

Buying a label takes two calls: quote the order, then buy a box at one of its rates. This works for any of your orders, including orders from Shopify, TikTok Shop or Medusa (use the Navexa order id).

Quote an order ​

POST /orders/{orderId}/rates (scope labels:write)

json
{
  "order_id": "ord_...",
  "test_mode": false,
  "boxes": [
    {
      "box_id": "pgr_...",
      "status": "quoted",
      "package": "Small box",
      "dimensions": "8x6x4 in",
      "weight_grams": 1120,
      "rates": [
        {
          "id": "shr_...",
          "carrier": "USPS",
          "service": "GroundAdvantage",
          "cost_cents": 742,
          "currency": "USD",
          "estimated_days": 3,
          "recommended": true,
          "billed_by": "navexa"
        }
      ]
    }
  ]
}
  • One entry per box. status is quoted, failed (error says why: often a missing address field or a box with no weight), on_hold, or labelled (it already has a label).
  • recommended marks the rate your shipping rules pick, else the cheapest.
  • cost_cents is the postage before insurance and fees. billed_by: carrier means your own carrier account pays the postage.
  • Rates stay buyable for up to 24 hours while the order and box don't change. If a buy answers RATE_EXPIRED, quote again.

Buy a label ​

POST /labels (scope labels:write) with an Idempotency-Key header:

bash
curl https://api.navexa.co/api/v1/external/labels \
  -H "Authorization: Bearer nfx_..." \
  -H "Idempotency-Key: 6f1c2a8e-..." \
  -H "Content-Type: application/json" \
  -d '{"box_id": "pgr_...", "rate_id": "shr_..."}'

Your account is charged the same as for a label bought in Navexa: the postage, the per-label fee and the payment fee, on your default payment method (a bank account is charged with the nightly charge). The response (HTTP 201) is the label, with tracking_number, label_url (PDF or PNG) and label_zpl_url when the carrier has one.

  • The Idempotency-Key is required. Use a new one (a UUID) for each label you mean to buy, and the same one when you retry. A retry returns the label the first request bought, with HTTP 200 and Idempotent-Replayed: true, instead of buying another.
  • HTTP 402: billing refused the purchase (no payment method, past due, card declined). code says which.
  • HTTP 409: LABEL_EXISTS (the box already has a label), RATE_EXPIRED (quote again), IDEMPOTENCY_IN_PROGRESS (a request with that key is still buying), ORDER_CANCELLED or ORDER_ON_HOLD.
  • HTTP 422: LABEL_PURCHASE_FAILED (the carrier refused the label; anything charged for it is refunded), RATE_NOT_FOR_BOX, IDEMPOTENCY_KEY_REUSED (the key was used for another box or rate) or FREIGHT_NOT_SUPPORTED (book LTL/FTL freight in Navexa).

GET /labels/{labelId} (scope labels:write or tracking:read) returns a label.

Void a label ​

POST /labels/{labelId}/void (scope labels:write) cancels a label the carrier hasn't scanned and asks for the postage back. The postage is refunded to you once the carrier confirms the refund (refund_status goes from submitted to refunded); the per-label fee is not refunded. The box's items go back into stock and the box can be bought again. A label that can't be voided (it has carrier scans, or is voided already) answers HTTP 409 LABEL_NOT_VOIDABLE.

Tracking ​

GET /orders/{orderId}/tracking (scope tracking:read): every label bought for the order, with its carrier scans, newest first.

json
{
  "order_id": "ord_...",
  "shipments": [
    {
      "id": "lbl_...",
      "box_id": "pgr_...",
      "tracking_number": "9400...",
      "carrier": "USPS",
      "status": "in_transit",
      "voided": false,
      "estimated_delivery_date": "2026-11-07T00:00:00Z",
      "events": [
        {
          "status": "in_transit",
          "message": "Arrived at USPS Regional Facility",
          "occurred_at": "2026-11-05T09:12:00Z",
          "city": "Austin",
          "state": "TX",
          "country": "US"
        }
      ]
    }
  ]
}

Inventory ​

GET /inventory (scope inventory:read): stock by SKU and warehouse, from Navexa's inventory ledger. ?sku= narrows to one SKU (exact match), ?warehouse_id= to one warehouse; limit (up to 500) and offset page.

json
{
  "inventory": [
    {
      "sku": "WID-001",
      "title": "Widget",
      "warehouse_id": "phl_...",
      "warehouse_name": "Main warehouse",
      "on_hand": 40,
      "committed": 6,
      "unavailable": 1,
      "available": 33,
      "incoming": 100
    }
  ],
  "total": 1,
  "limit": 100,
  "offset": 0
}

available is what you can sell: on hand, minus units committed to open orders and units set aside as unavailable (damaged, quarantined). incoming is on purchase orders and transfers.

Warehouses ​

GET /warehouses (scope location): your warehouses, with the warehouse_id orders ship from and stock is counted in.

json
{ "warehouses": [{ "id": "phl_...", "name": "Main warehouse", "is_default": true, "is_active": true }] }

Locations ​

GET /locations (scope location): your active ship-from locations.

json
[
  {
    "id": "<location id>",
    "name": "Main warehouse",
    "city": "Austin",
    "province": "TX",
    "zip": "78701",
    "country": "US",
    "is_active": true
  }
]

Packages ​

GET /packages (scope shipping:rates): your package templates (boxes, polybags, tubes, pallets). Dimensions in inches, weights in grams.

json
[
  {
    "id": "<package id>",
    "name": "Small box",
    "type": "box",
    "length_inches": 8,
    "width_inches": 6,
    "height_inches": 4,
    "weight_grams": 120
  }
]

Bin-pack items ​

POST /shipping/bin-pack (scope shipping:rates): which of your packages the items fit in, and how many boxes. Give a sku and Navexa uses the product's dimensions, or give the dimensions yourself. Up to 500 items and 10,000 units per request.

json
{
  "items": [
    { "id": "line-1", "name": "Widget", "quantity": 2, "sku": "WID-001" },
    {
      "id": "line-2",
      "name": "Poster tube",
      "quantity": 1,
      "weight_grams": 300,
      "length_inches": 24,
      "width_inches": 3,
      "height_inches": 3
    }
  ]
}
json
{
  "package_assignments": [
    {
      "package_name": "Small box",
      "length_inches": 8,
      "width_inches": 6,
      "height_inches": 4,
      "weight_grams": 1120,
      "item_count": 2
    }
  ],
  "skipped_items": [{ "item_id": "line-2", "name": "Poster tube", "reason": "..." }],
  "partial_quote": true,
  "total_packages": 1
}

partial_quote is true when some items were skipped; skipped_items says why.

Rates ​

POST /shipping/rates (scope shipping:rates): carrier rates for packages you describe, totalled per service, for display (they can't be bought; to buy, create an order and quote it). US addresses only for now; up to 100 packages; weights in ounces. Each package that isn't in the rate cache costs one call from your account's rating budget.

json
{
  "location_id": "<location id>",
  "destination": {
    "address1": "456 Oak Ave",
    "city": "New York",
    "state": "NY",
    "zip": "10001",
    "country": "US"
  },
  "destination_is_residential": true,
  "packages": [{ "length_inches": 8, "width_inches": 6, "height_inches": 4, "weight_oz": 18 }],
  "items": [{ "sku": "WID-001", "quantity": 2 }]
}
  • location_id is optional. Without it, Navexa ships from the location that has the items in stock, else your default location.
  • items is optional; with it, inventory_warnings lists SKUs the chosen location can't fully cover.
json
{
  "rates": [
    {
      "id": "USPS_GroundAdvantage_USD",
      "carrier": "USPS",
      "service": "GroundAdvantage",
      "service_code": "GroundAdvantage",
      "cost_cents": 742,
      "currency": "USD",
      "estimated_days": 3,
      "package_count": 1
    }
  ],
  "location_id": "<location id>",
  "quoted_at": "2026-11-03T17:00:00Z",
  "expires_at": "2026-11-03T17:30:00Z",
  "quote_id": "sq_..."
}

A carrier that fails to quote gets HTTP 502 with code RATE_PROVIDER_UNAVAILABLE.

Medusa order labels ​

GET /orders/{orderId}/labels (scope orders:labels): the live labels bought for a Medusa order, by its Medusa order id (the Navexa Medusa plugin uses this). An empty list means nothing has shipped yet; an unknown order is HTTP 404.

json
{
  "labels": [
    {
      "tracking_number": "9400...",
      "carrier": "USPS",
      "postage_cents": 742,
      "insurance_cents": 0,
      "is_freight": false,
      "orders_on_shipment": 1,
      "purchased_at": "2026-11-03T17:05:00Z"
    }
  ]
}

postage_cents is null when the purchase recorded no price. orders_on_shipment above 1 means a consolidated box whose postage is shared with other orders.

Return labels ​

For a return created in Medusa, keyed by its Medusa return fulfillment id (scope returns:labels). The Navexa Medusa plugin uses these. Live tokens only: a test-mode token gets HTTP 409 WRONG_MODE.

EndpointDoes
POST /returns/{fulfillmentId}/ratesQuotes the return label rates, cheapest first. Free. Optional body: { "order_id": "..." }
POST /returns/{fulfillmentId}/buy-labelBuys the rate you picked: { "rate_id": "..." }. Charges your account
POST /returns/{fulfillmentId}/cancelCancels the return, refunding its label first if one was bought

Buying twice, or with a rate from an expired quote, is refused with HTTP 409 (RETURN_LABEL_ALREADY_BOUGHT, RETURN_RATE_STALE). Get rates again and retry.