API Reference · v1

Drop a shipment in, get a label back.

REST, JSON, idempotency keys, signed webhooks. One auth header, no SDK lock-in. Browse the full reference below — when you're ready, create a key from your dashboard.

Endpoint index
Everything in the v1 API at a glance. Every route requires a Bearer API key.
EndpointWhat it doesNeeds
POST /v1/rates/quoteLive marked-up rates, no draftread
POST /v1/addresses/validateVerify + normalize an addressread
POST /v1/shipmentsCreate a draft shipment (auto-rated)write
GET /v1/shipmentsList shipments (cursor-paginated)read
GET /v1/shipments/:idRetrieve one shipmentread
PATCH /v1/shipments/:idRe-select carrier on a draftwrite
POST /v1/shipments/:id/purchaseBuy the label (wallet debit)write + live
POST /v1/shipments/:id/voidCancel a label + refundwrite + live
POST /v1/shipments/batchUp to 50 drafts in one callwrite
POST /v1/shipments/batch-purchaseBuy up to 10 labels in one callwrite + live
GET /v1/shipments/:id/trackingLive tracking + checkpointsread
GET /v1/tracking/:numberSame, by tracking numberread
POST /v1/shipments/:id/returnIssue a return label (wallet debit)write + live
GET /v1/carriersCarrier + service catalogread
GET /v1/boxesFlat-rate + custom box catalogread
GET/POST /v1/addressesAddress book list / saveread / write
GET/PATCH/DELETE /v1/addresses/:idAddress book manageread / write
GET /v1/walletBalance + pending refundsread
GET /v1/wallet/transactionsFull wallet ledger (paginated)read
GET /v1/adjustmentsAll carrier adjustments (paginated)read
GET /v1/shipments/:id/adjustmentsOne shipment's adjustmentsread
POST /v1/pickupsSchedule a carrier pickupwrite + live
GET /v1/pickups · POST /v1/pickups/:id/cancelList / cancel pickupsread / write
POST /v1/webhooks · GET /v1/webhooksRegister / list endpointswrite / read
PATCH/DELETE /v1/webhooks/:idUpdate / remove an endpointwrite
GET /v1/webhooks/:id/deliveriesDelivery log (per attempt)read
POST …/deliveries/:id/replayRe-send a recorded deliverywrite
POST /v1/webhooks/:id/testSend a signed ping nowwrite

Needs: read works with any key; write requires the write scope; live means blocked for sk_test_ keys (money / real carrier actions). Details in Authentication.

Authentication
Every public endpoint is gated by a Bearer-token API key.

Create a key on Sign up to create an API key and store it in an environment variable on your server. Keys are prefixed sk_live_ (production) or sk_test_ (everywhere else) and the full secret is shown once at create time — copy it then.

bash
# Pass the key on every request
curl https://goatlabels.io/api/v1/shipments \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"

Rate limit: every plan gets the same 300 req/min burst window with unlimited monthly calls — it's a shared-pool safety limit, not a tier gate. The exact ceiling for the current request is always echoed back as X-RateLimit-Limit, alongside X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds). Over-limit requests get 429 with a Retry-After header in seconds. Each key's Last used timestamp is shown on the keys page so you can spot stale ones.

Key capability scopes

Each key carries a set of scopes that gate what it can do:

  • Full access (read + write) — can read and mutate (create, purchase, void, schedule pickups, manage webhooks).
  • Read-only (read) — can list, retrieve, track, quote rates, and validate addresses, but every mutating endpoint returns 403 insufficient_scope.

POST /v1/rates/quote and POST /v1/addresses/validate are POST but non-mutating, so a read-only key can call them. Legacy keys with no scopes set are treated as unrestricted.

Test-mode keys

A sk_test_ key can quote rates, validate addresses, and create / manage draft shipments — but it cannot spend money or trigger real carrier actions. Purchasing a label, voiding, batch-purchasing, or scheduling a pickup returns 403 test_mode_unsupported. Use a sk_live_ key for those.

Rate quote
POST /v1/rates/quote — live, marked-up carrier rates for a parcel. Read-only: no draft, no wallet debit.

Quote rates without creating a shipment. Prices are the same GoatLabels-marked-up rates you'd get on create; the underlying carrier pricing is never exposed. Works with a read-only key.

cURL
curl -X POST https://goatlabels.io/api/v1/rates/quote \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": { "postal_code": "94105", "country": "US" },
    "to":   { "postal_code": "M5H 1A1", "country": "CA" },
    "parcel": { "length": 12, "width": 9, "height": 4, "weight": 2, "weight_unit": "lb" },
    "items": [{ "description": "T-shirt", "quantity": 1, "declared_value": 25,
                "currency": "USD", "country_of_origin": "US", "hs_code": "6109.10" }]
  }'

Lithium items must include both unNumber and packagingMode (same guard as create) or you'll get a 400 hazmat_unNumber_required. Creating a shipment auto-rates and echoes the same rate set inline as shipment.rates[], so a single POST /v1/shipments can skip this call.

Create a shipment
POST /v1/shipments — creates a draft shipment that you can later purchase a label for.

All fields use snake_case. Country codes are 2-letter ISO. The response includes a stable id prefixed shp_ and object: "shipment". Instead of a full from block you can pass from_address_id (a saved address book entry), and parcel.box_slug pins a catalog box (flat-rate pricing included; with a box pinned, dims — and for flat-rate boxes, weight — become optional).

