Appearance
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
- In Navexa, open API tokens in the menu (owners and admins) and choose the scopes the token needs. An expiry is optional.
- Copy the token when it is shown. It is shown once; if you lose it, revoke it and create another.
- 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.
| Scope | Allows |
|---|---|
orders:read | Listing and reading orders |
orders:write | Creating, updating and cancelling orders |
labels:write | Quoting, buying and voiding shipping labels (buying spends money) |
tracking:read | Reading an order's shipments and tracking |
inventory:read | Reading stock by SKU and warehouse |
location | Reading ship-from locations and warehouses |
shipping:rates | Packages, bin-packing and rate quotes for parcels you describe |
returns:labels | Quoting and buying customer return labels for Medusa returns (spends money) |
orders:labels | Reading 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_LIMITEDand aRetry-Afterheader (seconds). Every response carriesX-RateLimit-Limit,X-RateLimit-RemainingandX-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(itsdetailssay 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 }]
}itemsnames products bysku(orproduct_id, withvariant_idwhen the product has several variants). Navexa packs them into your packages using the products' dimensions and weights. Or sendpackagesinstead: the boxes as you packed them, each apackage_idfromGET /packagesor its dimensions, withweight_gramsandquantity.warehouse_id(fromGET /warehouses) is where the order ships from. Or send a one-offship_from_address.external_referenceis 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, andsignature_confirmation(NONE,SIGNATUREorADULT_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
| Endpoint | Does |
|---|---|
GET /orders | Your 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.
statusisquoted,failed(errorsays why: often a missing address field or a box with no weight),on_hold, orlabelled(it already has a label). recommendedmarks the rate your shipping rules pick, else the cheapest.cost_centsis the postage before insurance and fees.billed_by: carriermeans 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-Keyis 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 andIdempotent-Replayed: true, instead of buying another. - HTTP 402: billing refused the purchase (no payment method, past due, card declined).
codesays 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_CANCELLEDorORDER_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) orFREIGHT_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_idis optional. Without it, Navexa ships from the location that has theitemsin stock, else your default location.itemsis optional; with it,inventory_warningslists 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.
| Endpoint | Does |
|---|---|
POST /returns/{fulfillmentId}/rates | Quotes the return label rates, cheapest first. Free. Optional body: { "order_id": "..." } |
POST /returns/{fulfillmentId}/buy-label | Buys the rate you picked: { "rate_id": "..." }. Charges your account |
POST /returns/{fulfillmentId}/cancel | Cancels 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.