Labels API — Mules Integration Guide

REST API for purchasing UPS shipping labels.

Mules is an internal business branch of OwnerLabels. This document defines the API contract between the Mules application and the OwnerLabels label service.

Important: an API key is issued personally for each client. Keep it secret — it has direct access to your prepaid balance. If the key is leaked, contact support immediately to regenerate it.


Quick Start

# 1. Check balance
curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://api.ownerlabels.com/api/v1/balance

# 2. Get the current UPS Ground price (no purchase or charge)
curl -X POST https://api.ownerlabels.com/api/v1/rates \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -H "Content-Type: application/json" \
     -d @rate.json

# 3. Buy the selected rate (returns order_id + tracking + final price)
curl -X POST https://api.ownerlabels.com/api/v1/orders \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -H "Content-Type: application/json" \
     -H "Idempotency-Key: mules-label-342880" \
     -d @order.json

# 4. Download the label PDF
curl -H "Authorization: Bearer YOUR_API_KEY" \
     -o label.pdf \
     https://api.ownerlabels.com/api/v1/orders/123/label

Authentication

Every request must include the header:

Authorization: Bearer <YOUR_API_KEY>

Keys look like: lk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

On 401 Unauthorized — the key is invalid, deactivated, or missing.


Pricing

To top up your balance — contact support.


Endpoints

POST /api/v1/rates

Returns the current UPS Ground price including the OwnerLabels $3.50 markup. It does not purchase a label and does not change the client balance. The request uses the same ship_from, ship_to, and package objects as order creation.

Request body:

{
  "ship_from": {
    "name": "John Sender",
    "company": "Mules",
    "address1": "1600 Amphitheatre Pkwy",
    "address2": "Suite 200",
    "city": "Mountain View",
    "state": "CA",
    "zip": "94043",
    "country": "US",
    "phone": "5555555555"
  },
  "ship_to": {
    "name": "Jane Receiver",
    "address1": "350 Fifth Avenue",
    "city": "New York",
    "state": "NY",
    "zip": "10118",
    "country": "US",
    "phone": "5555555555"
  },
  "package": {
    "weight_lbs": 1.0,
    "weight_oz": 0,
    "length": 6,
    "width": 6,
    "height": 6
  },
  "service": "Ground",
  "carrier": "ups"
}

Response 200:

{
  "rate_id": "psq_...",
  "carrier": "ups",
  "service": "Ground",
  "price": 12.03,
  "currency": "USD"
}

Pass the returned opaque rate_id in POST /api/v1/orders so the confirmed quote stays linked to the intended purchase. If it is rejected or has expired, request a fresh rate and let the user confirm the new displayed price before retrying the purchase.

Treat rate_id as an opaque short-lived value: do not modify it and do not reuse it for a different address, parcel, or user-confirmed purchase.

GET /api/v1/healthz

Liveness probe. No authentication required.

curl https://api.ownerlabels.com/api/v1/healthz

Response 200:

{ "ok": true, "service": "labels-api", "version": "0.1.0" }

GET /api/v1/balance

Returns the current balance of the authenticated client.

curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://api.ownerlabels.com/api/v1/balance

Response 200:

{
  "client": "Acme Inc",
  "balance": 88.98,
  "currency": "USD"
}

POST /api/v1/orders

Creates an order and immediately purchases the selected UPS Ground label. Returns the order id, tracking number, and final price. Mules must call Rates first and pass its rate_id in this request.

curl -X POST https://api.ownerlabels.com/api/v1/orders \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -H "Content-Type: application/json" \
     -H "Idempotency-Key: mules-label-342880" \
     -d @order.json

Request body:

{
  "ship_from": {
    "name": "John Sender",
    "company": "Acme Inc",
    "address1": "1600 Amphitheatre Pkwy",
    "address2": "Suite 200",
    "city": "Mountain View",
    "state": "CA",
    "zip": "94043",
    "country": "US",
    "phone": "5555555555"
  },
  "ship_to": {
    "name": "Jane Receiver",
    "address1": "350 Fifth Avenue",
    "city": "New York",
    "state": "NY",
    "zip": "10118",
    "country": "US",
    "phone": "5555555555"
  },
  "package": {
    "weight_lbs": 1.0,
    "weight_oz": 0,
    "length": 6,
    "width": 6,
    "height": 6
  },
  "service": "Ground",
  "carrier": "ups",
  "external_order_id": "342880",
  "shipping_id": "SHIP-12345",
  "rate_id": "rate_..."
}

