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.

EndpointLimit
POST /api/rates60 / minute
POST /api/labels20 / minute
POST /api/insurance/quote120 / minute
GET /api/tracking/:nnot 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 */ ]
}
CodeMeaning
400Invalid body / known business rejection
401Missing or invalid auth
403Authenticated but the plan / role forbids it
404Resource not found in this organization
429Rate limit hit — honour Retry-After
500Unexpected — correlate with /status + retry
GET/api/orders

List 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 paramAccepts
limit1–200, default 50
cursororder id from the previous page's nextCursor
statusDRAFT | AWAITING_SHIPMENT | ON_HOLD | PARTIALLY_SHIPPED | SHIPPED | DELIVERED | CANCELED | RETURNED
searchcase-insensitive match on orderNumber or customer email
channelmatch on sourceName or live Store.name
since / untilISO 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
}
GET/api/orders/:id

Get 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_...
POST/api/orders

Create 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_..."
}
StatusReason
201Order created — response is the full ApiOrder shape
400Validation error (Zod issues in response body)
401Missing / invalid Bearer
409orderNumber already exists for this org (DUPLICATE_ORDER_NUMBER)
429Rate limit (60/min/org)
GET/api/carrier-accounts

List 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.

FieldMeaning
idpass as carrierAccountId to POST /api/labels
carrierUPS | FEDEX | DHL | USPS | ROYAL_MAIL | EASYPOST
nicknameTenant-facing label from /carriers
accountNumberShipper account number, cleartext
sourcePLATFORM (rates include markup) | CUSTOMER (tenant's own contract)
enabledfalse = withStoreAuth tripped it off; test via the UI before buying
isDefaultUsed 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..."
    }
  ]
}
POST/api/rates

Rate-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": "..." }
  ]
}
POST/api/labels

Buy 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>"
}
StatusReason
200Shipment created — response carries { shipment }
400Validation error, missing service, plan cap hit, past-due grace expired, idempotent duplicate
401Missing / invalid Bearer or expired session
429Rate limit (20/min/org)
POST/api/labels/:id/void

Void 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
}
StatusReason
404Shipment not found in your org
400Already voided, delivered, past-window, or carrier refused
429Rate limit (30/min/org)
GET/api/shipments

List 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 paramAccepts
limit1–200, default 50
cursorshipment id from the previous page's nextCursor
statusDRAFT | LABEL_PURCHASED | IN_TRANSIT | OUT_FOR_DELIVERY | DELIVERED | EXCEPTION | RETURNED | VOIDED
carrierUPS | FEDEX | DHL | USPS | ROYAL_MAIL | EASYPOST
searchcase-insensitive match on trackingNumber or order.orderNumber
since / untilISO 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
}
GET/api/shipments/:id

Get 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_...
GET/api/tracking/:trackingNumber

Get 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_...
POSTyour endpoint

Receive 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.canceled
  • shipment.label_purchased / shipment.in_transit / shipment.delivered / shipment.exception / shipment.voided
  • return.created / return.refunded
  • claim.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.