Introduction
The Shipnest REST API lets you rate-shop across every carrier you've connected, track shipments, and subscribe to lifecycle events. Everything is JSON over HTTPS. Responses are flat-shaped and consistent with the in-app data model — if a field renders in the dashboard, it's on the API.
Base URL: https://my.shipnest.app
Authentication
All endpoints accept either a browser session cookie (for in-app dashboards) or a Bearer API key. Most integrations use API keys.
Create a key in Settings → API keys. Keys are shown once at creation — we store only the hash. Revoke + regenerate if leaked.
curl https://my.shipnest.app/api/rates \
-H "Authorization: Bearer sk_live_XXXXXXXX_YYYYYYYYYYYYY" \
-H "Content-Type: application/json" \
-d '{"shipFrom": {...}, "shipTo": {...}, "parcels": [...]}'Keys are scoped to one organization and carry the same role-based permissions as the admin who created them. Prefer one key per integration so you can revoke selectively.
Rate limits
Per-organization, per-endpoint, sliding-window.
| Endpoint | Limit |
|---|---|
| POST /api/rates | 60 / minute |
| POST /api/labels | 20 / minute |
| POST /api/insurance/quote | 120 / minute |
| GET /api/tracking/:n | not limited |
Exceeded requests return 429 with Retry-After, X-RateLimit-Limit,X-RateLimit-Remaining, and X-RateLimit-Reset headers matching the Stripe / GitHub convention.
Errors
Error responses follow a single shape:
{
"error": "Invalid request body",
"issues": [ /* zod issue list, when applicable */ ]
}| Code | Meaning |
|---|---|
| 400 | Invalid body / known business rejection |
| 401 | Missing or invalid auth |
| 403 | Authenticated but the plan / role forbids it |
| 404 | Resource not found in this organization |
| 429 | Rate limit hit — honour Retry-After |
| 500 | Unexpected — correlate with /status + retry |
/api/ordersList orders
Newest-first by placedAt, cursor-paginated. Typical integration pattern: poll with since= on the initial sync, then narrow to status=AWAITING_SHIPMENT on subsequent pulls. Reads from Postgres — it does NOT trigger a live channel pull (use POST /api/orders/sync for that; currently session-only).
| Query param | Accepts |
|---|---|
| limit | 1–200, default 50 |
| cursor | order id from the previous page's nextCursor |
| status | DRAFT | AWAITING_SHIPMENT | ON_HOLD | PARTIALLY_SHIPPED | SHIPPED | DELIVERED | CANCELED | RETURNED |
| search | case-insensitive match on orderNumber or customer email |
| channel | match on sourceName or live Store.name |
| since / until | ISO dates bounding placedAt |
GET /api/orders?status=AWAITING_SHIPMENT&limit=100
Authorization: Bearer sk_live_...
→ 200 OK
{
"data": [
{
"id": "ord_...",
"orderNumber": "#1042",
"status": "AWAITING_SHIPMENT",
"channel": "sku-sync-test-2.myshopify.com",
"channelKind": "SHOPIFY",
"externalId": "6234567890123",
"customerName": "Juan Pérez",
"customerEmail": "juan@example.com",
"currency": "USD",
"subtotalCents": 12500,
"shippingCents": 899,
"taxCents": 1025,
"totalCents": 14424,
"weightG": 750,
"tags": ["gift"],
"items": [
{ "id": "...", "sku": "SKU-1", "name": "Bag", "quantity": 1, "priceCents": 12500, ... }
],
"shipTo": { "city": "...", "country": "US", ... },
"shipFrom": null,
"warehouseId": "wh_...",
"shipmentIds": [],
"placedAt": "2026-04-20T...",
...
}
],
"nextCursor": "ord_...",
"hasMore": true
}/api/orders/:idGet an order
Same shape as list rows. Returns 404 when the id doesn't exist or belongs to another tenant — no timing leak between the two.
GET /api/orders/ord_...
Authorization: Bearer sk_live_.../api/ordersCreate an order (headless / ERP push)
For stores not connected via one of our channel adapters — typically headless Next.js commerce or a custom ERP pushing into Shipnest as the shipping layer. Creates a manual order (storeId, sourceKind, sourceName all null), fires ORDER_IMPORTED rules, and stamps an audit row tagged with the caller's apiKeyId.
For orders already synced from a connected channel (Shopify, Woo, Amazon, etc.), use POST /api/orders/sync to trigger a pull from that channel instead of POSTing individual orders here.
Request
POST /api/orders
Authorization: Bearer sk_live_...
Content-Type: application/json
{
"orderNumber": "HL-1042",
"externalId": "cust-order-98765",
"currency": "USD",
"shipTo": {
"name": "Juan Pérez",
"line1": "123 Main Street",
"city": "Austin",
"state": "TX",
"postalCode": "78701",
"country": "US",
"email": "juan@example.com"
},
"items": [
{ "sku": "BAG-001", "name": "Tote", "quantity": 1, "priceCents": 4500, "weightG": 500 }
],
"tags": ["gift"],
"notes": "Handle with care",
"warehouseId": "wh_..."
}| Status | Reason |
|---|---|
| 201 | Order created — response is the full ApiOrder shape |
| 400 | Validation error (Zod issues in response body) |
| 401 | Missing / invalid Bearer |
| 409 | orderNumber already exists for this org (DUPLICATE_ORDER_NUMBER) |
| 429 | Rate limit (60/min/org) |
/api/carrier-accountsList connected carrier accounts
Discovery endpoint: the ids returned here are the valid values for carrierAccountId on POST /api/labels. Never exposes credentials. Unpaginated — real tenants have fewer than a dozen accounts.
| Field | Meaning |
|---|---|
| id | pass as carrierAccountId to POST /api/labels |
| carrier | UPS | FEDEX | DHL | USPS | ROYAL_MAIL | EASYPOST |
| nickname | Tenant-facing label from /carriers |
| accountNumber | Shipper account number, cleartext |
| source | PLATFORM (rates include markup) | CUSTOMER (tenant's own contract) |
| enabled | false = withStoreAuth tripped it off; test via the UI before buying |
| isDefault | Used when no carrierAccountId is supplied to rate-shop |
GET /api/carrier-accounts
Authorization: Bearer sk_live_...
→ 200 OK
{
"data": [
{
"id": "cacc_...",
"carrier": "UPS",
"nickname": "Acme Logistics UPS",
"accountNumber": "A1234567",
"source": "CUSTOMER",
"enabled": true,
"isDefault": true,
"createdAt": "2026-01-15T..."
}
]
}/api/ratesRate-shop across every enabled carrier
Fans out the request to every enabled carrier account and returns a sorted list of quotes plus a warnings array for carriers that failed (auth, network, no rates).
Request
{
"shipFrom": {
"name": "Main warehouse",
"line1": "1000 Shipping Lane",
"city": "Austin",
"state": "TX",
"postalCode": "78701",
"country": "US"
},
"shipTo": {
"name": "Carla Ruiz",
"line1": "Calle Mayor 24",
"city": "Madrid",
"postalCode": "28013",
"country": "ES"
},
"parcels": [
{ "weightG": 900, "lengthMm": 300, "widthMm": 200, "heightMm": 100 }
],
"options": {
"signatureLevel": "DIRECT",
"saturdayDelivery": false,
"incoterms": "DAP"
}
}Response
{
"quotes": [
{
"carrier": "UPS",
"serviceCode": "03",
"serviceName": "UPS Ground",
"totalCents": 1450,
"currency": "USD",
"estDeliveryDays": 5,
"carrierAccountId": "cacc_..."
}
],
"warnings": [
{ "carrier": "DHL", "code": "NO_RATES", "message": "..." }
]
}/api/labelsBuy a label for an order
Creates a Shipment + ShipmentPiece rows, writes the audit log, decrements stock, notifies the channel, and dispatches a shipment.label_purchased webhook. Accepts Bearer sk_live_... keys as of April 2026 — plan-cap and past-due gates still apply (API keys don't grant billing bypass). 20/min/org rate limit.
POST /api/labels
Authorization: Bearer sk_live_...
Content-Type: application/json
{
"orderId": "ord_...",
"serviceCode": "03",
"carrierAccountId": "cacc_...",
"labelFormat": "PDF",
"options": { "signatureLevel": "DIRECT" },
"idempotencyKey": "<nonce>"
}| Status | Reason |
|---|---|
| 200 | Shipment created — response carries { shipment } |
| 400 | Validation error, missing service, plan cap hit, past-due grace expired, idempotent duplicate |
| 401 | Missing / invalid Bearer or expired session |
| 429 | Rate limit (20/min/org) |
/api/labels/:id/voidVoid a label
Refunds a label with the originating carrier, reverses local + channel stock, walks the order back to AWAITING_SHIPMENT when no other active shipment exists, writes a label.voided audit row, and dispatches a shipment.voided webhook. :id is the Shipment id.
Carriers enforce per-network void windows (UPS 90d, FedEx 60d, DHL 28d, USPS 28d, Royal Mail 14d, EasyPost 30d). Past-window attempts return 400 and record a label.void_past_window audit row.
POST /api/labels/shp_.../void
Authorization: Bearer sk_live_...
→ 200 OK
{
"id": "shp_...",
"status": "VOIDED",
"carrier": "UPS",
"trackingNumber": "1Z...",
...full shipment object
}| Status | Reason |
|---|---|
| 404 | Shipment not found in your org |
| 400 | Already voided, delivered, past-window, or carrier refused |
| 429 | Rate limit (30/min/org) |
/api/shipmentsList shipments
Newest-first, cursor-paginated. Returns up to 200 rows per page (default 50). Pass nextCursor back as ?cursor= to fetch the next page.
| Query param | Accepts |
|---|---|
| limit | 1–200, default 50 |
| cursor | shipment id from the previous page's nextCursor |
| status | DRAFT | LABEL_PURCHASED | IN_TRANSIT | OUT_FOR_DELIVERY | DELIVERED | EXCEPTION | RETURNED | VOIDED |
| carrier | UPS | FEDEX | DHL | USPS | ROYAL_MAIL | EASYPOST |
| search | case-insensitive match on trackingNumber or order.orderNumber |
| since / until | ISO dates bounding createdAt |
GET /api/shipments?status=IN_TRANSIT&carrier=UPS&limit=100
Authorization: Bearer sk_live_...
→ 200 OK
{
"data": [
{
"id": "shp_...",
"orderId": "ord_...",
"orderNumber": "#1042",
"carrier": "UPS",
"serviceCode": "03",
"serviceName": "UPS Ground",
"trackingNumber": "1Z...",
"labelUrl": "https://...",
"status": "IN_TRANSIT",
"totalCostCents": 1250,
"currency": "USD",
"pieces": [],
"parcels": [{ "weightG": 500, "lengthMm": 200, ... }],
"shipFrom": { "city": "...", "country": "US", ... },
"shipTo": { "city": "...", "country": "GB", ... },
"shippedAt": "2026-04-20T...",
"deliveredAt": null,
"handedOverAt": "2026-04-20T...",
"createdAt": "2026-04-20T..."
}
],
"nextCursor": "shp_...",
"hasMore": true
}/api/shipments/:idGet a shipment
Same row shape as the list endpoint. Returns 404 when the id doesn't exist or belongs to another tenant — no timing leak between the two cases.
GET /api/shipments/shp_...
Authorization: Bearer sk_live_.../api/tracking/:trackingNumberGet latest tracking events for a shipment
Returns the shipment + sorted events. Session callers trigger a live carrier refresh; API-key callers get the persisted events (refreshed every 30 min by the cron).
GET /api/tracking/1Z999AA1012345670
Authorization: Bearer sk_live_...your endpointReceive outbound events
Configure outbound webhook URLs in Settings → Webhooks. Each attempt is logged with the status code, response body (first 4KB), and latency, viewable at /settings/webhooks/deliveries.
Events
order.imported/order.updated/order.canceledshipment.label_purchased/shipment.in_transit/shipment.delivered/shipment.exception/shipment.voidedreturn.created/return.refundedclaim.created/claim.paid
Headers
Content-Type: application/json
X-Shipnest-Event: shipment.label_purchased
X-Shipnest-Delivery: <delivery id>
X-Shipnest-Timestamp: <unix seconds>
X-Shipnest-Signature: sha256=<hmac>Verify the HMAC
import crypto from "node:crypto";
function verify(req, secret) {
const body = req.rawBody; // string, before JSON.parse
const ts = req.headers["x-shipnest-timestamp"];
const sig = req.headers["x-shipnest-signature"];
const expected = "sha256=" +
crypto.createHmac("sha256", secret)
.update(ts + "." + body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(sig)
);
}Timestamp is prefixed to the body before HMAC to defeat replay — reject deliveries older than ~5 minutes.
Versioning
The API is currently at v1 (implicit — no version prefix on the path). Breaking changes will ship under /api/v2/* with at least 6 months of overlap; non-breaking additions land in place.
SDKs
No official SDK yet. The API is plain REST + JSON; any HTTP client works. If you want typed requests, the src/types/index.ts file in the repo is the canonical schema source — copy the RateRequest and LabelRequest types directly.
Drop feedback at info@shipnest.app — SDK language priorities are driven by what customers ask for.