cURL
curl -X POST https://goatlabels.io/api/v1/shipments \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "from": {
      "name": "Warehouse",
      "line1": "1 Market St", "city": "San Francisco",
      "state": "CA", "postal_code": "94105", "country": "US"
    },
    "to": {
      "name": "Jane Doe",
      "line1": "9 King St", "city": "Toronto",
      "state": "ON", "postal_code": "M5H 1A1", "country": "CA"
    },
    "parcel": {
      "length": 12, "width": 9, "height": 4,
      "weight": 2, "weight_unit": "lb"
    },
    "items": [{
      "description": "T-shirt", "quantity": 1,
      "declared_value": 25, "currency": "USD",
      "country_of_origin": "US", "hs_code": "6109.10"
    }],
    "reference": "ORD-1042",
    "acknowledgements": {
      "noHazardous": true,
      "noPerishable": true,
      "noProhibited": true,
      "accurateDeclaration": true,
      "carrierTermsAccepted": true,
      "version": "v1-2026-05",
      "acceptedAt": "2026-05-08T12:00:00Z"
    }
  }'
Node.js
import fetch from "node-fetch";
import { randomUUID } from "node:crypto";

const res = await fetch("https://goatlabels.io/api/v1/shipments", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GOATLABELS_API_KEY}`,
    "Content-Type": "application/json",
    // A fresh UUID per *intent*. Reuse the same key on retries.
    "Idempotency-Key": randomUUID(),
  },
  body: JSON.stringify({
    from: { name: "Warehouse", line1: "1 Market St", city: "San Francisco",
            state: "CA", postal_code: "94105", country: "US" },
    to:   { name: "Jane Doe",  line1: "9 King St",   city: "Toronto",
            state: "ON", postal_code: "M5H 1A1",     country: "CA" },
    parcel: { length: 12, width: 9, height: 4, weight: 2, weight_unit: "lb" },
    items:  [{ description: "T-shirt", quantity: 1, declared_value: 25,
               currency: "USD", country_of_origin: "US", hs_code: "6109.10" }],
    reference: "ORD-1042",
    // Compliance attestation. Strongly encouraged on create;
    // REQUIRED on POST /v1/shipments/:id/purchase.
    acknowledgements: {
      noHazardous: true,
      noPerishable: true,
      noProhibited: true,
      accurateDeclaration: true,
      carrierTermsAccepted: true,
      version: "v1-2026-05",
      acceptedAt: new Date().toISOString(),
    },
  }),
});

if (!res.ok) throw new Error(`GoatLabels: ${res.status} ${await res.text()}`);
const shipment = await res.json();
console.log(shipment.id, shipment.status);
Python
import os, uuid, requests

res = requests.post(
    "https://goatlabels.io/api/v1/shipments",
    headers={
        "Authorization": f"Bearer {os.environ['GOATLABELS_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "from": {"name": "Warehouse", "line1": "1 Market St", "city": "San Francisco",
                 "state": "CA", "postal_code": "94105", "country": "US"},
        "to":   {"name": "Jane Doe",  "line1": "9 King St",   "city": "Toronto",
                 "state": "ON", "postal_code": "M5H 1A1",     "country": "CA"},
        "parcel": {"length": 12, "width": 9, "height": 4, "weight": 2, "weight_unit": "lb"},
        "items":  [{"description": "T-shirt", "quantity": 1, "declared_value": 25,
                    "currency": "USD", "country_of_origin": "US", "hs_code": "6109.10"}],
        "reference": "ORD-1042",
        # Compliance attestation. Strongly encouraged on create;
        # REQUIRED on POST /v1/shipments/:id/purchase.
        "acknowledgements": {
            "noHazardous": True,
            "noPerishable": True,
            "noProhibited": True,
            "accurateDeclaration": True,
            "carrierTermsAccepted": True,
            "version": "v1-2026-05",
            "acceptedAt": "2026-05-08T12:00:00Z",
        },
    },
    timeout=10,
)
res.raise_for_status()
print(res.json()["id"])
Example response (201 Created)
JSON
{
  "id": "shp_1731000000000_abc123",
  "object": "shipment",
  "created": 1731000000,
  "status": "draft",
  "reference": "ORD-1042",
  "from": { "name": "Warehouse", "line1": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105", "country": "US", ... },
  "to":   { "name": "Jane Doe",  "line1": "9 King St",   "city": "Toronto",       "state": "ON", "postal_code": "M5H 1A1", "country": "CA", ... },
  "parcel": { "length": 12, "width": 9, "height": 4, "dimension_unit": "in", "weight": 2, "weight_unit": "lb" },
  "items": [{ "description": "T-shirt", "quantity": 1, "declared_value": 25, "currency": "USD", "country_of_origin": "US", "hs_code": "6109.10", "sku": null }],
  "service": null,
  "tracking_number": null,
  "label_url": null,
  "documents": [],
  "amount": { "subtotal": 0, "duty_and_tax": 0, "total": 0, "currency": "USD" }
}

Status values: draft purchased in_transit delivered. Other terminal states are exception, returned, and voided.

Customs items (items[]): optional for domestic shipments (from.country = to.country) — omit it and a default package item is used. For international shipments items is required and every item's declared_value must be greater than 0 (it's used as the customs declared value); requests that violate this return a 422 items_invalid pointing at the offending index (e.g. items[0].declared_value).

Reason for export (reason_for_export): optional customs declaration for international shipments — one of merchandise, gift, sample, documents, return, repair, personal_effects, or other. It prints on the commercial invoice and is passed through to the carrier's electronic trade documents; it never changes rates or duty estimates. Ignored (stored as null) on domestic lanes, and echoed back on the shipment object.

Tax IDs (from.tax_id / to.tax_id): optional party-level identifier (EORI, VAT, EIN, ABN, CPF). Sender tax ID goes on from, recipient on to. Forwarded to the carrier as the address-level tax ID on international labels and printed on the commercial invoice. You can also save tax_id on an address book entry so from_address_id carries it automatically.

Customs overrides (customs_overrides): per-shipment override of the account-level IOSS / EORI / UK VAT numbers (Settings → Company) and of auto-attached US Section 321. Keys: ioss_number, eori_number, uk_vat_number, section_321_opt_out. Omitted keys keep the account default — those defaults already auto-apply to international v1 shipments by destination and declared-value threshold. Echoed on the shipment as an object of only the keys that were stored (empty object when none).

Catalog boxes (parcel.box_slug): when a catalog box is pinned, length / width / height are optional — the box's dimensions are used, and for flat-rate-priced slugs the box dimensions always override anything you send (the container fixes the size). Flat-rate slugs also make weight optional (defaults to 1 lb — flat-rate pricing is weight-independent) up to the carrier's cap: 70 lb for USPS flat rate, 50 lb for FedEx One Rate (a structured 400 invalid_request_error beyond that). Without box_slug, dims and weight stay required. Same rules apply on POST /v1/rates/quote.

Flat-rate service inference: if box_slug is omitted but service clearly names a USPS flat-rate product (e.g. "USPS Medium Flat Rate", "flat rate envelope"), the matching catalog box is pinned automatically so you get the flat-rate price instead of silent weight-based pricing. When you supply dims, the best-fitting variant is chosen (e.g. medium box 1 vs 2); otherwise variant 1. An explicit box_slug always wins, and non-USPS carriers never infer.

Rate fallback (rate_fell_back): when the request asked for a specific rate or service (rate_id, courier_settings, or service), the 201 response carries a rate_fell_back boolean — true means the requested service couldn't be matched and the persisted rate is a substitute (cheapest eligible). Absent when no specific selection was requested. To fail instead of substituting, set courier_settings.allow_courier_fallback: false — you'll get 422 courier_not_available whenever the requested rate or service can't be matched, including when the carrier itself is available but the specific service isn't.

Dangerous goods (parcel.hazmat)

Send a parcel.hazmat block when the package carries regulated items (lithium batteries, aerosols, perfume, dry ice, etc.). The block mirrors the dashboard's HazmatDeclaration / HazmatItem schemas — same wizard data, re-shaped for partners that don't go through the customer UI. Mixed-DG packages use items[]; single-class packages may pass only the top-level mirror fields.

Lithium metadata is required. Items whose category is lithium_ion or lithium_metal MUST include both unNumber (e.g. UN3480 / UN3481 / UN3090 / UN3091) and packagingMode (standalone | with_equipment | in_equipment). Both populate the Shipper's Declaration for Dangerous Goods (IATA / 49 CFR §172.204): unNumber fills the "UN" column and packagingMode selects PI965 / PI966 / PI967. Missing either field returns a 400 hazmat_unNumber_required with an offenders array pointing at the failing item indices. The server re-derives unNumber, packingInstruction, hazmatClass, and requiredLabels at quote / purchase time, so client-supplied values for those fields are accepted but ignored.

cURL
curl -X POST https://goatlabels.io/api/v1/shipments \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "from": {
      "name": "Warehouse",
      "line1": "1 Market St", "city": "San Francisco",
      "state": "CA", "postal_code": "94105", "country": "US"
    },
    "to": {
      "name": "Jane Doe",
      "line1": "9 King St", "city": "Toronto",
      "state": "ON", "postal_code": "M5H 1A1", "country": "CA"
    },
    "parcel": {
      "length": 8, "width": 6, "height": 3,
      "weight": 1.2, "weight_unit": "lb",
      "hazmat": {
        "items": [{
          "category": "lithium_ion",
          "unNumber": "UN3481",
          "packagingMode": "with_equipment",
          "wattHours": 50,
          "quantityPerPackage": 1
        }]
      }
    },
    "items": [{
      "description": "Power bank (lithium-ion, 50Wh)",
      "quantity": 1, "declared_value": 40, "currency": "USD",
      "country_of_origin": "CN", "hs_code": "8507.60"
    }],
    "reference": "ORD-2099"
  }'

Other structured 400 errors

The error envelope's code field distinguishes the structured cases so your integration can branch without parsing messages:

  • hazmat_unNumber_required — a lithium_ion / lithium_metal item is missing unNumber and/or packagingMode. See the lithium example above.
  • hs_code_required — an international shipment (from.country to.country) has one or more items missing a real HS tariff code on items[].hs_code. We refuse at create so partners don't 400 at mid-checkout.
  • otherwise — generic schema validation failure; see issues for the Zod path / message detail.

Compliance attestation (acknowledgements)

Send an acknowledgements block on every shipment you intend to buy a label for. By including it you confirm — on behalf of the shipper of record — that the parcel contains no hazardous, perishable, or prohibited items, that the declared contents, weight, and customs value are accurate, and that you accept the carrier and GoatLabels terms. We stamp your request IP and user-agent server-side and persist the full record so compliance can prove what you agreed to.

Policy: the field is strongly encouraged on create and required on POST /v1/shipments/:id/purchase. When omitted on create, the response carries the header Warning: 299 - "GOAT_ACK_RECOMMENDED: …" so you can wire the upgrade in advance.

Required fields: noHazardous, noPerishable, noProhibited, accurateDeclaration, carrierTermsAccepted (all must be true), version (the disclaimer version your integration showed the shipper, e.g. "v1-2026-05"), and acceptedAt (ISO 8601 timestamp of when the shipper accepted).

Retrieve a shipment
GET /v1/shipments/:id — fetch a single shipment you own.
cURL
curl https://goatlabels.io/api/v1/shipments/shp_… \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"

Returns the full shipment object (same shape as create). A 404 not_found is returned for ids that don't belong to the calling key — shipments are never visible across accounts.

Change carrier
PATCH /v1/shipments/:id — re-price / re-select the carrier on a draft, before you buy the label.

Re-runs the rate engine against the draft's saved address / parcel / items, picks the requested rate, and updates the courier and total cost. Only works while the shipment is a draft (to_ship); a purchased or voided shipment returns 409. Returns the updated shipment plus the fresh rates[].

cURL
# Re-select the carrier on a draft (re-quotes + updates total cost).
curl -X PATCH https://goatlabels.io/api/v1/shipments/shp_… \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "service": "UPS Ground" }'

# Or pin an exact rate id returned by POST /v1/shipments (shipment.rates[].rate_id):
#   { "rate_id": "rate_…" }
# Or the structured courier-settings block:
#   { "courier_settings": { "courier_service_id": "…", "allow_courier_fallback": false } }

Select a carrier by friendly service string, an exact rate_id from shipment.rates[], or the structured courier_settings block (courier_service_id, allow_courier_fallback). When the requested carrier isn't available and fallback is disabled you get 422 courier_not_available; otherwise it falls back to the cheapest rate.

Reason for export (reason_for_export): the customs declaration (same enum as create: merchandise, gift, sample, documents, return, repair, personal_effects, other) can be updated here too. International only — it prints on the commercial invoice and is stored as null on domestic lanes. Sent on its own (no carrier-selection field), the update is applied directly: no re-rate runs and the response omits rates[].

Purchase a label
POST /v1/shipments/:id/purchase — debits your wallet for the carrier-quoted price and issues the label.

This is the wallet-debit moment for partners. The acknowledgements block is required on this endpoint — there's no deprecation window because the v1 purchase surface is brand-new. Validation, persisted shape, and IP/user-agent stamping mirror the dashboard checkout flow exactly. The response is the updated shipment row including tracking_number, label_url, and documents.

cURL
curl -X POST https://goatlabels.io/api/v1/shipments/shp_…/purchase \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "acknowledgements": {
      "noHazardous": true,
      "noPerishable": true,
      "noProhibited": true,
      "accurateDeclaration": true,
      "carrierTermsAccepted": true,
      "version": "v1-2026-05",
      "acceptedAt": "2026-05-08T12:00:00Z"
    }
  }'

Compliance attestation (acknowledgements)

By including this block your integration confirms — on behalf of the shipper of record — that the parcel contains no hazardous, perishable, or prohibited items, that the declared contents, weight, and customs value are accurate, and that you accept the carrier and GoatLabels terms. We stamp your request IP and user-agent server-side and persist the full record on shipment.purchaseAcknowledgements so compliance can prove what you agreed to at the moment the wallet was debited.

Required fields (the request 400s if any is missing or set to anything other than true): noHazardous, noPerishable, noProhibited, accurateDeclaration, carrierTermsAccepted, version (the disclaimer version your integration showed the shipper, e.g. "v1-2026-05"), and acceptedAt (ISO 8601 timestamp of when the shipper accepted).

Other responses: 402 when the wallet has insufficient funds (top up via the dashboard wallet and retry the same Idempotency-Key); 409 when the shipment has already been purchased or voided; 503 with a Retry-After header during a carrier upstream outage (your wallet is automatically refunded).

Void a label
POST /v1/shipments/:id/void — cancel a purchased label and refund your wallet.

Cancels the label with the carrier, transitions the shipment to voided, and refunds your wallet (net of any non-refundable debit), following the same void/refund policy as the dashboard. Returns the updated shipment.

cURL
curl -X POST https://goatlabels.io/api/v1/shipments/shp_…/void \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"

404 if the shipment isn't yours, 409 if it can't be voided in its current state, and 503 service_unavailable if the carrier cancel is temporarily unreachable.

Batch create + buy
Create up to 50 drafts and purchase up to 10 labels per call, with per-item results.

POST /v1/shipments/batch creates up to 50 drafts in one request. Each entry is the same body as POST /v1/shipments. Batch drafts aren't auto-rated (50 synchronous rate calls won't fit one request) — rate each later via PATCH or at purchase. A partial failure doesn't lose the good ones.

cURL
# Up to 50 drafts in one call. Each entry is the SAME body as POST /v1/shipments.
# Drafts are NOT auto-rated in batch — rate each via PATCH or at purchase.
curl -X POST https://goatlabels.io/api/v1/shipments/batch \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "shipments": [
      { "from": { … }, "to": { … }, "parcel": { … }, "reference": "ORD-1" },
      { "from": { … }, "to": { … }, "parcel": { … }, "reference": "ORD-2" }
    ]
  }'
JSON
{
  "object": "batch",
  "created_count": 2,
  "error_count": 0,
  "created": [ { "id": "shp_…", "object": "shipment", "status": "draft", ... } ],
  "errors": []
}

POST /v1/shipments/batch-purchase buys labels for up to 10 drafts. One acknowledgements block covers the whole batch (same shape as single purchase). Each item is independently atomic — wallet debit with refund-on-carrier-failure — so a mid-batch failure never corrupts the others.

cURL
# Buy labels for up to 10 drafts. One acknowledgements block covers the batch.
# Each item is independently atomic (debit + refund-on-failure).
curl -X POST https://goatlabels.io/api/v1/shipments/batch-purchase \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "shipment_ids": ["shp_a", "shp_b"],
    "acknowledgements": {
      "noHazardous": true, "noPerishable": true, "noProhibited": true,
      "accurateDeclaration": true, "carrierTermsAccepted": true,
      "version": "v1-2026-05", "acceptedAt": "2026-05-08T12:00:00Z"
    }
  }'
JSON
{
  "object": "batch_purchase",
  "purchased_count": 1,
  "failed_count": 1,
  "results": [
    { "shipment_id": "shp_a", "ok": true,  "status": 200, "response": { ... } },
    { "shipment_id": "shp_b", "ok": false, "status": 402, "response": { "error": { ... } } }
  ]
}
List shipments
GET /v1/shipments — newest first. Paginated by limit (1–100, default 25).
cURL
curl https://goatlabels.io/api/v1/shipments?limit=25 \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"
JSON
{
  "object": "list",
  "has_more": false,
  "data": [ { "id": "shp_…", "object": "shipment", "status": "purchased", ... } ]
}
Live tracking
GET /v1/shipments/:id/tracking and GET /v1/tracking/:tracking_number — live status, ETA, and per-scan checkpoints.

Both routes return the same live snapshot: normalized status, the carrier's eta_date, a hosted tracking_page_url for your end customers, and the full scan-by-scan checkpoints[] timeline (newest first). The shipment-id route works even before the carrier's first scan.

cURL
# By tracking number…
curl https://goatlabels.io/api/v1/tracking/1Z999AA10123456784 \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"

# …or by shipment id (works before the first carrier scan too)
curl https://goatlabels.io/api/v1/shipments/shp_…/tracking \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"
JSON
{
  "object": "tracking",
  "tracking_number": "1Z999AA10123456784",
  "shipment_id": "shp_…",
  "carrier": "UPS",
  "status": "in_transit",
  "live": true,
  "eta_date": "2026-07-08",
  "tracking_page_url": "https://…",
  "checkpoints": [
    { "time": "2026-07-03T09:12:00Z", "location": "San Francisco, CA", "message": "Departed facility", "status": "in_transit" },
    { "time": "2026-07-02T18:40:00Z", "location": "San Francisco, CA", "message": "Origin scan",       "status": "in_transit" }
  ]
}

live: true means the carrier feed was reached on this request; live: false means the persisted record is being reported (feed temporarily unreachable or the label is too new) — we never invent a timeline. checkpoints is empty until the first physical scan. Rather than polling, subscribe to the tracking webhook events (shipment.in_transit shipment.out_for_delivery shipment.delivered) and use these routes for the detail view. Returns 404 if the shipment or tracking number isn't owned by the calling key.

Return labels
POST /v1/shipments/:id/return — issue a return label for a purchased shipment. Wallet debit, refunded automatically on carrier failure.

Pass the original outbound shipment's id. Origin and destination are swapped automatically and the same carrier + service the outbound label used is pinned, so the return matches the outbound format. The label cost is debited from your wallet at the same marked-up price you'd see in the dashboard; if the carrier fails to issue the label, the debit is refunded automatically.

cURL
curl -X POST https://goatlabels.io/api/v1/shipments/shp_…/return \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Customer return — wrong size", "refund_amount": 25 }'
JSON
{
  "object": "return",
  "id": "ret_…",
  "shipment_id": "shp_…",
  "status": "label_created",
  "carrier": "USPS",
  "tracking_number": "9400…",
  "label_url": "https://…",
  "reason": "Customer return — wrong size",
  "refund_amount": 25,
  "cost": 6.42,
  "created": 1731000000
}

refund_amount is bookkeeping only (what you intend to refund your buyer) — GoatLabels doesn't move that money. Requires a write-scope live key. 402 when the wallet can't cover the label, 409 when the outbound label was never issued (draft / voided) or no return rates are available, 503 + auto-refund on a carrier outage.

Carrier catalog
GET /v1/carriers — every carrier + service the rate engine quotes, with transit windows. Build service pickers from this.
cURL
curl https://goatlabels.io/api/v1/carriers \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"
JSON
{
  "object": "list",
  "data": [
    {
      "object": "carrier",
      "id": "usps",
      "name": "USPS",
      "services": [
        { "name": "USPS Priority Mail", "international": false, "ddp_supported": false, "transit_time": "1-3 working days" },
        { "name": "USPS Priority Mail Express", "international": false, "ddp_supported": false, "transit_time": "1-2 working days" }
      ]
    }
  ],
  "has_more": false
}

Service name strings are valid values for the loose service selector on POST /v1/shipments / PATCH. Which services actually quote for a given parcel depends on route and size — treat this as the menu, and the rate response as the truth.

Boxes catalog
GET /v1/boxes — discoverable box_slug values: carrier flat-rate packaging + your saved custom parcels.

Pass a carrier box's slug as parcel.box_slug on quotes and creates to pin that packaging — flat_rate: true slugs get the carrier's flat-rate price (fixed by the container, not the weight, up to the carrier's cap) and the box's dimensions are used to quote every other carrier too. With a box pinned, length / width / height are optional (flat-rate slugs always use the box's own dimensions), and flat-rate slugs make weight optional too.

cURL
curl https://goatlabels.io/api/v1/boxes \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"
JSON
{
  "object": "box_catalog",
  "carrier_boxes": [
    {
      "object": "carrier_box",
      "slug": "usps_priority_flat_medium_1",
      "carrier": "USPS",
      "name": "Priority Mail Medium Flat Rate Box — 1",
      "length": 11.25, "width": 8.75, "height": 6,
      "dimension_unit": "in",
      "flat_rate": true
    }
  ],
  "custom_boxes": [
    {
      "object": "custom_box",
      "id": "box_…",
      "name": "Sneaker box",
      "length": 14, "width": 10, "height": 5,
      "dimension_unit": "in",
      "weight": 2, "weight_unit": "lb",
      "type": "box",
      "default": true
    }
  ]
}

custom_boxes are the parcel presets saved in your dashboard (Settings → Boxes) — returned here so integrations can mirror the same presets. Unknown box_slug values are rejected with 400.

Address book
GET/POST /v1/addresses, GET/PATCH/DELETE /v1/addresses/:id — saved addresses, usable as from_address_id on shipment creates.

Save your warehouses once, then create shipments with from_address_id instead of repeating the full from block. Exactly one of the two must be present on POST /v1/shipments.

cURL
# Save a warehouse once…
curl -X POST https://goatlabels.io/api/v1/addresses \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "SF Warehouse", "default": true,
    "name": "Warehouse", "line1": "1 Market St",
    "city": "San Francisco", "state": "CA",
    "postal_code": "94105", "country": "US",
    "phone": "+14155550100"
  }'

# …then create shipments with just the id
curl -X POST https://goatlabels.io/api/v1/shipments \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from_address_id": "adr_…",
    "to": { … }, "parcel": { … }
  }'

default: true marks the account default Ship From (only one at a time — setting it clears the previous default); default_return: true works the same for returns. Saving a duplicate (same line1 + postal code + country) returns 409 with the existing entry's id. DELETE is a soft-delete — historical shipments keep their address snapshot. PATCH is partial: unspecified fields keep their saved values. Optional tax_id (EORI / VAT / EIN) is stored on the entry and copied onto shipments created with from_address_id.

Wallet
GET /v1/wallet and GET /v1/wallet/transactions — read your balance and the complete ledger. Read-only; top-ups stay in the dashboard.
cURL
curl https://goatlabels.io/api/v1/wallet \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"

curl "https://goatlabels.io/api/v1/wallet/transactions?limit=25" \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"
JSON
{
  "object": "wallet",
  "balance": 142.55,
  "currency": "USD",
  "pending_refund_amount": 6.42,
  "next_refund_available_at": 1731600000
}

Check the balance before batch purchases to avoid mid-batch 402s. pending_refund_amount is void refunds still being returned by the carrier. /wallet/transactions is cursor-paginated (limit, starting_after) and each entry carries the linked shipment_id where applicable, so you can reconcile every debit/credit programmatically. There is deliberately no top-up endpoint — card operations stay in the dashboard.

Carrier adjustments
GET /v1/adjustments and GET /v1/shipments/:id/adjustments — post-purchase carrier billing corrections (re-weighs, dimension corrections, surcharges, credits).

Carriers re-weigh and re-measure parcels after pickup. When the billed price changes, the difference hits your wallet and shows up here — tied to the exact shipment and wallet transaction — so you can reconcile or re-bill your own customers.

cURL
# Everything on the account, newest first (cursor-paginated)…
curl "https://goatlabels.io/api/v1/adjustments?limit=25" \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"

# …or just one shipment's adjustments
curl https://goatlabels.io/api/v1/shipments/shp_…/adjustments \
  -H "Authorization: Bearer $GOATLABELS_API_KEY"
JSON
{
  "object": "list",
  "has_more": false,
  "data": [{
    "object": "adjustment",
    "id": "adj_…",
    "shipment_id": "shp_…",
    "reason": "reweigh",
    "description": "Carrier re-weigh: billed weight 3.2 lb vs declared 2 lb",
    "original_amount": 8.40,
    "adjusted_amount": 10.29,
    "delta_amount": 1.89,
    "currency": "USD",
    "wallet_transaction_id": "txn_…",
    "posted_at": 1731000000,
    "created": 1731000000
  }]
}

delta_amount > 0 means your wallet was charged; negative means credited. reason is one of reweigh, dim_correction, residential_surcharge, address_correction, additional_handling, refund, other. To be pushed instead of polling, subscribe to the shipment.adjustment.created webhook event.

Validate an address
POST /v1/addresses/validate — normalize and verify an address before you ship. Read-only.

Catch undeliverable or mistyped addresses up front (fewer failed deliveries and surcharges). Stateless and owner-agnostic, but still requires a Bearer key. A read-only key can call it.

cURL
curl -X POST https://goatlabels.io/api/v1/addresses/validate \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "line1": "9 King St", "city": "Toronto",
    "state": "ON", "postal_code": "M5H 1A1", "country": "CA"
  }'
JSON
{
  "object": "address_validation",
  "status": "verified",
  "address_type": "commercial",
  "residential": false,
  "corrected": {
    "line1": "9 King St W", "line2": null, "city": "Toronto",
    "state": "ON", "postal_code": "M5H 1A1", "country": "CA"
  },
  "warnings": []
}

corrected is null when no normalization was applied. Required fields: line1, city, postal_code, and a 2-letter country.

Schedule a pickup
Arrange a courier collection for purchased labels — handy for 3PLs handing off in bulk.

POST /v1/pickups schedules a pickup for already-purchased shipments. All shipment ids must share the same carrier and origin. pickup_date is YYYY-MM-DD and time_window a free-form window like "09:00 — 12:00".

cURL
curl -X POST https://goatlabels.io/api/v1/pickups \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "shipment_ids": ["shp_a", "shp_b"],
    "pickup_date": "2026-07-01",
    "time_window": "09:00 — 12:00"
  }'
JSON
{
  "object": "pickup",
  "id": "pck_…",
  "pickup_date": "2026-07-01",
  "time_window": "09:00 — 12:00",
  "carrier": "UPS",
  "shipment_ids": ["shp_a", "shp_b"],
  "status": "scheduled",
  "confirmation_number": "WP12345",
  "pickup_fee": 0,
  "created": 1731000000
}

GET /v1/pickups lists your pickups (newest first); POST /v1/pickups/:id/cancel cancels one. Scheduling a pickup is a live carrier action, so it's blocked for test-mode keys (403 test_mode_unsupported).

Idempotency
Send Idempotency-Key on every POST so retries are safe.

Generate a fresh UUID per intent (one shipment = one key). On a network blip, retry the request with the same key and the same body — you'll get the original response back, with Idempotent-Replayed: true in the headers, and we won't create a duplicate shipment.

  • Same key, same body → cached response replayed.
  • Same key, different body → 409 idempotency_error (we refuse to pin two different intents to one token).
  • Keys are scoped per API key — no cross-account collisions.
  • Only POST endpoints honour the header; GETs are already idempotent.
bash
# Retry-safe: same key + same body = same result, no duplicate shipment
curl -X POST https://goatlabels.io/api/v1/shipments \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Idempotency-Key: 7c1e4f2a-…" \
  -H "Content-Type: application/json" \
  -d @shipment.json
Webhooks
Register endpoints, then verify the HMAC-SHA256 signature on every delivery before trusting the body.

Manage endpoints

Register an endpoint with POST /v1/webhooks. The signing secret is returned once in secret — store it now; later reads only show a masked secret_preview. Rotate by recreating. Omit events to subscribe to all of them. URLs must be https://.

cURL
# The signing secret is returned ONCE in "secret" — store it now.
curl -X POST https://goatlabels.io/api/v1/webhooks \
  -H "Authorization: Bearer $GOATLABELS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/goatlabels",
    "events": ["shipment.label.created", "shipment.cancelled"]
  }'
JSON
{
  "object": "webhook",
  "id": "whk_…",
  "url": "https://example.com/webhooks/goatlabels",
  "events": ["shipment.label.created", "shipment.cancelled"],
  "enabled": true,
  "secret_preview": "whsec_…ab12",
  "created": 1731000000,
  "secret": "whsec_…"   // shown ONCE — store it now
}
  • GET /v1/webhooks — list your endpoints (secrets masked).
  • GET /v1/webhooks/:id — retrieve one.
  • PATCH /v1/webhooks/:id — update url / events / enabled. Re-enabling an auto-disabled webhook clears its failure count for a fresh start.
  • DELETE /v1/webhooks/:id — remove the endpoint and its delivery log.
  • GET /v1/webhooks/:id/deliveries — the per-attempt delivery log (event, response code, duration, next retry, exact payload sent).
  • POST /v1/webhooks/:id/deliveries/:deliveryId/replay — re-send a recorded delivery with the original byte-identical body (signature still verifies). Use after fixing an outage on your side.
  • POST /v1/webhooks/:id/test — deliver a synthetic ping now to confirm reachability + signature verification.

Retries: a non-2xx (or timeout) is retried with exponential backoff; an endpoint that keeps failing is auto-disabled and we notify your other endpoints via webhook.delivery.failed. Deliveries can arrive more than once in rare edge cases — key your processing on the payload's id field to stay idempotent.

Verify the signature

The X-GoatLabels-Signature header is t=<unix>,v1=<hex>. Compute HMAC_SHA256(secret, "{t}.{rawBody}").hex() and compare in constant time. Reject anything older than 5 minutes to mitigate replay.

Node.js (Express)
import crypto from "node:crypto";
import express from "express";

const app = express();
// Webhook verification needs the *raw* bytes — JSON-parsing first will
// re-serialize and break the signature.
app.use("/webhooks/goatlabels", express.raw({ type: "application/json" }));

app.post("/webhooks/goatlabels", (req, res) => {
  const sig = req.headers["x-goatlabels-signature"];
  if (typeof sig !== "string") return res.status(400).end();

  // Header is "t=<unix>,v1=<hex>"
  const parts = Object.fromEntries(
    sig.split(",").map((p) => p.split("=") as [string, string]),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return res.status(400).end();

  const expected = crypto
    .createHmac("sha256", process.env.GOATLABELS_WEBHOOK_SECRET)
    .update(`${t}.${req.body.toString("utf8")}`)
    .digest("hex");

  // Constant-time compare to avoid timing oracles.
  const ok =
    expected.length === v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"));
  if (!ok) return res.status(401).end();

  // Optional: reject events older than 5 minutes to mitigate replay.
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) {
    return res.status(401).end();
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // ... handle event.event (shipment.* | batch.* | credit.balance.low | payment.confirmed | ping)
  res.status(200).end();
});
Python (Flask)
import os, hmac, hashlib, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["GOATLABELS_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/goatlabels")
def goatlabels_hook():
    sig = request.headers.get("X-GoatLabels-Signature", "")
    parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        abort(400)

    # Use raw body bytes — re-serializing JSON would break the signature.
    payload = f"{t}.{request.get_data(as_text=True)}".encode()
    expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, v1):
        abort(401)
    if abs(int(time.time()) - int(t)) > 300:
        abort(401)  # replay window

    event = request.get_json()
    # ... handle event["event"] (shipment.* | batch.* | credit.balance.low | payment.confirmed | ping)
    return "", 200

Supported events

  • Shipment lifecycle: shipment.created, shipment.label.created, shipment.label.failed, shipment.cancelled.
  • Tracking (fired once per transition, deduped across pollers/sweepers): shipment.in_transit, shipment.out_for_delivery, shipment.delivered, shipment.exception, shipment.returned. Subscribe to these instead of polling the tracking endpoints.
  • Billing adjustments: shipment.adjustment.created — a carrier re-weigh / correction hit your wallet. Payload carries the adjustment id, shipment id, signed delta, and the wallet transaction id.
  • Batch import: batch.started, batch.item.finished, batch.finished.
  • Wallet & payments: credit.balance.low, payment.confirmed, payment.expired.
  • Refunds: refund.requested, refund.completed.
  • Self-monitoring: webhook.delivery.failed (fired to your other endpoints when one exhausts all retries).
  • Synthetic: ping (the Send-test button / /test endpoint).

Sample event payloads

JSON
// Tracking lifecycle (shipment.in_transit / out_for_delivery / delivered / exception / returned)
{
  "id": "evt_…",
  "event": "shipment.delivered",
  "createdAt": "2026-07-08T16:22:04.000Z",
  "data": {
    "shipmentId": "shp_…",
    "orderNumber": "ORD-1042",
    "trackingNumber": "1Z999AA10123456784",
    "carrier": "UPS",
    "status": "delivered"
  }
}

// Carrier billing adjustment
{
  "id": "evt_…",
  "event": "shipment.adjustment.created",
  "createdAt": "2026-07-08T16:22:04.000Z",
  "data": {
    "adjustmentId": "adj_…",
    "shipmentId": "shp_…",
    "deltaAmount": 1.89,
    "direction": "charge",
    "balanceAfter": 140.66,
    "walletTransactionId": "txn_…"
  }
}
Errors
Every 4xx / 5xx from /v1/* returns the same JSON envelope. Branch on error.type, not the message.

All partner-API errors share one stable shape so your integration can write a single error handler. Validation failures (missing field, wrong type, out-of-range value) come back as 400 invalid_request_error with an issues[] array describing every offending field — the same Zod issue objects our server uses internally, so path points straight at the field you sent.

JSON
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": {
    "type": "invalid_request_error",
    "message": "Request body failed validation.",
    "issues": [
      {
        "code": "invalid_type",
        "expected": "string",
        "received": "undefined",
        "path": ["to", "postal_code"],
        "message": "Required"
      },
      {
        "code": "too_small",
        "minimum": 1,
        "type": "number",
        "inclusive": true,
        "exact": false,
        "path": ["parcel", "weight"],
        "message": "Number must be greater than or equal to 1"
      }
    ]
  }
}

Known error.type values

  • invalid_request_error 400. Body or query failed schema validation. Walk issues[] for the field paths. Also returned when the Idempotency-Key header is malformed.
  • authentication_error 401. Missing or invalid Bearer key on a /v1/* route. Rotate the key from Sign up to create an API key.
  • plan_quota_error 402. You've hit the monthly shipment cap on your current plan. Upgrade from the dashboard and retry.
  • rate_limit_error 429. Per-key rate limit exceeded. Honour the Retry-After header.
  • idempotency_error 409. You reused an Idempotency-Key with a different body. See Idempotency.
  • not_found 404. Returned for shipments / tracking numbers that don't belong to the calling key — never leaks data across accounts.
  • insufficient_scope 403. A read-only key tried to call a mutating endpoint (create, purchase, void, pickups, webhooks). Use a key with the write scope. See Authentication.
  • test_mode_unsupported 403. A sk_test_ key tried to spend money or trigger a real carrier action (purchase, batch-purchase, void, schedule a pickup). Use a sk_live_ key.
  • service_unavailable 502 / 503. A carrier upstream (e.g. void or pickup) was temporarily unreachable; retry shortly. On purchase, your wallet is auto-refunded.
  • hazmat_unNumber_required, hs_code_required 400. Structured domain-specific cases on POST /v1/shipments. See Create a shipment for the full payload shape — these carry an offenders array of item indices instead of issues[].

Handling a validation error

Node.js
const res = await fetch(`${baseUrl}/shipments`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.GOATLABELS_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(shipment),
});

if (!res.ok) {
  const { error } = await res.json();
  switch (error.type) {
    case "invalid_request_error":
      // error.issues is a ZodIssue[] — surface field-level errors in your UI.
      for (const issue of error.issues ?? []) {
        const field = issue.path.join(".");
        console.warn(`${field}: ${issue.message}`);
      }
      break;
    case "authentication_error":
      throw new Error("Rotate the GoatLabels API key.");
    case "rate_limit_error":
      await sleep(Number(res.headers.get("retry-after") ?? 1) * 1000);
      // retry…
      break;
    case "idempotency_error":
      // Same key was used with a different body — generate a fresh key per intent.
      throw new Error("Idempotency-Key collision.");
    default:
      throw new Error(error.message ?? "Unknown GoatLabels error");
  }
}

5xx responses follow the same envelope. 503 GOAT_TEMP_OUTAGE on /v1/shipments/:id/purchase means a carrier upstream is down — your wallet is automatically refunded; safe to retry the same Idempotency-Key after the Retry-After window.

OpenAPI spec
The complete, machine-readable description of every /v1 endpoint — generate clients, import into Postman, or browse the reference.

Everything documented here is generated from an authoritative OpenAPI 3.1 document. Point your codegen, Postman, or Insomnia at it, or browse the interactive reference.

bash
# Generate a typed client from the spec (example: openapi-typescript)
npx openapi-typescript https://goatlabels.io/api/v1/openapi.yaml -o goatlabels.d.ts

Need something not in here? Open a ticket from Contact.