Overview

Shipping in {{COMPANY_SHORT_NAME}} is a thin operator surface over a carrier-rate API. You connect a carrier credential per store (today: Shippo), the platform brokers the conversation, and you get rate quotes, purchasable labels, and live tracking attached to your orders — without ever leaving the app.

The functional flow is: invoice in paid → click Buy label → pick a rate → pay the carrier through your connected account → PDF downloads, tracking attaches to the order, an outbound webhook fires, and a background job polls the carrier for delivery events until the package marks as delivered.

Supported carriers

Carrier support is provided through adapters in supabase/functions/_shared/shipping/. Today:

  • Shippo — the primary live adapter. Brokers rates and labels across USPS, UPS, FedEx, DHL, OnTrac, and most international postal services. This is the path almost every operator should use.
  • UPS direct — stub. The adapter is registered but gated as “coming soon” in the UI. UPS rates are still accessible today via Shippo’s UPS reseller; switch to direct UPS later when we activate the stub.
  • EasyPost — stub. Same pattern as UPS direct. Use Shippo today.
  • Pirate Ship — stub. Same pattern.

The catalog of physical carriers (UPS, USPS, FedEx, DHL, OnTrac, Canada Post, Royal Mail, Australia Post, plus an “other” sentinel) lives in shipping.carriers and is shared with the marketplace’s vendor-side tracking flow — so a vendor who pastes in a tracking number gets the right carrier auto-detected.

What working correctly looks like

If everything is wired up, you should see these things. If any one of them is missing, jump to Troubleshooting.

  • Settings → Shipping shows a connected Shippo carrier with a green “Connected” pill.
  • The Store address is filled in (city, state, ZIP all present) — otherwise quotes return zero rates.
  • From a paid invoice, Buy label opens a rate picker that lists at least 3-4 USPS or UPS options within 2 seconds.
  • After purchase, the label PDF downloads on the same click, and the order detail page shows the tracking number with a clickable link to the carrier’s public tracking page.
  • A shipment.tracking_added webhook fires within a second of purchase (visible in Settings → Webhooks → Deliveries).
  • Carrier scan events (in transit, out for delivery, delivered) appear on the order timeline without any operator action.

Connect a carrier

  1. Go to Settings → Shipping.
  2. Under Carriers, click Connect next to Shippo.
  3. Paste your Shippo API key. Use a live key for production, a test key for sandbox. The key is encrypted with the per-org vault before being written to shipping.carrier_accounts; the plaintext never leaves the request handler.
  4. Click Save. We verify the key by issuing a no-op rate request; if Shippo returns “invalid token,” we reject the save and you get a typed error rather than a stored bad key.
Keys are per-store, not per-workspace

Each store under your workspace has its own Shippo carrier account row. This is on purpose: different stores can use different Shippo balances and different return addresses. If you only have one store, you only need to connect it once.

Store from-address

Every quote needs a from-address. The from-address is the store’s shipping origin, configured at Settings → Stores → store → Address.

  1. Enter street, city, state, ZIP, country, and a contact phone.
  2. Click Validate. We round-trip the address through Shippo’s address validator; corrections (USPS-normalized casing, ZIP+4) are offered.
  3. Save. The validated address becomes the default from-address on all rate quotes from this store.

If the address is missing or incomplete, the rate picker on a label purchase will show “Configure store address” with a deep link to this page.

Quote rates

You can quote without purchasing — useful for surfacing a shipping estimate to a client on a proposal.

  1. From an invoice detail page, click Quote shipping in the kebab menu.
  2. Fill in the to-address (pre-filled from the client’s record), the package dimensions, and weight.
  3. Click Get rates. Live rates from Shippo come back within ~2 seconds.
  4. (Optional) Click Add to proposal as line to insert the picked rate as a shipping line on the order.

Quotes are cached for 60 seconds at the carrier’s recommendation — rates can drift, so don’t quote and purchase weeks apart.

Buy a label from an invoice

  1. Open a paid invoice. Click Buy label in the header.
  2. Confirm the from-address (the store default), the to-address (the client’s default shipping), the package, and the weight. Edit any of them inline if a one-off shipment needs different values.
  3. Click Get rates. The rate picker lists every carrier service Shippo returns, with price, delivery estimate, and a tracking-availability chip.
  4. Pick a rate, click Buy. We POST to the shipping-label edge function with action purchase. Shippo charges your connected balance / card, the PDF streams back, and a row is written to shipping.labels with the cost, the tracking number, and the Shippo request id.
  5. The label PDF auto-downloads. Print it on a 4×6 thermal printer or a regular sheet. The tracking number appears on the order detail page immediately.
Idempotent purchase

The purchase call carries an idempotency key derived from the order id + the picked rate id. A retried purchase with the same key (e.g. on a flaky network) is a no-op — we never double-charge your Shippo balance.

Tracking sync

Once a label is bought, the platform takes over tracking. You don’t have to push a button or visit Shippo’s dashboard.

  • On purchase, the tracking number is registered with Shippo’s track service.
  • Shippo POSTs scan events (label created, in transit, out for delivery, delivered, exception) to our shop-shippo-track edge function. We verify the webhook signature, find the matching shipping.labels row, and append the event to the order’s timeline.
  • If Shippo’s webhook is dropped (it sometimes happens), a periodic cron poll calls GET /tracks/{id} as a backstop. You don’t see this fire; it just keeps the timeline accurate.
  • On the “delivered” event, we emit a shipment.delivered event on the webhook bus. For marketplace orders, this also triggers the order_delivered buyer email.
Replayed webhooks are no-ops

Shippo retries deliveries on transient failures, which means we sometimes get the same event twice. Our handler is idempotent on the carrier event id — the second arrival is dropped silently, not double-recorded.