Field reference:

Field Required Type Notes
ship_from.name yes string Sender full name, 1–120 chars
ship_from.company no string Optional company name
ship_from.address1 yes string Street address
ship_from.address2 no string Apt / Suite / Unit
ship_from.city yes string
ship_from.state yes string 2-letter US state code (e.g. FL)
ship_from.zip yes string US ZIP, 5 or 9 digits
ship_from.country no string Defaults to US, 2-letter ISO
ship_from.phone no string Sender phone
ship_to.* same Same shape as ship_from
package.weight_lbs yes number Pounds (use 0 if ounces only)
package.weight_oz no number Ounces, added to pounds
package.length yes number Inches, max 108
package.width yes number Inches, max 108
package.height yes number Inches, max 108
service no string Ground (default). Mules currently supports only UPS Ground.
carrier no string Defaults to ups; only ups supported today
external_order_id no string Client/integration order id. Used as the main idempotency fallback if Idempotency-Key header is missing.
shipping_id no string Mules Shipping ID / concrete label id, stored for lookup/debugging. Used as idempotency fallback if no order id is provided.
rate_id yes for Mules string rate_id returned by the immediately preceding Rates request. It remains optional in the API only for backward compatibility.

Integration note: Mules is an internal OwnerLabels business branch. There is no provider_id to send; the integration is identified by its API key and base URL.

Duplicate protection: safest option is to send a unique Idempotency-Key per label purchase. If the header is missing, OwnerLabels dedupes retries by external_order_id; if no order id is provided, it falls back to shipping_id.

Total weight = weight_lbs * 16 + weight_oz (in ounces). Must be ≥ 1 oz.

Response 201 Created:

{
  "order_id": 123,
  "status": "purchased",
  "tracking_number": "1Z19D2C70325572916",
  "tracking_url": "https://www.ups.com/track?tracknum=1Z19D2C70325572916",
  "external_order_id": "342880",
  "shipping_id": "SHIP-12345",
  "price": 12.34,
  "label_url": "/api/v1/orders/123/label",
  "error": null
}

The label is ready immediately — fetch it with GET /api/v1/orders/123/label.

External IDs are echoed back in create/status responses so Mules can map:

external_order_id → shipping_id → order_id → tracking_number → label_url


GET /api/v1/orders/{order_id}

Returns the current status of an order. Useful for re-checking an old order or polling if you lost the original response.

curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://api.ownerlabels.com/api/v1/orders/123

Response 200:

{
  "order_id": 123,
  "status": "purchased",
  "tracking_number": "1Z19D2C70325572916",
  "tracking_url": "https://www.ups.com/track?tracknum=1Z19D2C70325572916",
  "external_order_id": "342880",
  "shipping_id": "SHIP-12345",
  "price": 12.34,
  "label_url": "/api/v1/orders/123/label",
  "error": null
}

Lookup by external IDs:

curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://api.ownerlabels.com/api/v1/orders/by-external/342880

curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://api.ownerlabels.com/api/v1/orders/by-shipping/SHIP-12345

Each endpoint returns the same OrderResponse shape as GET /api/v1/orders/{order_id}.

Order statuses:

Status Meaning
pending Order created but the label purchase hasn't completed (transient — rare)
processing Purchase outcome needs recovery/reconciliation. Do not start a replacement purchase with new identifiers; contact support.
purchased Label is ready to download
failed Purchase failed; check the error field. No charge has been applied.

GET /api/v1/orders/{order_id}/label

Downloads the label as a PDF file (Content-Type: application/pdf).

curl -H "Authorization: Bearer YOUR_API_KEY" \
     -o label_123.pdf \
     https://api.ownerlabels.com/api/v1/orders/123/label

POST /api/v1/orders/{order_id}/refund

