shiponline
Developers

REST API + webhooks for shipping automation

Build shiponline.app into your own product. Quote multi-carrier rates, buy and refund labels programmatically, subscribe to shipment events via webhook. Bearer-token auth, JSON in/out, no extra per-label cost on top of the standard pricing.

Quick start

Three steps from zero to a printed label:

  1. Sign up at shiponline.app (free, no card required).
  2. Visit Settings → API tokens, create a token with the write:shipments scope, copy the secret (shown once).
  3. Buy your first label with one POST request:
curl
curl -X POST https://shiponline.app/api/v1/labels/create \
  -H "Authorization: Bearer shp_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "fromAddress": {
      "street1": "123 Main St",
      "city": "San Francisco",
      "state": "CA",
      "zip": "94103",
      "country": "US"
    },
    "toAddress": {
      "street1": "350 Fifth Avenue",
      "city": "New York",
      "state": "NY",
      "zip": "10118",
      "country": "US"
    },
    "parcel": {
      "lengthIn": 10,
      "widthIn": 8,
      "heightIn": 4,
      "weightOz": 16
    },
    "serviceCategory": "standard"
  }'

The response returns a Shipment object with status: "paying". Poll GET /api/v1/labels/<id> for the final status: "purchased" + labelUrl - or skip the polling and subscribe to the shipment.purchased webhook.

Authentication

Every API request must include a Bearer token in the Authorization header:

HTTP header
Authorization: Bearer shp_live_AbCdEf123456789...

Tokens have one of two scopes:

  • read:shipments - GET list/detail, POST quote
  • write:shipments - POST create + refund (also implies read access)

Requests with no token return 401 Unauthorized; requests with the wrong scope return 403 Forbidden. Tokens never expire by default but you can set an expiry at creation time + revoke any token from the settings UI.

Security tips

  • Treat tokens like passwords. Never commit them.
  • Don't put a token in a URL query parameter - URLs leak via referrer + browser history.
  • Use the minimum scope necessary. Generate a read:shipments-only token for monitoring scripts.
  • Revoke immediately if you suspect a leak.

Endpoints

Base URL: https://shiponline.app/api/v1

All endpoints accept + return JSON. UTF-8. Idempotent writes via the idempotencyKey request field (where supported).

GET/api/v1/labelsScope: read:shipments

List your recent shipments, newest first. Cursor-paginated 50 per page.

curl https://shiponline.app/api/v1/labels?cursor=cm123 \
  -H "Authorization: Bearer shp_live_..."

Response (truncated):

JSON
{
  "data": [
    {
      "id": "cm123abc",
      "createdAt": "2026-06-21T20:31:00.000Z",
      "status": "purchased",
      "carrier": "USPS",
      "service": "GroundAdvantage",
      "serviceCategory": "standard",
      "trackingNumber": "9400111899560000000000",
      "chargeAmountCents": 695,
      "carrierRateCents": 596,
      "toName": "Recipient",
      "toStreet1": "350 Fifth Avenue",
      ...
    }
  ],
  "pagination": { "hasMore": true, "nextCursor": "cm123abc" }
}
GET/api/v1/labels/{id}Scope: read:shipments

Fetch a single shipment with up to 100 tracking events ordered newest-first.

curl
curl https://shiponline.app/api/v1/labels/cm123abc \
  -H "Authorization: Bearer shp_live_..."
POST/api/v1/labels/quoteScope: read:shipments

Fetch live multi-carrier rates without purchasing. Returns the same rate-shape the buy endpoint accepts.

curl -X POST https://shiponline.app/api/v1/labels/quote \
  -H "Authorization: Bearer shp_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "fromAddress": {
      "street1": "123 Main St",
      "city": "San Francisco",
      "state": "CA",
      "zip": "94103",
      "country": "US"
    },
    "toAddress": {
      "street1": "350 Fifth Avenue",
      "city": "New York",
      "state": "NY",
      "zip": "10118",
      "country": "US"
    },
    "parcel": {
      "lengthIn": 10,
      "widthIn": 8,
      "heightIn": 4,
      "weightOz": 16
    }
  }'
POST/api/v1/labels/createScope: write:shipments

Create + buy a label in one call. Returns 202 with status='paying'; poll GET or subscribe to the shipment.purchased webhook for the final state.

Service selection: pick one approach in order of specificity:

  • carrier + service together for an exact match (e.g. "USPS" + "GroundAdvantage")
  • serviceCategory for a tier preference (cheapest matching is picked)
  • Omit both for cheapest across all carriers

For international destinations, the customs field is required.