shipment.tracking_added webhook

If you subscribe to outbound webhooks from Settings → Webhooks, the most useful shipping-related event is shipment.tracking_added. It fires once per label, immediately after purchase, with a payload like:

{
  "event": "shipment.tracking_added",
  "delivered_at": "2026-05-21T15:42:11Z",
  "data": {
    "order_id": "8c1f3e8c-9b1c-4e36-95ff-1c3a8d7d2f01",
    "order_number": "INV-2026-0042",
    "tracking_number": "9400111899223377665544",
    "carrier": "usps",
    "service_level": "Priority",
    "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9400111899223377665544",
    "label_cost_cents": 879,
    "shippo_request_id": "shippo_abc123"
  }
}

Other shipping events on the bus: shipment.in_transit, shipment.out_for_delivery, shipment.delivered, shipment.exception. Use them to drive your own downstream notifications (CSAT email, ERP sync, inventory reservation release).

Void / refund a label

Most carriers let you void a label and refund the cost if it hasn’t been used yet (i.e. no carrier scan has happened). The window is usually 14-28 days from purchase, carrier-dependent.

  1. Open the order. In the shipping section, click Void label.
  2. We POST to shipping-label with action refund. The carrier returns an asynchronous refund request id.
  3. The shipping.labels.refund_status field updates from requested to refunded when the carrier confirms (usually within 24 hours). The original cost lands back on your Shippo balance.

If the carrier rejects the refund (label already scanned, outside the window), we surface the carrier’s exact reason rather than a generic error.

Returns & RMAs

The returns flow is a flip of the outbound flow: a buyer requests a return, you approve it, the platform issues a return label with the from/to swapped, and tracking syncs back the same way.

  • Initiate from the order detail page → Create return. Pick the line items being returned, the reason, and whether the buyer or you eat the label cost.
  • Issue the return label. The PDF goes to the buyer by email; tracking attaches to the original order with a “return” marker on the timeline.
  • On scan-in (the carrier marking the return as “delivered” back to your store), the RMA row flips to received. Inventory adjustments and refund issuance are separate operator steps (we don’t auto-refund on scan — you want to inspect the goods first).

The full returns spec including ledger semantics is in the QA cross-cutting checklist returns-and-rma; the operator UI surface is documented here.

Multi-store config

Shipping config is per-store, not per-workspace. Every store you operate has its own:

  • Carrier credentials (Shippo key, etc.)
  • Default from-address
  • Package presets (default box size, default weight)
  • Labels table (shipping.labels is partitioned-by-store at the RLS layer)

Switching stores in the operator shell instantly hides labels from the other store. There is no “all stores” labels view — if you operate three brands, each brand’s labels live under that brand.

Settings & permissions

Where settings live:

  • Settings → Shipping — per-store carrier connections.
  • Settings → Stores → store → Address — from-address for quotes / labels.
  • Settings → Webhooks — subscribe to shipment.* events.
  • Settings → Modules — shipping module flag (must be enabled for the module to surface).

Roles & permissions:

  • shipping:read — view labels and quotes (default for Member and above).
  • shipping:quote — run rate quotes.
  • shipping:purchase — buy labels (charges your Shippo balance — usually Manager+).
  • shipping:void — void labels and request refunds.
  • settings:carriers — connect / rotate / revoke carrier credentials (usually Owner only).

API + automation

The shipping-label edge function is the canonical entrypoint for quote / purchase / refund. It is invoked from the operator UI under your session JWT, with RLS on shipping.carrier_accounts ensuring per-org isolation. We do not expose a public v1 REST surface for label purchase in {{COMPANY_SHORT_NAME}} v1 — if you need to script bulk label purchases, drive the Shippo API directly with your store’s key.

For inbound shipment events from your own systems (e.g. you fulfill out of a 3PL and want to push tracking back into {{COMPANY_SHORT_NAME}}), the Integration API exposes an order-update path. See the recipes cookbook for a worked example.

For outbound notifications, subscribe to shipment.tracking_added / shipment.delivered / shipment.exception in Settings → Webhooks.

Troubleshooting

The full symptom-to-fix table lives at Troubleshooting. Common shipping-specific issues:

SymptomMost likely causeFix
“No rates returned” on quote.Store address incomplete (missing ZIP, missing country) or to-address malformed.Run the from-address through Validate at Settings → Stores → Address; verify the client’s shipping address has city + state + ZIP.
missing_credential typed error on purchase.Shippo key was deleted / rotated, or the vault row was wiped.Reconnect under Settings → Shipping. The key was encrypted, so we cannot recover it — you need to paste it again.
“Insufficient balance” on purchase.Your Shippo account balance is below the label price.Top up at Shippo’s dashboard, retry the purchase.
Tracking events stopped after “in transit.”Shippo webhook delivery dropped; the cron backstop will catch up within ~10 minutes.Wait 10 minutes; if still missing, refresh the order timeline. The cron poll continues until “delivered.”
Void label rejected by carrier.Label already scanned, or outside the carrier’s refund window.The carrier’s exact reason is surfaced on the modal. If scanned, no refund is possible.
EasyPost / UPS direct / Pirate Ship shows “coming soon.”The adapter is a stub in v1.Use Shippo, which brokers UPS rates and labels through its own account.
  • Orders & invoicing — labels are bought against paid invoices.
  • Vendors — vendor shipping origins feed into multi-source order routing.
  • Outbound webhooks — subscribe to shipment.* events.
  • Vendor storefront — marketplace vendors paste tracking numbers; the same carrier catalog auto-detects.
  • Integration API — push tracking from a 3PL back into {{COMPANY_SHORT_NAME}}.