Requests a refund for a previously-purchased label. The full price you paid is credited back to your balance (including our fee). Refunds are typically instant for UPS — your balance reflects the credit immediately.

curl -X POST \
     -H "Authorization: Bearer YOUR_API_KEY" \
     https://api.ownerlabels.com/api/v1/orders/123/refund

Response (instant refund — UPS):

{
  "order_id": 123,
  "refund_status": "refunded",
  "refund_amount": 12.84,
  "requested_at": 1781205198,
  "refunded_at": 1781205198,
  "estimated_refund_date": null,
  "message": "Refund processed instantly. Balance credited."
}

Response (pending — non-instant carriers):

{
  "order_id": 123,
  "refund_status": "requested",
  "refund_amount": null,
  "requested_at": 1781205198,
  "refunded_at": null,
  "estimated_refund_date": "2026-06-25T19:12:17+00:00",
  "message": "Refund requested. Carrier will confirm within ~14 days. Balance will be credited automatically."
}

Rules:

Checking refund status later — the existing GET /api/v1/orders/{id} response now includes refund fields:

{
  "order_id": 123,
  "status": "purchased",
  "tracking_number": "1Z2X8C700323237900",
  "price": 12.84,
  ...
  "refund_status": "refunded",
  "refund_amount": 12.84,
  "refund_requested_at": 1781205198,
  "refunded_at": 1781205198
}

refund_status is one of: none (never requested), requested (waiting on carrier), refunded (done — balance credited), denied (carrier rejected — see error field or contact support).


Errors

All errors are returned as JSON with HTTP status codes following standard REST conventions:

{ "detail": "Human-readable error message" }
HTTP Meaning What to do
400 Bad Request Malformed JSON or missing fields Check the request schema
401 Unauthorized Missing or invalid API key Verify the Authorization header
402 Payment Required Your balance is below the $10.00 minimum required to create new orders Top up your balance
404 Not Found Order id doesn't exist (or doesn't belong to your account) Check the id
409 Conflict Label not yet ready (status ≠ purchased) Poll the order endpoint
410 Gone Label file is missing on our storage Contact support
422 Unprocessable Entity Validation failed — bad ZIP, weight too low, invalid service, etc. Read detail, fix and retry
429 Too Many Requests Temporary purchase throttle/rate-limit, if enabled Slow down and retry after the Retry-After header
502 Bad Gateway Carrier (UPS) refused the shipment for a reason we couldn't categorize Read detail; usually an address issue. Retry won't help — fix and re-create the order.
503 Service Unavailable We can't reach the carrier right now (auth/network outage on our side) Retry in 1–2 minutes
504 Gateway Timeout The request timed out before a purchase could start Check the order lookup first; retry only with the same idempotency identifiers

Examples:

// 401
{ "detail": "Invalid API key" }

// 402
{ "detail": "Insufficient balance. Minimum $10.00 required to create an order, current balance is $5.40. Please top up to continue." }

// 422
{ "detail": "Service 'ups Overnight' not available for this shipment" }

// 422
{ "detail": "Package weight too small (need ≥1 oz)" }

// 503
{ "detail": "Upstream provider unavailable. Try again later." }

Best Practices

  1. Always use the two-step flow: Rates → user confirms the displayed price → Orders with the returned rate_id.
  2. Send your unique order id in external_order_id. This prevents duplicate purchases if a request is retried.
  3. Send one stable Idempotency-Key per intended label purchase. Reuse the same key when retrying that purchase; never generate a new key for a transport retry.
  4. Save order_id as soon as you receive it — use it to re-download the label later.
  5. Don't retry on 4xx errors except 429. They usually mean the request must be fixed first.
  6. After a timeout or 5xx, query by external_order_id or shipping_id first. If no order exists, retry with exponential backoff (1s, 2s, 5s, 15s) and the same idempotency identifiers. Never generate new identifiers for a transport retry.
  7. Validate addresses on your side before sending — typos in ZIP / state are the #1 cause of 422 / 502.
  8. Store the PDF after the first successful download. Re-fetching is supported but not guaranteed forever.
  9. Monitor your balance — call /balance daily; we'll add webhooks for low-balance alerts later.

Support


Changelog