curl
curl -X POST https://shiponline.app/api/v1/labels/create \
  -H "Authorization: Bearer shp_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "fromAddress": { "street1": "123 Main St", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" },
    "toAddress": { "street1": "350 Fifth Avenue", "city": "New York", "state": "NY", "zip": "10118", "country": "US" },
    "parcel": { "lengthIn": 10, "widthIn": 8, "heightIn": 4, "weightOz": 16 },
    "serviceCategory": "standard",
    "options": { "signatureConfirmation": true },
    "idempotencyKey": "order-1234-attempt-1"
  }'
POST/api/v1/labels/{id}/refundScope: write:shipments

Void a previously-purchased label. Returns 202 with status='void_requested'. Refund credits your shipping balance, applied automatically to your next label purchase.

curl
curl -X POST https://shiponline.app/api/v1/labels/cm123abc/refund \
  -H "Authorization: Bearer shp_live_..."

Webhooks

Subscribe to events by registering an HTTPS endpoint in Settings → API tokens → Webhooks. We POST signed JSON payloads to your URL when matching events fire.

Event types

  • shipment.purchased - label printed successfully
  • shipment.failed - label purchase failed
  • shipment.refunded - label voided
  • tracking.update - any carrier scan
  • tracking.delivered - terminal delivery event
  • tracking.exception - carrier-reported exception
  • webhook.test- fires from the "Send test" button

Payload shape

JSON
{
  "id": "evt_abc123def456",
  "type": "shipment.purchased",
  "apiVersion": "2026-06-21",
  "createdAt": "2026-06-21T20:34:12.001Z",
  "data": {
    "id": "cm123abc",
    "status": "purchased",
    "carrier": "USPS",
    "service": "GroundAdvantage",
    "trackingNumber": "9400111899560000000000",
    "labelUrl": "https://shiponline.app/labels/...",
    ...
  }
}

Signature verification

Every delivery carries an X-Shiponline-Signature header containing the HMAC-SHA256 of the raw request body using your endpoint secret. Verify in constant time to prevent timing attacks.

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(rawBody, signatureHeader, secret) {
  const expected = createHmac("sha256", secret)
    .update(rawBody, "utf8")
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express handler example
app.post("/webhooks/shiponline", express.raw({ type: "*/*" }), (req, res) => {
  const sig = req.header("X-Shiponline-Signature");
  if (!sig || !verifyWebhook(req.body.toString("utf8"), sig, process.env.SHIPONLINE_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // handle event.type ...
  res.status(200).end();
});

Retries

Failed deliveries (network error, 5xx response, 408, 429) retry with exponential backoff: 1 minute → 5 minutes → 25 minutes → 2 hours → 12 hours. After 5 total attempts the delivery is marked failed. Non-retryable 4xx responses (400, 401, 403, 404, etc.) fail immediately - the receiver said no, more attempts won't change that.

Deliveries are deduplicated per endpoint by event id. If our worker retries a dispatch internally, your receiver still sees each logical event exactly once.

Acknowledging deliveries

Respond with any 2xx status code (200, 201, 204) within 10 seconds. Don't do heavy processing in the handler - acknowledge fast + queue the work.

Errors

Standard HTTP status codes. Every error response includes an error field with a human-readable message. Validation errors include an issues array with per-field details.

StatusMeaningCommon cause
  • 400Bad requestInvalid JSON or missing required fields
  • 401UnauthorizedMissing or invalid Bearer token
  • 402Payment requiredNo payment method on file (returned from POST /labels/create)
  • 403ForbiddenToken doesn't have the required scope
  • 404Not foundResource doesn't exist OR exists but belongs to a different account
  • 409ConflictResource is in a state where the operation isn't valid (e.g. refunding an already-refunded label)
  • 422UnprocessableCarrier rejected the request (no rates available, address invalid, service unavailable)
  • 502Bad gatewayCarrier or downstream provider is unreachable; safe to retry

Rate limits

Per-token: 30 requests per minute token-bucket. Exceeding returns HTTP 429 Too Many Requests with a Retry-After header (in seconds). Webhooks are not rate-limited from your side - we control delivery rate.

Need a higher limit? Email support@shiponline.app.

Versioning

The API is versioned via URL prefix: https://shiponline.app/api/v1/... Field additions are non-breaking and ship without a version bump. Field renames, removals, and shape changes ship as a new version (/api/v2/) with at least 6 months of overlap before the prior version retires.

Webhook payload shape changes follow the same rule, with the version exposed via the apiVersion field on every event.

Start building.

Free signup. No extra per-label cost on API calls. Token creation takes 30 seconds.

Create your account