Skip to content
BuildBase

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 and runs on the same Stripe keys: test keys in Staging for test sales, live keys in Production once live selling 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 and Zero to 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); 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

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

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).

The Order Shipped email: the item, the carrier and tracking number with the carrier's tracking address, and the order page's address

Limits

What is not shipped yet is listed under 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, 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.

{
  "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:

{
  "name": "Standard",
  "amount": 500,
  "currency": "usd",
  "countries": ["US", "CA"],
  "minDays": 3,
  "maxDays": 5,
  "freeOver": 5000
}
WhoRoutes
The sellerGET, POST /api/v1/public/me/shipping-rates; GET, PUT, DELETE /me/shipping-rates/:id
A workspace sellerthe 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 consoleGET /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 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 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 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.
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>"}'
WhoRoutes
The sellerGET, POST /me/sales/:id/shipments; POST /me/sales/:id/shipments/:shipmentId/status; GET .../label
A workspace sellerthe same under /workspaces/:workspaceId/sales/:id/shipments
Your serverGET /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.

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.

{
  "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):

combineOne 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_additionalThe dearest unit once, then each other unit's additionalRate (its rate when it has none): $9 + $1.50 + $9 = $19.50
flat_per_orderThe 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:

{
  "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):

{
  "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:

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:

addressMismatchWhat 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)
requoteThe 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
ignoreNothing; 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:

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.

StatusMeans
requestedThe buyer asked
approvedThe seller agreed; its shipment back is made
in_transitIts shipment back is on its way
deliveredIt arrived back
refundedRefunded, once, under the refund id return-<id>
rejectedThe 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:

{
  "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:

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

EventWhen
order.shippedThe first shipment with tracking, or on its way: { order, shipment }
shipment.updatedA shipment's status or tracking changed: { shipment, order, previousStatus }
return.requestedA buyer asked for a return: { return, order, previousStatus: null }
return.updatedA 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.