# Shipping

Sell physical items: rates at checkout, the address on Stripe's page, labels, tracking and customs through your EasyPost or Shippo account, and returns.

An item that ships asks the buyer for an address on Stripe's page and
charges a shipping rate the buyer picks there. The seller then ships the
order in one or more shipments, each with a carrier and tracking number, and
the buyer is told when it ships and when it arrives.

> **Note:**

**Arrives with selling.** Shipping arrives with [selling](/billing/selling)
and runs on the same Stripe keys: test keys in Staging for test sales, live keys
in Production once [live selling](/billing/selling#test-in-staging-then-go-live) is open for your app.

**The screens require `@buildbase/sdk` 1.2.0 or later.** `<ShippingRatesEditor>`, `<ShipOrder>`, `<OrderTracking>`, `<OrderReturns>`, `useShipments`, `useShippingQuote`, `useReturns`, `useSellerShipments` and `useSellerReturns` arrive with it; earlier versions have none of them, and their webhook catalog does not list `order.shipped`, `shipment.updated`, `return.requested` or `return.updated`. The routes on this page also work from your app or your server directly.

## Walkthrough

New to selling? [Zero to first sale: marketplace](/billing/first-sale-marketplace) and [Zero to first sale: storefront](/billing/first-sale-storefront) walk the whole flow once, from Stripe keys to a refund and going live.

### When to use it

Anything physical: rates at checkout, an address on Stripe's page, one or
more shipments with tracking, and returns.

### Before you start

An item type or item with shipping rules (the countries it ships to). For
bought labels and live tracking, your own EasyPost or Shippo account.

### Set it up in the console

1. **Billing**, **Item types**: give the type shipping rules under
   **Checkout and buyers**, and publish.
2. **Billing**, **Selling**, **Settings**, **Shipping labels**: save your
   EasyPost key and webhook secret to buy labels (a Shippo token is saved
   through the API, see [Shippo](#shippo)); shipping by hand needs nothing.
3. Ship from an order's page under **Orders** (the **Shipping**,
   **Shipments** and **Returns** cards), or list what waits with
   **Awaiting shipment**. A seller's rates show on their page under
   **Sellers**.

![An order page's Shipping and Ship to cards: the rate the buyer picked and the address, and one USPS shipment Delivered with its tracking number, history and Update status](/images/billing/selling/console-order-shipping.webp)

### Marketplace and storefront

The address is asked on Stripe's page in both shapes. Who may refund a
return follows the shape, as any refund.

### What the seller sees

`<ShippingRatesEditor>` for their rates, and `<ShipOrder>` to ship a sale.

### What the buyer sees

The address and rate on Stripe's page, then `<OrderTracking>` and
`<OrderReturns>` on their order page.

![OrderTracking at 360 pixels wide: the rate and address, one USPS parcel Delivered with its tracking number and link, and a requested return below](/images/billing/selling/sdk-order-tracking.webp)

### Emails and webhooks

The buyer gets **Order Shipped**, **Order Delivered** and **Return
Approved**. Your server gets `order.shipped`, `shipment.updated`,
`return.requested` and `return.updated` ([webhooks](#webhooks)).

![The Order Shipped email: the item, the carrier and tracking number with the carrier's tracking address, and the order page's address](/images/billing/selling/email-order-shipped.webp)

### Limits

What is not shipped yet is listed under [Not yet](#not-yet).

### Staging and Production

Your EasyPost or Shippo key is saved apart from your Stripe keys: save
a live one yourself in Production when you go live there. Dev and Staging
take test keys only (EasyPost `EZTK...`, Shippo `shippo_test_...`) and
refuse a production key, which would buy real postage for test orders.

## What ships

An item ships when it, or its [item type](/billing/item-types), has
shipping rules: the countries it ships to (ISO 3166-1, `US`), optionally the
regions within them (ISO 3166-2, `US-WA`) for goods only some states may
receive, a flat rate per unit, and whether the buyer may collect it. An
item's own rules stay within its type's.

```json
{
  "shipping": {
    "countries": ["US", "CA"],
    "regions": ["US-WA", "US-OR"],
    "flatRate": 500
  }
}
```

A one-time price ships. A subscription or a price paid in parts is sold
without shipping for now, and a cart that ships an item cannot also carry a
subscription (`cart_shipping_subscription`). A free claim is shipped at no
charge and asks no address.

## Shipping rates

A seller keeps up to 5 rates, each a name, an amount per order in one
currency, the countries it covers, a delivery estimate in business days, an
optional "free over" amount, or a pickup:

```json
{
  "name": "Standard",
  "amount": 500,
  "currency": "usd",
  "countries": ["US", "CA"],
  "minDays": 3,
  "maxDays": 5,
  "freeOver": 5000
}
```

| Who                | Routes                                                                                                    |
| ------------------ | --------------------------------------------------------------------------------------------------------- |
| The seller         | `GET`, `POST /api/v1/public/me/shipping-rates`; `GET`, `PUT`, `DELETE /me/shipping-rates/:id`             |
| A workspace seller | the same under `/api/v1/public/workspaces/:workspaceId/shipping-rates` (`SELLING_VIEW`, `SELLING_MANAGE`) |
| Your server        | `/api/v1/server/commerce/shipping-rates`, any seller's (a create names `sellerId`)                        |
| The console        | `GET /api/marketplace/shipping/rates?sellerId=`                                                           |

A rate is saved whole; an order keeps a copy of the one it was sold with, so
an edit or a delete never changes an order. A refusal answers 422
`SHIPPING_RATE_REFUSED` with `problems`.

## At checkout

Checkout offers the seller's rates in the order's currency that cover the
countries every line ships to, cheapest first, as Stripe Checkout shipping
options, and asks for an address in a country every offered rate covers:

- **Free over** compares the subtotal after discount of the lines that ship.
  At or over the amount, that rate is 0.
- **Pickup** is offered only when every line that ships may be collected. A
  pickup keeps no address.
- **No rate in the currency**: the items' own flat rates times the quantity,
  as one "Shipping" option.
- Lines that share no country are refused with `shipping_no_common_country`;
  a seller with no rate that fits, `shipping_no_rate` (422
  `CHECKOUT_REFUSED`, at `shipping`).

With [Stripe Tax](/billing/tax) on, Stripe taxes the shipping as well.

The order records the rate the buyer picked once Checkout completes:
`shipping` (minor units, inside `total`), `shippingRate` and
`shippingAddress`. The shipping is spread over the lines that ship, in
proportion to their subtotal, so a refund of a line returns its share. It
goes to the seller, who ships: in the marketplace shape the seller's share
then includes a rate not known until the buyer picks it, so the seller is
paid by a transfer after the charge rather than with it.

The address is read only by the buyer, the order's seller, you and your
server.

### Carts across sellers

A cart in the marketplace shape is one Stripe page, so it offers one option,
"Shipping (2 sellers)": the sum of each seller's cheapest rate that is not a
pickup. Each order keeps its own part and pays it to its seller. A
storefront cart pays each seller on their own page, with their own rates.

### Regions

Stripe's page cannot refuse a state, so the address is checked against each
line's regions once paid. An address outside them refunds the order in full,
once (`shippingRefused: true`), tells the buyer by email, and its seller is
not paid. Nothing ships.

### With affiliates

An order bought through a [referral](/billing/affiliates) earns on the
lines' net, before tax and shipping, so the rate the buyer picks never
changes the commission, and free-over reads the same subtotal with or
without one. A seller's program still comes off the seller's side when the
seller is paid after the charge: the transfer is their share with the
shipping in it, less your fee and the commission, as a destination charge
would send. In a storefront cart each step's page offers its seller's
rates, and the commission rides on that step's application fee; the
shipping goes to the seller. An order refunded for its region earns no
commission. A refund takes back a commission's share measured against the
line's total, shipping in it, so a full refund takes back all of it.

## Shipments

The seller, you or your server ship a paid order in one or more shipments.
Each names the units of each line it carries (every unit left when none are
named) and either:

- **By hand** (`provider: "manual"`, always on): the carrier and tracking
  number, a tracking link (made for USPS, UPS, FedEx, DHL, Canada Post,
  Royal Mail and Australia Post when none is given, `https://` only), and the
  label as a PDF uploaded with the [seller's files](/billing/orders#files)
  first (`labelFileId`). The status moves by hand.
- **A label bought from EasyPost** (`provider: "easypost"`), on your own
  EasyPost account (below): the address it ships from and the parcel
  (`weightOz`, and inches). The rate asked for by `carrier` and `service`, or
  the cheapest, is bought; tracking moves the status.

```bash
curl -X POST "$SERVER/api/v1/public/me/sales/$ORDER/shipments" \
  -H "x-session-id: $SESSION" -H 'content-type: application/json' \
  -d '{"carrier":"USPS","trackingNumber":"9400111899223","labelFileId":"<pdf file id>"}'
```

| Who                | Routes                                                                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The seller         | `GET`, `POST /me/sales/:id/shipments`; `POST /me/sales/:id/shipments/:shipmentId/status`; `GET .../label`                                                     |
| A workspace seller | the same under `/workspaces/:workspaceId/sales/:id/shipments`                                                                                                 |
| Your server        | `GET /api/v1/server/commerce/shipments?orderId=`, `POST` (names `orderId`), `GET /:id`, `POST /:id/status`, `GET /:id/label` (the `marketplace_orders` grant) |
| The console        | `/api/marketplace/orders/:id/shipments`, and the same status and label routes                                                                                 |

A shipment's `status` is `label_created`, `in_transit`, `out_for_delivery`,
`delivered`, `returned`, `failure` or `cancelled`. Returned and cancelled are
final; delivered may only become returned; the same status again changes
nothing. A provider's shipment moves with its tracking and may only be
cancelled by hand (its label is refunded at the provider). A refusal
answers 422 `SHIPMENT_REFUSED` with `problems` (`shipment_quantity`,
`shipment_status`, `label_not_pdf`, `nothing_to_ship`), and an unknown one
404 `SHIPMENT_NOT_FOUND`. One shipment of an order is made at a time: a
second request while a label is being bought answers 409
`SHIPMENT_REFUSED` with `shipment_in_progress`; try it again once the first
answers. A label purchase the provider did not answer (a timeout, an error
on its side) keeps its shipment, and the next request for the order buys it
again under the same key, so a label is never bought twice. One nobody
retries is settled by the daily shipping pass: it asks EasyPost, without
buying, what it holds for that shipment, and keeps the label it finds or
frees the units when there is none. A Shippo purchase is left for you to
check in your Shippo account, and is never bought again.

The order follows its shipments:

- The first shipment with a tracking number, or on its way, moves the lines
  that ship from `due` to `in_progress` and sends `order.shipped`, once. The
  buyer is emailed (and, signed in, notified) with the tracking.
- A line whose every unit is in delivered shipments is fulfilled, which
  sends `order.fulfilled`. When the last parcel is delivered, the buyer is
  emailed.

Reading one order returns its `shipments`, and each line that ships its
`shipped` units. A guest reads them with their order link, as the rest of
the order.

## Labels through your EasyPost account

Labels are bought on your own EasyPost account, never BuildBase's. Under
Selling, Shipping, save your EasyPost API key and the secret of a webhook
you create in EasyPost pointing at the address shown there
(`/api/webhooks/shipping/easypost/<your app id>`, with the environment's id
inside an environment). Both are encrypted at rest and never shown again.

```bash
curl -X PUT "$SERVER/api/marketplace/shipping/provider" \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"easypostApiKey":"EZTK...","easypostWebhookSecret":"..."}'
```

EasyPost's tracking events are verified against the secret, stored under
their id and processed once; a repeat changes nothing. A daily read of each
shipment still on its way catches anything a webhook missed. When this
platform has not turned EasyPost on, or no key is saved, a label purchase
answers 503 `SHIPPING_PROVIDER_UNAVAILABLE`, and shipments by hand work as
always.

## Weight and brackets

A rate has a basis: `flat` (the default, its `amount` per order), or
brackets by `weight` (grams), `subtotal` (minor units, after discount) or
`items` (units). Up to 10 brackets, each `amount` up to `upTo`, the last
open-ended (`upTo: null`). Any basis but `carrier` may add `perItem` per
unit and is free at `freeOver`.

```json
{
  "name": "By weight",
  "amount": 0,
  "currency": "usd",
  "countries": ["US"],
  "basis": "weight",
  "brackets": [
    { "upTo": 500, "amount": 500 },
    { "upTo": 2000, "amount": 900 },
    { "upTo": null, "amount": 1500 }
  ],
  "perItem": 0
}
```

The weight is each variant's `weightGrams`, plus the item type's
`shipping.packagingGrams` per unit (0 by default), times the units. A
variant may also give its sides in `dimsMm` for a carrier's parcel. When a
seller has a rate by weight and a line has no weight, checkout is refused
before anything is held with `shipping_weight_missing` at `lines.<n>`, and
the seller should add the weight.

## Combining

When a seller has no rate in the order's currency, the items' own flat
rates are charged, combined as the seller sets (`combine`):

| `combine`                 | One order of a $4 mug (additional $1.50) and two $9 bowls                                                            |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `sum` (the default)       | Each unit at its rate: $4 + $9 + $9 = $22                                                                            |
| `highest_plus_additional` | The dearest unit once, then each other unit's `additionalRate` (its rate when it has none): $9 + $1.50 + $9 = $19.50 |
| `flat_per_order`          | The dearest rate once: $9                                                                                            |

`additionalRate` is part of an item's (or its type's) shipping rules. A
seller's settings are read and saved at `GET`/`PUT /me/selling/shipping`
(a workspace's at `/workspaces/:workspaceId/selling/shipping`, under the
selling permissions), by the app's server at
`/api/v1/server/commerce/shipping-settings/:sellerId`, and in the console at
`/api/marketplace/shipping/sellers/:id`. Each part may be sent alone:

```json
{
  "combine": "highest_plus_additional",
  "returns": {
    "enabled": true,
    "windowDays": 30,
    "labelPaidBy": "seller",
    "autoRefundOn": "none"
  },
  "customs": {
    "contentsType": "merchandise",
    "incoterm": "DAP",
    "nonDelivery": "return"
  }
}
```

## Carrier rates and why the address comes first

A rate with `basis: "carrier"` charges the carrier's own price for the
parcel from the rate's `origin` to the buyer's address, among `carriers`
and `services` (empty is any), with the seller's markup (`markupBps`, then
`markupFixed`):

```json
{
  "name": "Carrier",
  "amount": 0,
  "currency": "usd",
  "countries": ["US", "CA"],
  "basis": "carrier",
  "carrier": {
    "carriers": ["USPS", "UPS"],
    "services": [],
    "markupBps": 1000,
    "markupFixed": 50,
    "fallbackAmount": 1200,
    "origin": {
      "name": "Maya Pottery",
      "line1": "1 Kiln Rd",
      "city": "Seattle",
      "state": "WA",
      "postalCode": "98101",
      "country": "US"
    }
  }
}
```

Stripe's hosted page cannot work out a shipping price while the buyer types
an address: it offers fixed options only. So the address comes first. Your
page asks for at least the country and postal code, then asks for a quote
with the same lines checkout will send:

```bash
curl -X POST "$SERVER/api/v1/public/items/$ITEM/shipping-quote" \
  -H "x-session-id: $SESSION" -H 'content-type: application/json' \
  -d '{"variantId":"mug","priceId":"usd","quantity":1,"address":{"country":"US","postalCode":"94080"}}'
```

A cart asks at `POST /api/v1/public/cart/shipping-quote` with `lines`, and a
guest at `/api/v1/guest/<app id>/items/:id/shipping-quote` and
`/cart/shipping-quote`. The answer is each option (the carrier and service
in `carrier` and `service`, cheapest first, up to 3 per carrier rate by
default), each seller's options, and a `quoteToken`, good for 30 minutes.
Show the options, then start checkout with `"shippingQuote": "<quoteToken>"`.
Stripe's page offers the quoted options as fixed amounts and takes an
address in the quoted country only.

The token is signed and names the lines and the address it was priced for:
a changed amount, other lines or an expired token is refused with 422
`SHIPPING_QUOTE_INVALID` before anything is held, and your page asks for a
new quote. Prices from the provider are reused for 15 minutes for the same
parcel and address. Without a quote, a carrier rate offers its
`fallbackAmount`, or is left out, and checkout with only carrier rates and no
fallback answers `shipping_quote_required`.

Once paid, the address Stripe collected is compared to the quoted country
and postal code. A different one is flagged on the order
(`shippingQuoteMismatch: true`) for the seller to decide, or, when you set
`quote.mismatch` to `refund`, the order is refunded in full once and nothing
ships.

Within the quoted postal code, the street address paid for is then checked
against the quoted one: both are verified with your connected label provider
(EasyPost or Shippo address verification) and compared in their normal form
(case, punctuation, spacing and a ZIP+4 do not count; a part the quote was
asked without, such as the city, is not compared). When they differ, the
seller's choice applies (`addressMismatch` in their shipping settings, which
the app server's `PUT /api/v1/server/commerce/shipping-settings/:sellerId`
also takes). A `null` choice follows your default, set under
**Billing > Selling > Settings**:

| `addressMismatch`     | What happens                                                                                                                                                                                                                                                                                |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `flag_hold` (default) | The order reads `shippingQuoteMismatch` and `shippingAddressHeld`, and no shipment is made until the seller sends `confirmAddress: true` with it (otherwise `409`, problem code `address_held`)                                                                                             |
| `requote`             | The carrier is asked again for the verified address, for the carrier and service paid for. A cheaper rate refunds the difference, once; a dearer one is recorded as owed (`shippingAddressCheck.difference`) and held as `flag_hold` is. Charging the difference is the seller's to arrange |
| `ignore`              | Nothing; the address is not checked                                                                                                                                                                                                                                                         |

Nothing is refunded by this check except a re-quote's cheaper difference. A
provider that cannot answer leaves the addresses compared as typed, and a
difference is held, never refunded. The result is on the order as
`shippingAddressCheck` (`outcome`, `action`, `difference`, `currency`).
Orders quoted before this compare the house number only, as before.

The provider settings also hold `quote.carrierOptionsMax` (1 to 5)
and `quote.ui`, `address_first`, the only one built: working out prices on Stripe's page as the address is typed needs Stripe's Elements checkout, which no SDK component offers yet, so `stripe_dynamic` is refused (422).

## Returns

A seller takes no returns until they turn them on (`returns.enabled`). A
buyer then asks to return units of a delivered order within
`returns.windowDays` (30 by default) of its delivery:

```bash
curl -X POST "$SERVER/api/v1/public/me/orders/$ORDER/returns" \
  -H "x-session-id: $SESSION" -H 'content-type: application/json' \
  -d '{"lines":[{"lineKey":"1","quantity":1}],"reason":"Chipped"}'
```

A guest asks at `/api/v1/guest/<app id>/orders/:id/returns` with the
order's token in `x-order-token`. Leaving out `lines` returns every unit
delivered and not already in a return. A line is in one return at a time:
a second request for it answers 409 `RETURN_REFUSED`. Before delivery, past
the window, or with returns off, the request is refused with
`return_window_closed` or `returns_off`.

The seller (`/me/sales/:id/returns/:returnId/approve`, a workspace's under
`/workspaces/:workspaceId/sales`), the app owner
(`/api/marketplace/orders/:id/returns/...`) or the app's server
(`/api/v1/server/commerce/returns/:id/approve`) approves it, which makes its
shipment back (`direction: "return"`): by hand with a carrier, tracking
number or the seller's own PDF label, or a return label bought on your
provider account by sending `provider`, `to` (where it goes back) and
`parcel`. The buyer is emailed with how to send it. A return can also be
rejected, which frees its lines.

| Status       | Means                                             |
| ------------ | ------------------------------------------------- |
| `requested`  | The buyer asked                                   |
| `approved`   | The seller agreed; its shipment back is made      |
| `in_transit` | Its shipment back is on its way                   |
| `delivered`  | It arrived back                                   |
| `refunded`   | Refunded, once, under the refund id `return-<id>` |
| `rejected`   | The seller said no                                |

A refund (`.../refund`) returns the units' share of their lines, not the
shipping, less the return label when `returns.labelPaidBy` is `buyer`. Who
may refund follows your app's shape, as any refund. With
`returns.autoRefundOn: "delivered"`, a return refunds by itself once its
shipment back is delivered.

## Customs

A label for a parcel that crosses a border is bought with a customs
declaration built from the lines it carries: each line's net per unit as
its value, its weight, and the variant's (or the item's) `customs`:

```json
{
  "customs": {
    "hsCode": "6912.00",
    "originCountry": "PT",
    "description": "Ceramic mug"
  }
}
```

`hsCode` is 6 to 10 digits, `description` at most 50 characters (the item's
title when unset), and the origin country is required. The seller's
`customs` settings say what the parcel holds (`contentsType`: `merchandise`
by default, `gift`, `documents`, `sample`, `returned_goods`, `other`; a
shipment may override it), who pays duties (`incoterm`: `DAP`, the buyer, by
default, or `DDP`, the seller) and what happens to a parcel that cannot be
delivered (`nonDelivery`: `return` or `abandon`). A line with no weight or
origin refuses the label with `invalid_customs` at `lines.<lineKey>`, and
nothing is bought. A return label across a border declares
`returned_goods`.

## Shippo

Shippo works as EasyPost does, on your own account: save your Shippo API
token under Selling, Shipping, and choose the one provider your app buys
labels through:

```bash
curl -X PUT "$SERVER/api/marketplace/shipping/provider" \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"shippoApiKey":"shippo_test_...","provider":"shippo","quote":{"mismatch":"flag"}}'
```

Saving the token makes a webhook address of its own,
`/api/webhooks/shipping/shippo/<app id>/<random token>`, shown in the
answer (`shippoWebhookUrl`): add it in Shippo as a `track_updated` webhook.
The random token in it is what lets Shippo's deliveries in; anything
else answers 404. A delivery's body is never applied as sent: the tracker it
names is read again from Shippo with your token, and only Shippo's answer
moves the shipment. Labels, return labels, customs and carrier quotes then go
through Shippo, and a daily read of each shipment still on its way catches
anything a webhook missed. A label asked for through the other provider
answers 409. When this platform has not turned Shippo on, it answers 503
`SHIPPING_PROVIDER_UNAVAILABLE`.

## Webhooks

| Event              | When                                                                                                      |
| ------------------ | --------------------------------------------------------------------------------------------------------- |
| `order.shipped`    | The first shipment with tracking, or on its way: `{ order, shipment }`                                    |
| `shipment.updated` | A shipment's status or tracking changed: `{ shipment, order, previousStatus }`                            |
| `return.requested` | A buyer asked for a return: `{ return, order, previousStatus: null }`                                     |
| `return.updated`   | A return was approved, rejected, shipped back, delivered or refunded: `{ return, order, previousStatus }` |

## In the console

The console reads an order with its address, rate and shipments
(`GET /api/marketplace/orders/:id`), ships it or moves a shipment through
the routes above, and lists "Awaiting shipment"
(`GET /api/marketplace/orders?shipping=awaiting`): paid orders with a line to
ship and nothing on its way. The order's page under **Billing**, **Selling**,
**Orders** shows the **Ship to** address, the rate, a **Shipments** card
(**Add a shipment**, and each shipment's status moved by hand) and a
**Returns** card. A seller's page under **Sellers** shows their shipping
rates and settings.

## Not yet

Shopping one parcel across several providers, insurance, a parcel in more
than one box, shipping on a subscription, duties charged at checkout,
working out carrier prices on Stripe's page as the address is typed, and
pickup scheduling.
