# AI agents Let shopping agents check out in your shop over UCP and follow their orders. The buyer agrees on your checkout, or in their agent's app with AP2. AI agents buy through the same Carts, Quotes and Orders as your storefront. They speak the [Universal Commerce Protocol](https://ucp.dev) (UCP). You add one setting; OrbCart serves the protocol. ## Turn it on Add `agents` to the one Channel that serves them: ```ts title="src/worker.ts" members="Channels" de: { currency: "EUR", pricesIncludeTax: true, locale: "de-DE", payments: { card: "adyen", google_pay: "adyen" }, orderPage: "https://shop.example.com/orders", agents: { continueUrl: "https://shop.example.com/checkout/continue", }, }, ``` `orderPage` is required with `agents`: UCP links every Order to it. [The order page →](/commerce/order-page) Agents now find your shop at `https:///.well-known/ucp`. At most one Channel per environment serves agents, because the UCP profile names one endpoint. ## How a purchase works ### The agent builds the checkout A signed agent platform creates a checkout session, adds Lines, an address and a shipping method. OrbCart prices everything with your `offers` function, exactly like for your storefront. ### The buyer agrees With the default `consent: "storefront"`, the agent sends the buyer a link to your `continueUrl`. The buyer reviews the Quote on **your** checkout and pays there. ### You get an Order The same Order as any other, with the same `deliverOrder` hand-off. Its [`order.created`](/integrations/events#the-catalog) event says `journey.origin: "agent"`. ## Build the continue page The link carries a short-lived `handoff` code. Redeem it, keep the Cart token in your [cart cookie](/commerce#in-a-cookie) like any other, drop the code from the address and show the checkout: ```ts title="app/routes/checkout.continue.ts" browser import { CommerceError } from "@orbcart/client"; import { redirect } from "react-router"; import type { LoaderFunctionArgs } from "react-router"; import { cartCookie } from "../cart-cookie.server"; import { commerce } from "../commerce"; export async function loader({ request }: LoaderFunctionArgs) { const handoff = new URL(request.url).searchParams.get("handoff"); if (!handoff) return redirect("/"); try { const cart = await commerce.continueCheckout(handoff); return redirect("/checkout", { headers: { "Set-Cookie": await cartCookie.serialize(cart.token), "Referrer-Policy": "no-referrer", // the code never leaves in a Referer }, }); } catch (error) { // A link that ran out opens the shop, never an error page. if (error instanceof CommerceError) return redirect("/"); throw error; } } ``` Your checkout page then shows the agent's Cart, with its Lines, address and shipping, like the buyer's own. ## Try it With your commerce Worker running under [`orbcart dev`](/sdks/cli): ```sh title="Terminal" curl -s http://localhost:8787/.well-known/ucp | jq '.ucp.capabilities | keys' ``` ```json [ "dev.ucp.shopping.checkout", "dev.ucp.shopping.discount", "dev.ucp.shopping.fulfillment", "dev.ucp.shopping.order" ] ``` Nordgrat also lists `dev.ucp.common.payment.ap2_mandate`, because it lets buyers agree inside the agent with [`consent: "mandate"`](#let-buyers-agree-inside-the-agent). ## Let buyers agree inside the agent With `consent: "mandate"`, the buyer can also agree in their agent's app. The platform proves it with the buyer's **AP2 checkout mandate** and pays with **Google Pay**: ```ts members="ChannelConfig" agents: { continueUrl: "https://shop.example.com/checkout/continue", consent: "mandate", googlePay: { merchantId: "BCR2DN4T...", merchantName: "Example Shop" }, }, ``` * Map `google_pay` to `adyen` in the Channel's `payments`. * `merchantId` comes from the Google Pay & Wallet Console. In test, Google ignores it. * OrbCart only accepts closed mandates that the buyer confirmed in person, signed within the last five minutes. It never issues mandates. Whether an order button inside an agent's app meets your legal duties, such as § 312j BGB in Germany, is yours to judge. `consent: "storefront"` keeps the final step on your own checkout. ## Order updates The platform that started a checkout hears of its Order. It lists UCP's order capability in its profile with a `webhook_url`; OrbCart sends the full Order to that address when the Order is created, fulfilled or refunded. There is nothing to configure and no code to write. * **Always the current state.** Each update shows the Order as it is when OrbCart sends it. A platform that was away gets the latest state, not a replay of every change. * **Signed with your keys.** The request carries an HTTP message signature (RFC 9421) made with a key from your UCP profile, which is the same key set as your `/.well-known/jwks.json`. A new signature goes out with every attempt. * **Tried for 72 hours.** OrbCart repeats a failed update with the same `Webhook-Id`, so the platform can drop duplicates. `Webhook-Timestamp` is the time of the change and stays the same too; a replay window belongs on the signature's `created` and `expires`, which are fresh each time. A `410` answer ends updates for that Order. * **No personal data.** The update holds Lines, quantities, totals, tracking and the withdrawal policy. It leaves out the delivery address. * **`permalink_url`** is the buyer's link to the Order: your `orderPage` and the Order's token. It stays the same for as long as the Order exists. A platform that gives no `webhook_url` can read its Orders instead with `GET /ucp/orders/{id}`. Only the platform that made an Order can read it. ## Who can call Only **signed** agent platforms. Each request carries an HTTP message signature and a `UCP-Agent` header naming the platform's profile. OrbCart fetches that profile, checks the signature against its keys and records the platform with the Cart. Unsigned requests get nothing. ## Endpoints | Method | Path | What it does | | ------ | -------------------------------------- | --------------------------------------- | | `GET` | `/.well-known/ucp` | Your shop's UCP profile. | | `POST` | `/ucp/checkout-sessions` | Start a checkout. | | `GET` | `/ucp/checkout-sessions/{id}` | Read it. | | `PUT` | `/ucp/checkout-sessions/{id}` | Change Lines, address, shipping, codes. | | `POST` | `/ucp/checkout-sessions/{id}/complete` | Pay with an AP2 mandate. | | `POST` | `/ucp/checkout-sessions/{id}/cancel` | Cancel it. | | `GET` | `/ucp/orders/{id}` | Read an Order the platform made. | ## Good to know * **Same rules as your storefront.** Prices, tax, stock, discounts and shipping come from the same code. Agents can't compute anything. * **Extensions stay with the buyer.** UCP has no field for your [extensions](/commerce/extensions). If one needs a new value, the agent hands the checkout to the buyer. * **Canceling ends the checkout.** The answer shows it as it was, with `status: "canceled"`; after that the checkout is gone and reading it answers `404`. You get [`cart.canceled`](/integrations/events) with `reason: "discarded"`. Once a payment runs or the Order arose, a cancel answers `409 checkout_not_cancelable`. [Test the whole flow →](/testing) # Errors Every error code, what caused it and what to do next. A failed request answers with a Problem. The SDKs throw it as `CommerceError`: ```ts try { await cart.add("BACKPACK-30-GREEN"); } catch (error) { if (error instanceof CommerceError) { error.problem.code; // what happened error.problem.kind; // the category error.problem.retryable; // may the same request succeed later? error.problem.cart; // the Cart as it is, when the buyer can fix it } } ``` **Not errors:** a declined payment, `quote_changed` and `pending` are normal results of `pay()` and `accept()`. [Outcomes →](/commerce/quotes#4-place-the-order) ## Decide by kind When you meet a code you don't handle, `kind` tells you the general next step: | `kind` | Next step | | -------------- | ---------------------------------------------------------------- | | `invalid` | Fix the input. Show the buyer what to correct. | | `unauthorized` | The credential is missing or wrong. A bug in your setup. | | `forbidden` | The credential is valid but not allowed here. | | `not_found` | The Cart, Quote or Order doesn't exist for this credential. | | `conflict` | The state moved on. Read the Cart again and continue from there. | | `rate_limited` | Wait and repeat with the same `Idempotency-Key`. | | `unavailable` | A system is temporarily away. Repeat later with the same key. | | `internal` | Our bug. Report the `requestId`. | ## The buyer can fix it Show a friendly message. `describe(locale).problem(code)` has one in four languages. | Code | Cause | What to do | | ----------------------------- | ----------------------------------------------- | --------------------------------------------------- | | `cart_incomplete` | Details missing for a Quote. | Ask for `problem.missing`. | | `ship_to_invalid` | The address fails the country's rules. | Highlight `problem.invalid`, such as `postalCode`. | | `bill_to_invalid` | Same, for the billing address. | Highlight `problem.invalid`. | | `ship_to_incomplete` | A partial address where a full one is needed. | Ask for the full address. | | `ship_to_unsupported` | You don't ship to this country. | Let the buyer choose another country. | | `shipping_method_unavailable` | The chosen method isn't offered here. | Show `cart.shippingMethods` again. | | `not_shippable` | Some Lines can't go to this address. | Offer to remove `problem.lines`. | | `lines_unavailable` | Lines can no longer be bought. | Offer to remove `problem.lines`. | | `code_invalid` | The code applies to nothing in this Cart. | Say so at the code field. | | `quantity_invalid` | Not a positive whole number. | Correct the quantity field. | | `configuration_invalid` | A line extension value was refused. | Map `problem.errors[].pointer` to your fields. | | `extension_invalid` | A Cart extension value was refused. | Map `errors`, or fix the names in `invalid`. | | `payment_method_unavailable` | The method doesn't fit this buyer. | Offer `quote.paymentMethods`. | | `tax_undeterminable` | No tax for `problem.country` and `taxCategory`. | Your tax setup. Let the buyer pick another address. | ## The purchase moved on | Code | Cause | What to do | | ------------------------------------------------ | ------------------------------------------------------- | ----------------------------------------------------------------------- | | `cart_locked` | A payment for this Cart is running. | Reopen its Quote with `openQuote()` or `resume()`. Don't start another. | | `cart_ordered` | The Cart already became an Order. | `commerce.continueShopping(token)`. | | `cart_not_ordered` | `continueShopping` before the purchase was handed over. | Keep using the current Cart. | | `cart_merged` | The buyer signed in and this Cart joined theirs. | Continue with the Cart from `identify()`. | | `quote_not_current` | The Cart changed after this Quote. | Ask for a new Quote. | | `preparation_unavailable`, `preparation_unknown` | A PayPal approval expired or doesn't match. | Start the express step again. | | `payment_instrument_required` | An agent or server accepted without a token. | Pass `instrument` to `accept()`. | ## Not found `cart_not_found`, `line_not_found`, `quote_not_found`, `order_not_found`, `payment_not_found`, `component_not_found`, `event_not_found`, `receiver_not_found`, `route_not_found`. A `cart_not_found` on a saved token usually means the Cart expired after 180 days without activity, or it belongs to a signed-in buyer whose token is missing. Start a new Cart quietly. ## Setup and credentials Fix these in your code or configuration. Buyers should never see them. | Code | Cause | | ------------------------ | ------------------------------------------------------------------------------------ | | `credential_missing` | No `Authorization` header. | | `credential_invalid` | Unknown key or token, or one of another environment. | | `environment_mismatch` | A `test` key against `live`, or the other way round. | | `not_permitted` | The credential may not do this, such as a publishable key on an Admin route. | | `not_a_member` | Explorer access without membership. [`members add` →](/sdks/cli#orbcart-members-add) | | `origin_not_allowed` | The browser's origin isn't in the Channel's `origins`. | | `buyer_token_invalid` | The `OrbCart-Buyer` token is expired or from an unknown issuer. | | `buyer_token_missing` | `identify()` without a signed-in buyer. | | `buyer_token_unexpected` | A buyer token next to a secret key. | ## Request format | Code | Cause | | -------------------------- | ----------------------------------------------------------------- | | `malformed_json` | The body isn't valid JSON. | | `request_invalid` | The body doesn't match the schema. See `problem.errors`. | | `idempotency_key_required` | This operation needs an `Idempotency-Key`. | | `idempotency_key_invalid` | The key is malformed. | | `idempotency_key_reused` | The same key with a different body. Use a new key. | | `request_in_progress` | The first request with this key is still running. Repeat shortly. | ## Temporarily unavailable Repeat the same request with the same key, after a short pause. Check `retryable` on the Problem to be sure. `temporarily_unavailable`, `provider_unavailable`, `identity_unavailable`, `projection_unavailable`, `invalidation_pending`, `internal_error`. ## Refunds and fulfillment | Code | Cause | | --------------------------- | -------------------------------------------------------- | | `refund_exceeds_order` | More units than the Order has left. | | `refund_exceeds_captured` | More money than was captured. | | `nothing_to_refund` | The selection adds up to zero. | | `refund_changed` | The amount differs from `expectedAmount`. Preview again. | | `fulfillment_exceeds_order` | More units shipped than ordered. | ## Webhooks `provider_webhook_invalid`, `provider_webhook_signature_invalid` and `provider_webhook_unavailable` go to the payment provider, not to you. A signature error almost always means a wrong webhook secret, or two Adyen webhooks on the same URL. ## Support Every Problem has a `requestId`. Log it, never the request body. It finds the request in the Explorer without exposing personal data. # HTTP API Call OrbCart from any language. Keys, headers, the endpoints you call and how retries work. The SDKs wrap this API. Use it directly from PHP, Python, Go or anything else that speaks HTTP. All paths are relative to your environment's URL, such as `https://commerce.example.com`. ## Authentication Send a credential as a Bearer token. Never in the URL. ```http Authorization: Bearer pk_test_... ``` | Credential | Looks like | Opens | | --------------- | ----------------- | ------------------------------------------------------ | | Publishable key | `pk_test_…` | Creating Carts in its Channel. Safe in a browser. | | Cart token | `cart_…_secret_…` | One Cart: read, change, quote and pay. Keep it secret. | | Secret key | `sk_test_…` | The Admin API. Server only. | A signed-in buyer's ID token goes in its own header, next to a publishable key or Cart token: `OrbCart-Buyer: `. ## A first request ```sh curl -X POST http://localhost:8787/v1/carts \ -H "Authorization: Bearer $ORBCART_PUBLISHABLE_KEY" \ -H "Idempotency-Key: $(openssl rand -hex 16)" ``` The answer is the Cart with its `id` and `token`. Use the token for everything that follows: ```sh curl -X POST "http://localhost:8787/v1/carts/$CART_ID/lines" \ -H "Authorization: Bearer $CART_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(openssl rand -hex 16)" \ -d '{"reference":"BACKPACK-30-GREEN","quantity":1}' ``` Every Cart change answers with the whole Cart. ## Endpoints Generated from the same routes that check every request, so they never fall behind. Every field and every answer is in the OpenAPI 3.2 description, ready for a generated client in any language: openapi/v1.json. Your own environment serves the description of its API at `/v1/openapi.json`: the same, with the [extensions](/commerce/extensions) your code declares by name and every [event](/integrations/events#the-catalog) under `webhooks`. A generated client of that one knows the fields of exactly your shop. ### Storefront With a publishable key or a Cart token. | Method | Path | What it does | With | | -------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------- | | `POST` | `/v1/carts` | Create a Cart. | Publishable key | | `POST` | `/v1/carts/handoffs` | Open an agent's checkout in the buyer's browser. `{ handoff }` | Publishable key | | `POST` | `/v1/carts/{cart}/buyer` | Claim the Cart for the signed-in buyer. | Cart token | | `POST` | `/v1/carts/{cart}/successor` | Start the next Cart after a purchase. | Cart token | | `GET` | `/v1/carts/{cart}` | Read a Cart. | Cart token | | `GET` | `/v1/carts/{cart}/payment-methods/{method}/setup` | A payment method's public setup before there is a Quote. | Cart token | | `POST` | `/v1/carts/{cart}/lines` | Add a Line. `{ reference, quantity?, configuration? }` | Cart token | | `PATCH` | `/v1/carts/{cart}/lines/{line}` | Set a Line's quantity. `{ quantity }` | Cart token | | `DELETE` | `/v1/carts/{cart}/lines/{line}` | Remove a Line. | Cart token | | `PUT` | `/v1/carts/{cart}/ship-to` | Set the delivery address. | Cart token | | `PUT` | `/v1/carts/{cart}/bill-to` | Set a separate billing address. | Cart token | | `DELETE` | `/v1/carts/{cart}/bill-to` | Bill to the delivery address again. | Cart token | | `PUT` | `/v1/carts/{cart}/contact` | Set the buyer's email and phone. `{ email, phone? }` | Cart token | | `PUT` | `/v1/carts/{cart}/shipping` | Choose a shipping method. `{ id }` | Cart token | | `POST` | `/v1/carts/{cart}/codes` | Enter a discount code. `{ code }` | Cart token | | `DELETE` | `/v1/carts/{cart}/codes/{code}` | Remove a discount code. | Cart token | | `PUT` | `/v1/carts/{cart}/extensions/{name}` | Set a Cart extension. `{ value }` | Cart token | | `DELETE` | `/v1/carts/{cart}/extensions/{name}` | Remove a Cart extension. | Cart token | | `POST` | `/v1/carts/{cart}/quote` | Get a signed Quote. | Cart token | | `GET` | `/v1/quotes/{quote}` | Reopen a Quote with its last payment state. | Cart token | | `GET` | `/v1/quotes/{quote}/payment-methods/{method}/setup` | A payment method's public setup for the Quote's amount. | Cart token | | `POST` | `/v1/quotes/{quote}/accept` | Accept the Quote and pay. `{ payment, instrument?, preparation?, returnTo? }` | Cart token | | `POST` | `/v1/payments/{payment}/resume` | Resume a Payment after a provider redirect. `{ returned? }` | Cart token | | `POST` | `/v1/carts/{cart}/prepare-payment` | Prepare a payment the buyer approves at the provider first, such as PayPal. `{ payment, returnTo? }` | Cart token | | `POST` | `/v1/carts/{cart}/preparations/{preparation}/update` | Pass on an address or shipping method the buyer chose at the provider. `{ shipTo } or { shipping }` | Cart token | | `POST` | `/v1/carts/{cart}/preparations/{preparation}/review` | Quote what the buyer approved at the provider. | Cart token | | `GET` | `/v1/orders/{order}` | Read an Order by ID or number, or a buyer's own by its token. | | | `GET` | `/v1/orders` | List Orders: a signed-in buyer's own, or all of them with filters. | Publishable key | | `GET` | `/v1/address-formats/{country}` | Postal code and region rules of a country. | Publishable key, Cart token | ### Admin With a secret key, on servers only. | Method | Path | What it does | | ------ | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/carts` | List Carts that need a person. | | `GET` | `/v1/carts/{cart}` | Read a Cart. | | `GET` | `/v1/orders/{order}` | Read an Order by ID or number, or a buyer's own by its token. | | `POST` | `/v1/orders/{order}/fulfillments` | Report shipped units. | | `POST` | `/v1/orders/{order}/acknowledge` | Confirm your ERP took the Order. `{ reference? }` | | `GET` | `/v1/orders/{order}/refunds` | Refunds and what is still refundable. | | `POST` | `/v1/orders/{order}/refunds` | Refund. `{ lines?, shipping?, expectedAmount?, reason? }` | | `POST` | `/v1/orders/{order}/refunds/preview` | Calculate a refund without making it. `{ lines?, shipping?, expectedAmount?, reason? }` | | `POST` | `/v1/orders/search` | Find Orders by email. `{ email, limit?, after? }` | | `GET` | `/v1/orders` | List Orders: a signed-in buyer's own, or all of them with filters. | | `GET` | `/v1/events` | List the Events of Orders and of the Carts before them, newest first, with how far each receiver's delivery came. | | `POST` | `/v1/events/{event}/resend` | Send an Event again, with the same ID, to one Receiver or to all that want its type. `{ receiver? }` | | `POST` | `/v1/events/replay` | Send the Events from an instant on again, oldest first, a batch per call. `{ since, receiver?, after? }` | | `GET` | `/v1/insights/checkout` | Count where buyers get stuck: the funnel, the time a checkout takes, the friction and the payment methods of the last days. | | `GET` | `/v1/receivers` | List the Receivers of Events that the code defines, with how each stands. | | `POST` | `/v1/receivers/{receiver}/pause` | Stop deliveries to a Receiver until it is resumed. | | `POST` | `/v1/receivers/{receiver}/resume` | Resume a Receiver and deliver its backlog at once. | | `POST` | `/v1/offers/invalidate` | Void Offers by reference or tag. `{ references?, tags?, reason? }` | | `GET` | `/v1/environment` | Mode, jurisdiction and Channels. | | `GET` | `/v1/address-formats/{country}` | Postal code and region rules of a country. | ## Public endpoints | Method | Path | What it is | | ------ | ------------------------ | ---------------------------------------------------------------- | | `GET` | `/.well-known/jwks.json` | Public keys to [verify Quotes](/commerce/quotes#verify-a-quote). | | `GET` | `/.well-known/ucp` | Your UCP profile for [AI agents](/agents). | | `POST` | `/webhooks/{provider}` | Webhooks from `stripe`, `adyen`, `mollie` and `paypal`. | ## Retries and idempotency Send an `Idempotency-Key` header on every change. After a timeout, repeat the **same request with the same key**. OrbCart answers with the original result instead of doing it twice. A new operation gets a new key. Accepting a Quote, refunds and payment preparations **require** a key. Keys live for 24 hours. For 24 hours, replaying the key of `POST /v1/carts` returns that Cart's token. Keep it as private as the token. ## Errors Failures use [Problem Details](https://www.rfc-editor.org/rfc/rfc9457) as `application/problem+json`: ```json { "status": 422, "code": "cart_incomplete", "kind": "invalid", "retryable": false, "requestId": "req_01J...", "detail": "The cart still lacks ship_to, email.", "missing": ["ship_to", "email"] } ``` Decide by `code`, never by `detail`. `detail` is for developers, in English. [Every code and what to do →](/api-reference/errors) ## Versioning `/v1` only grows. New fields and new values can appear at any time; existing ones never change meaning. Ignore fields you don't know and handle unknown values in open lists such as `changes`, `missing` and `code`. [Look up an error code →](/api-reference/errors) # Discounts Codes, automatic promotions, free shipping and redemption limits. Declared, not coded. Declare discounts on the Channel. OrbCart applies them, spreads them over the Lines and shows them in the Cart, the Quote and the Order. ```ts title="src/worker.ts" members="ChannelConfig" discounts: [ { id: "welcome", code: "WELCOME10", percentage: 10 }, ], ``` The buyer enters the code: ```ts browser await cart.enterCode("welcome10"); // any spelling await cart.removeCode("WELCOME10"); ``` ## Common recipes A complete walk-through, from the declaration to a test: [Free shipping and a welcome code](/recipes/free-shipping-and-codes). ```ts alternatives value="Discount" // €5 off orders from €40 { id: "five-off", code: "FIVE", amount: 500, minimumSubtotal: 4000 } // Free standard shipping from €50, no code needed; express stays paid { id: "free-shipping", scope: "shipping", percentage: 100, minimumSubtotal: 5000, methods: ["standard"] } // 20 % off every unit tagged "outdoor", for one weekend { id: "weekend", scope: "line", percentage: 20, tags: ["outdoor"], from: "2026-10-09T22:00:00Z", until: "2026-10-11T22:00:00Z", } // The first 500 buyers, once each { id: "launch", code: "LAUNCH", percentage: 15, limit: { total: 500, perBuyer: 1 } } // Never combined with anything else { id: "vip", code: "VIP30", percentage: 30, combination: "exclusive" } ``` ## Options | Option | Example | What it does | | ----------------- | ----------------------------- | ------------------------------------------------------------------------------------------------- | | `id` | `"welcome"` | Your name. Unique per Channel. Shown in Quotes and Orders. | | `percentage` | `10` | Percent off. Use this **or** `amount`. | | `amount` | `500` | Fixed amount off, in minor units. | | `code` | `"WELCOME10"` | Applies only when entered. Without it, applies automatically. | | `scope` | `"order"` | `order` (default): the goods. `line`: each unit of matching Lines. `shipping`: the shipping cost. | | `references` | `["BACKPACK-30-GREEN"]` | Only these Offers. | | `tags` | `["outdoor"]` | Only Offers with these tags. | | `minimumSubtotal` | `5000` | Goods value before discounts the Cart must reach. | | `methods` | `["standard"]` | Shipping discounts only: these shipping rule IDs, in every country. Without it, every method. | | `from`, `until` | `"2026-10-09T22:00:00Z"` | Active from `from` until just before `until`. RFC 3339. | | `limit` | `{ total: 500, perBuyer: 1 }` | How often it can be redeemed. | | `combination` | `"exclusive"` | `combinable` (default) stack. Of the `exclusive` ones, only the best applies, alone. | ## What the buyer sees * **A code that applies nowhere** is refused with `code_invalid` and not kept. * **A kept code that stops qualifying,** for example when the Cart drops below the minimum, appears in `cart.changes` as `code_invalid`. It applies again once the Cart qualifies. * **A free-shipping code while express is chosen** stays in the Cart. `cart.changes` says `code_invalid` with `reason: "shipping_method"` and `methods: ["standard"]`, so you can tell the buyer to pick standard. The React kit says it for you. A code for a method the Cart cannot get at all is refused. * **Applied discounts** are listed once each in `estimate.discounts` and `quote.discounts`, with the amount they took off. ## Redemption limits A redemption counts when the Order is placed. A Quote reserves one while it is valid, so the last redemption cannot go to two buyers at once. `perBuyer` counts [signed-in buyers](/integrations/identity) by their identity and guests by their email address. ## Codes from your own pricing Already run promotions in your ERP? Your `offers` function receives every entered code. Redeem the ones you know and report them: ```ts export const offers: OffersContract = async (lines, { codes, currency }) => { const prices = await erp.prices(lines, { codes, currency }); return prices.map((price) => ({ reference: price.sku, title: price.name, price: { amount: price.amount, currency }, taxCategory: "standard", appliedCodes: price.redeemedCodes, // such as ["VIP20"] })); }; ``` OrbCart checks a code against `offers` first. Only a code that `offers` does not redeem is checked against your declared `discounts`. ## End a promotion early Tag the affected Offers and invalidate the tag. [How →](/integrations#end-a-sale-early) [Add your own fields with extensions →](/commerce/extensions) # Extensions Your own typed fields on a Line or the Cart. An engraving, a gift message, a configurator's choices. Need a value OrbCart doesn't know? Declare it once with a schema. OrbCart validates it, keeps it with the purchase and hands it to your ERP with the Order. | On | Example | Where the value goes | | -------- | -------------------------------------- | ------------------------------------------------ | | a Line | An engraving, a size configurator | Your `offers` function, the Quote and the Order. | | the Cart | A gift message, a note for the carrier | The Quote and the Order. | The recipe [A gift message, end to end](/recipes/gift-message) builds one from the declaration to the packing slip. ## Declare Any [Standard Schema](https://standardschema.dev) with JSON Schema support works, such as Zod 4.2 or newer: ```ts title="src/commerce.ts" import { z } from "zod"; import type { CommerceConfig } from "@orbcart/runtime"; export const commerce = { channels, offers, extensions: { engraving: { on: "line", schema: z.object({ text: z.string().trim().min(1).max(20) }) }, giftMessage: { on: "cart", schema: z.string().max(200) }, }, } satisfies CommerceConfig; ``` ```ts title="src/worker.ts" export default defineCommerce(commerce); ``` Names start with a letter and use only letters, digits and `_`. ## Set values from the storefront Pass your configuration's type to `createCommerce`. Every value is then typed by its schema: ```ts browser import type { commerce as config } from "../commerce/src/commerce"; const commerce = createCommerce({ url, key }); const cart = await commerce.cart(); await cart.add("BOTTLE-750", { configuration: { engraving: { text: "Anna 2026" } } }); await cart.setExtension("giftMessage", "Happy birthday!"); await cart.removeExtension("giftMessage"); ``` The same reference with a different configuration becomes its own Line. Two bottles engraved differently are two Lines. ## Charge for a configuration A Line's values arrive in your `offers` function as `line.configuration`. When the price depends on it, echo the configuration back with the price: ```ts export const offers: OffersContract = async (lines, { currency }) => lines.map((line) => { const engraved = line.configuration.engraving !== undefined; return { reference: line.reference, configuration: line.configuration, title: engraved ? "Bottle 750 ml · engraved" : "Bottle 750 ml", price: { amount: engraved ? 2990 : 2490, currency }, taxCategory: "standard", ...(engraved && { withdrawal: { excluded: "personalized" } }), }; }); ``` An answer without `configuration` applies to every Line of that reference. Cart extensions never change an amount by themselves. ## When a value is refused | Code | When | What the problem tells you | | ----------------------- | ---------------------------------------- | -------------------------------------------------------------------------------- | | `configuration_invalid` | `add()` with a value the schema refuses. | `errors[]` with a `pointer` per field, such as `#/configuration/engraving/text`. | | `extension_invalid` | `setExtension()` with a refused value. | `errors[]` with pointer `#/value`. | | `extension_invalid` | `quote()` after you changed a schema. | `invalid` names the extensions to fix or remove. | Map `errors[].pointer` to your form fields to show the message where it belongs. ## Read them in the Order ```ts order.quote.lines[0].configuration; // { engraving: { text: "Anna 2026" } } order.quote.extensions; // { giftMessage: "Happy birthday!" } ``` Your [`deliverOrder`](/integrations/inventory#hand-orders-to-your-erp) receives the same Order. ## Generate a client that knows them `GET /v1/openapi.json` on your environment describes the API with your declarations: the `LineConfiguration` and `CartExtensions` schemas name each extension with its JSON Schema, and every cart extension has the path of its own, such as `PUT /v1/carts/{cart}/extensions/giftMessage`. A client generated from it knows the fields of your shop. The description is public, like the endpoint: write the schemas, their enums and descriptions as something a storefront may read, and keep internal codes out of them. In it, a field with a default counts as optional, though your environment always sends it. ## Limits * Each value is at most **16 KiB** as JSON. Enough for a configurator's choices. * Values are personal data. They never appear in logs, and members without access to buyer data see them redacted. * AI agents cannot set extensions. If a Cart extension needs a new value, the agent hands the checkout to the buyer. [AI agents →](/agents) [Choose payment providers →](/payments) # Carts Add goods, change quantities and keep the buyer's place across reloads and devices. These examples use `@orbcart/client`. It works in the browser, on your server and in an agent. [React](/sdks/react) builds on it. ## Connect once ```ts title="lib/commerce.ts" import { createCommerce } from "@orbcart/client"; export const commerce = createCommerce({ url: process.env.ORBCART_URL, key: process.env.ORBCART_PUBLISHABLE_KEY, }); ``` Use the **OrbCart** publishable key (`pk_test_…`) from [`orbcart dev`](/sdks/cli), not a Stripe key. It is safe in the browser. For a deployed storefront, add its origin to the Channel's [`origins`](/configuration#origins-and-returnurl). ## Create a Cart and add a Line ```ts const cart = await commerce.cart(); const current = await cart.add("BACKPACK-30-GREEN", { quantity: 2 }); ``` `cart` is a handle you call methods on. `current` is the server's answer: the whole Cart, with Lines, prices and what is still missing. **Every change answers with the whole Cart**, so you never need a second request to refresh. The browser sends only a reference and a quantity. Prices always come from your `offers` function. ## Read the Cart ```ts current.lines; // [{ id, reference, quantity, status, offer: { title, price } }] current.estimate.totals; // { subtotal, discount, shipping, tax, total } current.missing; // ["ship_to", "email"] current.changes; // [{ type: "price_changed", line, from, to }] current.shippingMethods; // [{ id: "standard", title, price, deliveryTime }] current.paymentMethods; // [{ id: "card", provider: "stripe", flow: "inline" }] current.lifecycle; // { status: "open" } ``` Show `changes` to the buyer. They explain why something differs from what the buyer expected, such as a price that went up or a Line that sold out. ## Change and remove Lines Use the Line's `id` from `current.lines`, not the reference: ```ts const [line] = current.lines; await cart.setQuantity(line.id, 3); await cart.remove(line.id); ``` Adding the same reference again increases that Line's quantity. ## Set contact, address and shipping ```ts await cart.setContact({ email: "anna@example.com" }); await cart.setShipTo({ name: "Anna Berg", line1: "Bergstraße 12", postalCode: "10115", city: "Berlin", country: "DE", }); await cart.setShipping("express"); ``` Without a separate billing address, the delivery address bills. Use `cart.setBillTo(address)` and `cart.removeBillTo()` to change that. You can also pass everything at once to [`cart.quote()`](/commerce/quotes). Each country asks for different address fields. Ask once per country: ```ts const format = await commerce.addressFormat("IE"); // { postalCode: { required, pattern, kind: "eircode" }, region: { required, kind: "county", options } } ``` ## Keep the Cart across reloads Save the token in your session. Open the Cart again with it: ```ts const cart = commerce.openCart(savedToken); const current = await cart.read(); ``` `openCart()` makes a handle without a request. `read()` fetches the state. A Cart token opens that Cart and its personal data. Never put it in a URL, an analytics event or a log. A server-rendered storefront keeps it in an HttpOnly, Secure, SameSite cookie. Nordgrat keeps it for 180 days, the Cart's inactivity period. ### In a cookie In React Router, one cookie holds the token for your server, and one route lets the page save a new one: ```ts title="app/cart-cookie.server.ts" import { createCookie } from "react-router"; // Path `/` and SameSite Lax are React Router's defaults. 180 days: as long as OrbCart keeps an idle Cart. export const cartCookie = createCookie("cart", { httpOnly: true, secure: true, maxAge: 15_552_000, }); ``` ```ts title="app/routes/cart-token.ts" import type { ActionFunctionArgs } from "react-router"; import { cartCookie } from "../cart-cookie.server"; export async function action({ request }: ActionFunctionArgs) { // Only your own pages may set the token, or another site could slip the buyer a Cart it reads. if (request.headers.get("Origin") !== new URL(request.url).origin) { return new Response(null, { status: 403 }); } const token = (await request.formData()).get("token"); if (typeof token !== "string") return new Response(null, { status: 400 }); return new Response(null, { status: 204, headers: { "Set-Cookie": await cartCookie.serialize(token), "Cache-Control": "no-store" }, }); } ``` Register it in `app/routes.ts` as `route("cart-token", "routes/cart-token.ts")`. A loader reads the token with `cartCookie.parse(request.headers.get("Cookie"))`; the page saves a new one with `fetch("/cart-token", { method: "POST", body: new URLSearchParams({ token }) })`. [Add to cart and a mini cart](/recipes/add-to-cart) puts both to work. ## After the purchase Once the buyer has handed the purchase over, their bag should be empty again. Move to the next Cart: ```ts import { isSubmitted } from "@orbcart/client"; if (isSubmitted(current)) { const next = await commerce.continueShopping(cart.token); saveCartToken(next.token); } ``` `isSubmitted` is true when the Cart is ordered, or its payment only waits for the provider's answer. While the buyer still has a step to do, such as 3-D Secure, it is the same purchase. Every call within 24 hours returns the **same** next Cart, so two open tabs keep shopping together. ## One shopping Cart for the whole storefront `createShoppingCart()` does all of the above for any framework. It keeps the buyer's current Cart, creates the first one with the first line, and moves on by itself: to the next Cart after a purchase, to the buyer's own after a sign-in on another device, and to a fresh one when OrbCart no longer knows the Cart. The buyer never sees an error for any of it. ```ts import { createShoppingCart } from "@orbcart/client"; const shopping = createShoppingCart({ commerce, token: savedToken, onCartChange: (cart) => saveCartToken(cart.token), }); shopping.subscribe((cart) => render(cart)); await shopping.follow(savedToken); // once the page has loaded await shopping.add("TENT-1"); ``` `cart` is the Cart to show, `follow(token)` takes the token you saved now, and `shopOn()` moves on after a receipt was shown. In React, [`CartProvider`](/sdks/react#a-cart-for-the-whole-storefront) wraps it for you. ## Handle errors Mutations throw `CommerceError` when they cannot be applied. When the buyer can fix it, `error.problem.cart` carries the Cart as it is: ```ts import { CommerceError } from "@orbcart/client"; try { await cart.enterCode(input.value); } catch (error) { if (error instanceof CommerceError && error.problem.code === "code_invalid") { showFieldHint("This code doesn't apply to your bag."); } else { throw error; } } ``` See [Errors](/api-reference/errors) for every code and what to do. [Turn the Cart into a Quote →](/commerce/quotes) # The order page Give every buyer, guests included, their order from any device through a secret link, without an account. After the purchase, the buyer's order lives behind a link. The link carries a secret, so it works for guests, on any device, and without an account. **You host the page; OrbCart provides the link and what is on it.** ## 1. Name the page Add `orderPage` to the Channel: the address of the page that shows an order. The link to an order is this address and the order's token as its last segment. ```ts title="src/worker.ts" members="Channels" de: { currency: "EUR", pricesIncludeTax: true, locale: "de-DE", orderPage: "https://shop.example.com/orders", }, ``` An Order now carries its link: ```ts order.url; // https://shop.example.com/orders/ord_…_secret_… order.token; // ord_…_secret_… ``` `quote.accept()` returns the Order, so the storefront can send the buyer there right after paying. Your confirmation email reads it from the Order the same way: ```ts const order = await admin.orders.get(event.subject); await mail.send({ to: order.contact.email, data: { number: order.number, orderUrl: order.url } }); ``` The link does not change for as long as the Order exists, so every message you send about it can carry it. ## 2. Show the order Build a route that takes the token as its last segment and reads the Order with it. No key is needed: the token opens exactly this Order. ```tsx title="app/routes/orders.$token.tsx" browser import { CommerceError } from "@orbcart/client"; import { OrderPage } from "@orbcart/react"; import type { Route } from "./+types/orders.$token"; export async function loader({ params }: Route.LoaderArgs) { try { return { order: await commerce.order(params.token) }; } catch (error) { if (error instanceof CommerceError) throw new Response(null, { status: 404 }); throw error; } } export default function Order({ loaderData: { order } }: Route.ComponentProps) { return ; } ``` `` shows what was bought and paid, where it goes, how far it is, and the tracking of what shipped, in the language of the Order. Put your own content after it, such as a link to your support, as its children. Prefer your own design? `commerce.order(token)` returns the Order; show its amounts as they are. ## What the buyer sees The snapshot, the payment method, the shipments with tracking and the refunds. The buyer never sees what only you see: the payment provider's references, the history, issues and notes. Whoever holds the link sees the order, including the delivery address. Keep it out of logs and analytics, and send `Referrer-Policy: strict-origin-when-cross-origin` or stricter with the page. Opening the order with the token never changes anything. A token that opens no Order, or was cut short in an email client, refuses with a `CommerceError` (`order_not_found`, or `credential_invalid`). Show the buyer the way to your support instead of an error page. # Quotes & payments Show the exact total, collect the payment and turn the purchase into an Order. Using React? [``](/sdks/react) does everything on this page for you. In another framework, the checkout store does: [A checkout without React](/recipes/checkout-without-react). Read on for the single calls underneath, for a server or an agent. ## 1. Ask for a Quote ```ts const quote = await cart.quote({ contact: { email: "anna@example.com" }, shipTo: { name: "Anna Berg", line1: "Bergstraße 12", postalCode: "10115", city: "Berlin", country: "DE", }, }); ``` The Quote is binding. It is signed, holds the exact total and expires after at most 30 minutes. Show `quote.totals.total` next to the order button: ```ts import { formatMoney } from "@orbcart/client"; formatMoney(quote.totals.total, "en-GB"); // "€94.85" ``` Never divide amounts yourself. **Details missing?** The call throws `CommerceError` with code `cart_incomplete`. `error.problem.missing` lists what to ask for, such as `["ship_to_postal_code"]`. ## 2. Let the buyer choose how to pay `quote.paymentMethods` lists what this buyer can use right now. It depends on the Channel, the currency and the billing country: ```ts quote.paymentMethods; // [{ id: "card", provider: "adyen", flow: "inline" }, // { id: "paypal", provider: "paypal", flow: "redirect" }, // { id: "klarna", provider: "adyen", flow: "redirect" }] ``` | `flow` | What happens | | ---------- | ------------------------------------------------------------------------------- | | `inline` | A field from the provider sits on your page. Card, Bancontact. | | `redirect` | The buyer leaves for the provider and comes back. PayPal, Klarna, iDEAL, TWINT. | | `wallet` | A wallet sheet opens. Apple Pay, Google Pay. [Express →](/payments/express) | ## 3. Mount the payment field For an `inline` method, give the provider an empty element: ```html
``` ```ts const element = document.querySelector("#payment-field")!; const payment = await quote.mountPayment("card", element); ``` Card details go straight to the provider. They never touch your page or OrbCart. The field outlives the Quote, so a new Quote can use the same field. For a `redirect` method, there is nothing to mount. Pass the method's name instead. ### Match the field to your page Adyen draws its card from CSS variables on the element, Stripe from an `appearance`. Both helpers take your colors, so the field reads like the rest of your checkout: ```ts import { adyenVariables, stripeAppearance } from "@orbcart/client"; const colors = { text: "#0a0f1f", surface: "#fff", action: "#31476b", radius: "6px" }; Object.assign( element.style, adyenVariables({ ...colors, muted: "#5b6275", error: "#b3261e", border: "#d9dce4", focus: colors.action, }), ); const payment = await quote.mountPayment("card", element, { appearance: stripeAppearance(colors) }); ``` Adyen's variables accept `var(--your-token)`; Stripe's field is its own document and needs literal colors. Pass `dark: true` to `stripeAppearance` on a dark page. ## 4. Place the Order Call `pay()` only from the buyer's click on the order button: ```ts alternatives const result = await quote.pay({ payment }); // inline: the mounted field const result = await quote.pay({ payment: "paypal" }); // redirect: the method's name ``` `pay()` accepts the Quote, runs 3-D Secure if the bank asks for it and waits for the answer. Handle every outcome: ```ts function switch (result.status) { case "ordered": return showConfirmation(result.order.number); case "quote_changed": return askAgain(result.quote, result.changes); case "failed": return letBuyerTryAgain(); case "pending": return showWaiting(result); case "requires_action": return result.action.run(); } ``` | `status` | What it means and what you do | | ----------------- | ----------------------------------------------------------------------------------------- | | `ordered` | Done. Show `result.order.number`. | | `quote_changed` | A price, stock or shipping changed. Show `result.quote` and `result.changes`. Ask again. | | `failed` | The payment was declined. Let the buyer fix it or pick another method. | | `pending` | The provider has not decided after 10 seconds. Wait, don't retry. | | `requires_action` | The buyer left a step unfinished, such as 3-D Secure. `result.action.run()` continues it. | A timeout is not a failed payment. The money may already be on its way. Follow the same purchase until the provider decides. ### Waiting for an undecided payment A `pending` result's `wait()` checks again for 10 seconds. To keep a page open until the provider decides, wait without a limit: ```ts if (result.status === "pending") { const outcome = await result.wait({ timeout: Infinity }); } ``` The runtime decides within the 30-minute payment window at the latest. `` does this for you: after 10 seconds it shows a calm waiting panel, asks the buyer not to order again, and becomes the confirmation once the provider answers. ## 5. Return from a redirect PayPal, Klarna, iDEAL and others send the buyer to the Channel's `returnUrl`. On that page, open the saved Cart and resume: ```ts const cart = commerce.openCart(savedToken); const result = await cart.resume(); ``` `resume()` reads the return parameters from the page's URL and answers with the same outcomes as `pay()`. After a normal reload in the middle of checkout, reopen the Quote instead: ```ts const quote = await cart.openQuote(quoteId); ``` It never starts a new payment. It returns the Quote with what its last payment came to. ## pay() or accept()? | Method | Use it when | | ---------- | ---------------------------------------------------------------------------------------------------------------- | | `pay()` | A buyer is on the page. Runs every step and waits. Use this. | | `accept()` | You want to run each step yourself, or you pay from a server or an agent with a ready-made token (`instrument`). | ## Verify a Quote Every Quote is signed with the environment's key. Anyone can check it, even years later: ```ts import { verifyQuote } from "@orbcart/client"; const keys = await fetch(`${ORBCART_URL}/.well-known/jwks.json`).then((r) => r.json()); const { valid } = await verifyQuote(quote, keys); ``` An Order keeps its signed Quote in `order.quote`, so the agreed purchase stays provable. [Configure shipping →](/commerce/shipping) # Shipping Declare where you ship and what it costs. No code, just rules per country. Shipping lives in the Channel. The countries you list are the countries you ship to. Every other country gets `ship_to_unsupported`. ```ts title="src/worker.ts" members="ChannelConfig" shipping: { DE: [ { id: "standard", title: "Standard (DHL)", price: 495, deliveryTime: "2-4 business days" }, { id: "express", title: "Express", price: 1290, deliveryTime: "1 business day" }, ], AT: [{ id: "standard", title: "Standard", price: 790, deliveryTime: "3-5 business days" }], }, ``` Prices are in minor units of the Channel's currency. `495` is €4.95. ## Rule options | Option | Required | Example | What it does | | -------------- | -------- | --------------------- | --------------------------------------------------------------------- | | `id` | yes | `"express"` | Your ID. Unique per country. | | `title` | yes | `"Express"` | What the buyer sees. | | `price` | yes | `1290` | Minor units. `0` for free shipping. Or tiers by weight, see below. | | `deliveryTime` | | `"1 business day"` | Your text. Shown as is, never calculated. | | `requires` | | `["phone"]` | Contact fields this method needs, such as a phone number for freight. | | `excludes` | | `["lithium_battery"]` | Offer restrictions this method cannot carry. | | `only` | | `["bulky"]` | Offered only when a Line carries one of these restrictions. | ## Which method is selected? The buyer's choice. Until they choose, the cheapest method. The Cart shows what's on offer right now: ```ts browser current.shippingMethods; // [{ id: "standard", ... }, { id: "express", ... }] current.shipping; // { id: "standard" } await cart.setShipping("express"); ``` ## Keep goods off a method Mark goods in your `offers` function: ```ts members="OfferedReference" shipping: { weightInGrams: 180, restrictions: ["lithium_battery"] }, ``` Then exclude the restriction on the rule that cannot carry it: ```ts items="ShippingRuleConfig" { id: "express", title: "Express", price: 1290, excludes: ["lithium_battery"] }, ``` Express disappears for that Cart. `cart.changes` explains why with a `shipping_unavailable` entry, so you can tell the buyer. If no method is left for a country, the Quote fails with `not_shippable` and `problem.lines` names the Lines to remove. ## Price by weight Give `price` as tiers, lightest first: ```ts items="ShippingRuleConfig" { id: "standard", title: "Standard (DHL)", price: [ { upToGrams: 2000, price: 495 }, { upToGrams: 10000, price: 695 }, { price: 995 }, // any weight above ], }, { id: "express", title: "Express", price: [{ upToGrams: 10000, price: 1290 }] }, ``` * **The weight** is every shipped unit together, from `weightInGrams` in your Offers. * **The first tier that fits** sets the price. * **A last tier without `upToGrams`** covers any weight. With a limit, like express above, the method disappears for heavier Carts and `cart.changes` says why (`reason: "too_heavy"`). Keep one method open for any weight, so a large order always ships. * **An Offer without `weightInGrams`** counts as 0 g. Your logs name it as `weight_missing`. ## Bulky goods by freight Mark the goods with a restriction such as `bulky`, keep it off the parcel methods with `excludes`, and offer freight with `only`: ```ts items="ShippingRuleConfig" { id: "standard", title: "Standard (DHL)", price: 495, excludes: ["bulky"] }, { id: "freight", title: "Freight", price: 4900, requires: ["phone"], only: ["bulky"] }, ``` A Cart with a canoe gets freight alone. A pair of socks never sees it. Put `bulky` in `excludes` on every parcel method, or the cheaper parcel is preselected for the canoe. ## Goods that take longer A rule's `deliveryTime` is the parcel's time. Goods that ship later, such as an engraving, say so with their own `deliveryTime` in your `offers` function. The buyer sees both: the Line's time on the Line, the method's beside the method. See [Offers](/integrations#say-when-goods-take-longer). ## Digital goods Leave out `shipping` in the Offer. A Cart with only digital goods needs no delivery address and no shipping method. ## Many countries, one price A small helper keeps long lists readable. This is how Nordgrat ships to price zones across the EU: ```ts function zone(countries: string[], price: number, deliveryTime: string) { const standard = { id: "standard", title: "Standard (DHL)", price, deliveryTime }; return Object.fromEntries(countries.map((country) => [country, [standard]])); } ``` The channel then lists its own countries and spreads the zones: ```ts continues members="ChannelConfig" shipping: { DE: [standard, express], ...zone(["BE", "NL", "LU", "DK"], 890, "3-5 business days"), ...zone(["FR", "IT", "ES", "SE"], 990, "4-6 business days"), }, ``` ## Free shipping from €50 Use a [discount](/commerce/discounts) with `scope: "shipping"`. Name the methods it frees in `methods`, so express stays paid: ```ts members="ChannelConfig" discounts: [ { id: "free-shipping", scope: "shipping", percentage: 100, minimumSubtotal: 5000, methods: ["standard"] }, ], ``` Without a `code`, it applies automatically. ## DHL Packstation and pickup points `@orbcart/plugin-dhl` adds a pickup finder to the React checkout, for Germany. The buyer picks a Packstation or post office on a map, and it becomes the delivery address: ```tsx browser import { DhlDeliveryAddress, DhlPickupProvider } from "@orbcart/plugin-dhl/react"; import "@orbcart/plugin-dhl/styles.css"; ; ``` Each Packstation shows a green, yellow or red dot for how much space it usually has. The search runs on your server, at the `searchUrl` you name, with your DHL API key: ```ts title="app/routes/pickup.locations.ts" import { env } from "cloudflare:workers"; import { PickupSearch } from "@orbcart/plugin-dhl"; import { findDhlLocations } from "@orbcart/plugin-dhl/server"; export async function action({ request }: { request: Request }) { // Only your own checkout may spend your DHL quota. if (request.headers.get("Origin") !== new URL(request.url).origin) { return new Response(null, { status: 403 }); } const search = PickupSearch.safeParse(await request.json()); if (!search.success) return Response.json({ error: "invalid_query" }, { status: 400 }); const result = await findDhlLocations({ apiKey: env.DHL_API_KEY, search: search.data, signal: request.signal, }); const headers = { "Cache-Control": "private, no-store" }; return result.status === "success" ? Response.json({ locations: result.locations }, { headers }) : Response.json({ error: result.error }, { status: 503, headers }); } ``` Use DHL's API Key, not the API Secret, with a Location Finder Unified subscription. Keep `@orbcart/plugin-dhl/server` out of browser code. `packages/plugin-dhl/README.md` lists the error codes and what the finder does not promise yet. [How a plugin is shaped →](/extend#package-it-for-other-shops) [Set up discounts →](/commerce/discounts) # Concepts The mental model of OrbCart. Who owns what, the words of a purchase, and why it is shaped this way. **You own the catalog. OrbCart owns the purchase.** Products, prices and stock stay in your systems. OrbCart takes a reference and a quantity, asks your systems for the current facts and turns them into a signed Quote, a Payment and an Order. Two front doors lead into one Cart: your storefront, where the buyer shops with a publishable key, and AI agents, which buy for a buyer over UCP with signed requests. The Cart holds the purchase from its Lines to the Quote, the Payment and the Order, in a Worker in your own Cloudflare account. Behind it, your systems answer through small contracts for prices, stock, tax and the hand-off to your ERP, and the payment provider moves the money and reports back by webhook. Both doors follow the same rules. A storefront or an agent can only say what the buyer wants. Everything binding, from a price to the total, is decided on the server. [How it works](/how-it-works) follows one purchase through every part. ## The words of a purchase ### Offer **What you are willing to sell right now.** Your [offers function](/integrations) returns it whenever OrbCart needs current facts: ```ts value="OfferedReference" { reference: "BACKPACK-30-GREEN", title: "Trail backpack · 30 L · Green", price: { amount: 8990, currency: "EUR" }, taxCategory: "standard", shipping: { weightInGrams: 950 }, } ``` An Offer is not a catalog entry: no images, no description, no categories. Those stay in your storefront. Money is always an integer in minor units with its currency, so `8990` with `EUR` is €89.90. Use `formatMoney()` to display it. ### Line **What the buyer chose.** A reference and a quantity. OrbCart gives every Line its own `id`: ```ts browser await cart.add("BACKPACK-30-GREEN", { quantity: 2 }); ``` Add goods by **reference**. Change or remove them by **Line ID**. ### Cart **The purchase while it takes shape.** Lines, contact details, addresses, the shipping choice and discount codes. Every change answers with the whole Cart, including two fields a checkout shows: * `estimate` is the current, non-binding price. `estimate.excludes` names what is not in it yet: `"shipping"` until a method is chosen, `"tax"` for net prices until a rate is known. * `missing` lists what the buyer still has to provide, in the order a checkout asks: `["ship_to", "email"]`. ### Quote **The exact deal the buyer agrees to.** A Quote freezes the Lines, prices, tax, shipping and total. It is signed and expires after at most 30 minutes. If anything changed before the buyer pays, the answer is `quote_changed` with a new Quote and the list of changes. A buyer is never charged a total they did not see. ### Payment **The money for one accepted Quote.** A provider such as Stripe, Adyen, Mollie, PAYONE or PayPal moves it. OrbCart starts the Payment, follows it and never guesses its outcome: an unclear answer is `unknown` until the provider decides. ### Order **The record of the agreed purchase.** It keeps the Quote exactly as signed, even when your prices change later. Payment, shipping and refunds move on beside it; the purchase itself never changes. Handing the Order to your ERP is a separate step, retried until your ERP takes it. ### Environment and Channel * An **Environment** is one isolated deployment, such as `test` or `live`, with its own Worker, keys and data. * A **Channel** is one way you sell, such as your German shop. It sets currency, tax, shipping, payment methods and the sites allowed to call. A publishable key belongs to exactly one Channel. ```text Environment "live" ├── Channel "de" EUR · EU VAT · card, PayPal, Klarna └── Channel "ch" CHF · Swiss VAT · card, TWINT ``` ## Why it is shaped this way * **The server decides.** Browsers and agents send references, quantities and choices. Prices, tax, discounts and totals come only from the server, so the SDKs never calculate and a tampered request has nothing to change. * **The Quote freezes the deal.** Consent, Payment and Order all point to one signed Quote. A price that changes during the click leads to a new Quote, never to a different charge. * **Retries are safe.** Every change carries an idempotency key, so a repeat returns the first answer instead of acting twice. The SDKs add the keys for you. * **Every state has one owner.** Each Cart is one Durable Object that holds its Quotes, its Payment and its Order. Lists and dashboards read a copy, and no decision is made from it. * **Your catalog is asked, not copied.** There is no import and no sync. OrbCart asks `offers` when it needs facts and keeps an answer only while it is valid. To end a price at once, [invalidate it](/integrations#end-a-sale-early). ## Next * [How it works](/how-it-works): one purchase through the browser, the Worker, your systems and the provider. * [Security](/security): what each key and token opens, and why the browser cannot set a price. * [Configuration](/configuration): describe your shop in one file. # Configuration One defineCommerce call describes your whole shop. Markets, shipping, payments and your systems. Your commerce Worker is one file. It exports OrbCart's Durable Objects and one `defineCommerce` call: ```ts title="src/worker.ts" import { defineCommerce } from "@orbcart/runtime"; export * from "@orbcart/runtime/objects"; export default defineCommerce({ channels: { de: { currency: "EUR", pricesIncludeTax: true, locale: "de-DE", tax: "eu-standard", shipping: { DE: [{ id: "standard", title: "Standard delivery", price: 495 }] }, payments: { card: "stripe", paypal: "paypal" }, origins: ["https://shop.example.com"], returnUrl: "https://shop.example.com/checkout", }, }, offers, }); ``` That is a working shop. Everything else is optional. OrbCart checks the whole configuration when the Worker starts. A typo or an invalid value fails the deployment. A missing provider key or a `returnUrl` outside your `origins` fails every request with `internal_error`, and the Worker log names the reason. It never fails only the first buyer. ## Top-level options | Option | Required | What it does | | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `channels` | yes | Your markets by name, such as `de`. At least one. | | `offers` | yes | Prices and product facts for the Lines of a Cart. A function, an HTTP endpoint or a Service Binding. [Offers →](/integrations) | | `inventory` | | Check or reserve stock. [Inventory →](/integrations/inventory), in [another system →](/integrations/remote-contracts) | | `deliverOrder` | | Hand every new Order to your ERP. [Inventory →](/integrations/inventory#hand-orders-to-your-erp) | | `fetchOrders` | | `true` when your ERP fetches Orders instead, never with `deliverOrder`. [Fetching →](/integrations/inventory#or-let-your-erp-fetch-orders) | | `events` | | Receivers of Order events by name, such as `mails`: a function, an HTTP endpoint or a Service Binding. [Events →](/integrations/events) | | `orderNumbers` | | Your prefix, such as `{ prefix: "NG" }` for `NG-2026-004711`. | | `identity` | | Accept your shop's login. [Signed-in buyers →](/integrations/identity) | | `extensions` | | Your own typed values on Lines or the Cart. [Extensions →](/commerce/extensions) | ## Channel options A Channel is one market. Its name (`de`, `ch`, `b2b`) appears in keys, Carts and Orders. | Option | Example | What it does | | --------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `currency` | `"EUR"` | ISO 4217 code. One currency per Channel. | | `pricesIncludeTax` | `true` | `true` for gross prices (B2C), `false` for net prices (B2B). | | `locale` | `"de-DE"` | Language of Orders and confirmations. | | `tax` | `"eu-standard"` | Where tax comes from. [Tax →](/integrations/tax) | | `taxRounding` | `"total"` | `"line"` if your ERP rounds per Line. | | `shipping` | `{ DE: [...] }` | Countries you ship to and how. [Shipping →](/commerce/shipping) | | `payments` | `{ card: "stripe" }` | Payment methods and their provider. [Payments →](/payments) | | `discounts` | `[{ id, code, percentage }]` | Built-in discount codes. [Discounts →](/commerce/discounts) | | `contactFields` | `{ phone: "optional" }` | Extra contact fields beyond the email address. | | `abandonedAfterHours` | `24` | How long a Cart may stand still before `cart.abandoned`. [Events →](/integrations/events) | | `expiresAfterDays` | `180` | How long a Cart without an Order may stand still before it ends and is deleted, 1 to 365, longer than `abandonedAfterHours`. [Events →](/integrations/events) | | `origins` | `["https://shop.example.com"]` | Sites whose browsers may call the API with this Channel's key. | | `returnUrl` | `"https://shop.example.com/checkout"` | Where redirect payments send the buyer back. | | `orderPage` | `"https://shop.example.com/orders"` | Your page for an Order; its link is this address and the Order's token. [The order page →](/commerce/order-page) | | `agents` | `{ continueUrl }` | Let AI agents check out. [AI agents →](/agents) | | `legal` | `{ withdrawal_policy: { url, version } }` | Your legal texts, linked at the order button and named in every Quote. [Legal texts ↓](#legal) | ### origins and returnUrl `origins` lists exact origins: scheme, host and port, no path, no trailing slash. In `test`, `localhost` is always allowed, so local development needs no entry. `returnUrl` is required as soon as a method sends the buyer away, such as PayPal, Klarna or iDEAL. It must be a page on one of your `origins`. That page calls [`cart.resume()`](/commerce/quotes#5-return-from-a-redirect). ### contactFields The checkout always asks for an email address. Ask for more only if you need it: ```ts members="ChannelConfig" contactFields: { phone: "optional" }, ``` A shipping method that needs a field makes it required for that method alone. See [`requires`](/commerce/shipping#rule-options). ### legal Your terms, withdrawal policy and privacy policy stay on your own pages. OrbCart links them beside the order button, gives them to AI agents, and names each in every Quote by version and hash, so the buyer's consent records what they saw. ```ts members="ChannelConfig" import withdrawalPolicy from "./legal/widerruf.txt"; // Wrangler bundles .txt and .html as text legal: { terms: { url: "https://nordgrat.de/agb", version: "2026-09-01" }, withdrawal_policy: { url: "https://nordgrat.de/widerruf", version: "2026-06-19", content: withdrawalPolicy, // optional: the hash is then of the wording itself }, privacy_policy: { url: "https://nordgrat.de/datenschutz", version: "2026-05-01" }, }, ``` Change `version` whenever the wording changes and deploy. A Quote the buyer is looking at keeps the version it names; the next one shows `legal_updated` among its changes. Texts of your own, such as `shipping_terms`, work the same way. A `live` Worker refuses every request until each Channel has `withdrawal_policy` and `privacy_policy`, so a missing text shows at your deploy, never at a buyer's order button. `terms` are optional. In `test` nothing is required. ## Several markets Each market is its own Channel with its own currency, prices and rules. Nordgrat sells in Germany and Switzerland: ```ts members="CommerceConfig" channels: { de: { currency: "EUR", pricesIncludeTax: true, locale: "de-DE", tax: "eu-standard", shipping: { DE: [standard], AT: [standard] }, payments: { card: "adyen", paypal: "paypal", klarna: "adyen" }, }, ch: { currency: "CHF", pricesIncludeTax: true, locale: "de-CH", tax: swissVat, shipping: { CH: [swissPost] }, payments: { card: "adyen", twint: "adyen" }, }, }, ``` Your `offers` function receives the Channel's `currency`, so it can return Swiss prices in francs. [`orbcart dev`](/sdks/cli) writes one publishable key per Channel. ## Time budgets Every function you attach gets a time budget. When it runs out, OrbCart aborts the call through `context.signal` and treats your system as unreachable. | Function | Default | | ------------------------------ | ---------- | | `offers`, `inventory.*`, `tax` | 3 seconds | | `deliverOrder` | 30 seconds | | `events` receivers | 10 seconds | Need more time? Attach the function with `run` and `timeoutMs`: ```ts members="CommerceConfig" offers: { run: (lines, context) => pricing.offers(lines, context), timeoutMs: 5_000 }, ``` Pass `context.signal` to your `fetch` calls. An aborted request then stops instead of running in the background. ## Worker settings These live in `wrangler.jsonc` and as Worker secrets, not in `defineCommerce`. | Name | Kind | What it is | | --------------------------------------------------------- | ------ | ----------------------------------------------------------------------- | | `MERCHANT` | var | Your slug, such as `nordgrat`. Every Quote names it. | | `ENVIRONMENT` | var | `live`, `test` or `preview-{name}`. | | `PUBLIC_URL` | var | Optional. The environment's public address; the `source` of its events. | | `ROOT_SECRET` | secret | 32 random bytes as base64url. Every key derives from it. | | `STRIPE_*`, `ADYEN_*`, `MOLLIE_*`, `PAYONE_*`, `PAYPAL_*` | secret | Provider keys. Only for providers you use. [Payments →](/payments) | | `PROJECTION` | D1 | Optional. Powers the Order list. | Never change `ROOT_SECRET` on a running environment. The environment's keys derive from it. `examples/nordgrat/wrangler.jsonc` is a complete, commented reference for the bindings, Durable Objects and the Cron trigger. Everything beyond configuration, from contracts to plugins: [Extend OrbCart](/extend). [Connect your prices and products →](/integrations) # Deployment Deploy a test environment to your Cloudflare account, step by step. OrbCart runs in **your** Cloudflare account. There is no OrbCart server in between. ## What runs where | Piece | What it does | | -------------------------- | ------------------------------------------------------------- | | One Worker per environment | Serves the API and calls your systems. | | Durable Objects | Hold Carts, Payments and Orders. The source of truth. | | D1 (optional) | The Order list for the Admin API and the Explorer. | | Cron trigger | The daily EU VAT import, if you use `eu-standard`. | | Your storefront | Anywhere you like. Only the commerce Worker needs Cloudflare. | `test` and `live` are separate environments with separate keys and data. ## Deploy a test environment Until `orbcart deploy` exists, a test environment is a few Wrangler commands. Run them from your commerce Worker's directory. `examples/nordgrat` is the reference. ### Deploy the Worker ```sh pnpm exec wrangler deploy --name shop-commerce-test ``` The first deploy creates the D1 database for the Order list. Apply its schema, now and after every runtime update: ```sh pnpm exec wrangler d1 migrations apply PROJECTION --remote ``` ### Set the root secret Generated and piped straight in, so it never appears on screen or in your history: ```sh openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n' | pnpm exec wrangler secret put ROOT_SECRET --name shop-commerce-test ``` ### Set your provider keys Only for the providers your Channels use. [Which keys →](/payments) ```sh pnpm exec wrangler secret put STRIPE_SECRET_KEY --name shop-commerce-test pnpm exec wrangler secret put STRIPE_PUBLISHABLE_KEY --name shop-commerce-test pnpm exec wrangler secret put STRIPE_WEBHOOK_SECRET --name shop-commerce-test ``` Until every required secret is set, the Worker answers each request with `internal_error`. `pnpm exec wrangler tail --name shop-commerce-test` shows what's missing. ### Point the webhooks at it | Provider | URL | | -------- | ------------------------------------------------------------------ | | Stripe | `https://shop-commerce-test..workers.dev/webhooks/stripe` | | Adyen | `https://shop-commerce-test..workers.dev/webhooks/adyen` | | PayPal | `https://shop-commerce-test..workers.dev/webhooks/paypal` | ### Issue keys for the storefront ```sh pnpm exec orbcart keys issue --env test --channel de --url https://shop-commerce-test..workers.dev ``` This writes the URL, a publishable key and a secret key to `.orbcart/keys/test-de.env`. Give the storefront what it needs from there. ### Prime EU VAT Only for Channels with `tax: "eu-standard"`: ```sh pnpm exec orbcart tax sync --env test ``` ### Open the Explorer to your team ```sh pnpm exec orbcart access setup --env test --host shop-commerce-test..workers.dev --allow @example.com pnpm exec orbcart members add you@example.com --role owner --env test ``` The Explorer is at `https:///explorer`. [CLI →](/sdks/cli) ## Connect the storefront * Add the storefront's origin to the Channel's `origins` and set `returnUrl` to its return page. Then deploy the Worker again. * Give the storefront `ORBCART_URL` and `ORBCART_PUBLISHABLE_KEY`. Only a server-side part gets `ORBCART_SECRET_KEY`. * With Apple Pay, register the storefront's domain with your provider. [Express →](/payments/express#setup-checklist) ## Updating ```sh pnpm exec wrangler deploy --name shop-commerce-test pnpm exec wrangler d1 migrations apply PROJECTION --remote ``` Secrets stay on the Worker between deploys. Open Carts keep working: a Cart remembers its Channel, so don't rename or remove a Channel that still has Carts. ## Checklist before real money * [ ] Everything on the [security checklist](/security#before-you-go-live) is done: keys, secrets, `origins`, webhooks. * [ ] `orbcart tax status` shows today in `availableOn`, if you use EU VAT. * [ ] `deliverOrder` deduplicates by `order.id`. * [ ] Every Channel names its `withdrawal_policy` and `privacy_policy` in [`legal`](/configuration#legal). Without them a `live` Worker serves nothing. * [ ] Your confirmation email and an [order page](/commerce/order-page) are in place. `orbcart keys issue` only supports `test` today. A guided `live` setup arrives with `orbcart deploy`. Check the [status page](/status) before taking real orders. # Extend OrbCart Every place your code plugs into OrbCart, from one line of configuration to a package of your own, and what cannot be extended yet. OrbCart has no generic hooks such as `beforeQuote`. Every extension point has a name and one job, so it stays clear who decides a price, a tax amount or a stock level ([How it works](/how-it-works)). The points below go from shallow to deep. Most shops stop after the third. | Step | Extension point | Use it for | | ---- | ------------------------------------------------------------ | ------------------------------------------------------------ | | 1 | [Configuration](#configure) | Markets, shipping, discounts, legal texts, contact fields. | | 2 | [Contracts in your Worker](#answer-contracts-in-your-worker) | Prices, stock, tax and the hand-off, from your systems. | | 3 | [Contracts elsewhere](#answer-them-from-another-system) | The same, answered by your middleware or another Worker. | | 4 | [Events](#react-to-events) | Telling your systems what happened: mails, accounting. | | 5 | [Extensions](#add-fields-of-your-own) | Values OrbCart does not know, such as a gift message. | | 6 | [The React kit](#shape-the-storefront) | Look, wording and parts of your own in the checkout. | | 7 | [Packages](#package-it-for-other-shops) | One extension for many shops, such as the DHL pickup finder. | ### Configure A shop is one `defineCommerce` call, and most needs are a declaration, not code. A market is a Channel, free shipping is a discount, freight for a canoe is a shipping rule: ```ts title="src/worker.ts" members="ChannelConfig" shipping: { DE: [ { id: "standard", title: "Standard (DHL)", price: 495, excludes: ["bulky"] }, { id: "freight", title: "Freight", price: 4900, requires: ["phone"], only: ["bulky"] }, ], }, discounts: [ { id: "free-shipping", scope: "shipping", percentage: 100, minimumSubtotal: 5000, methods: ["standard"] }, ], ``` OrbCart checks the whole configuration when the Worker starts, so a typo fails the deploy, never a buyer. [Configuration](/configuration) · [Shipping](/commerce/shipping) · [Discounts](/commerce/discounts) ### Answer contracts in your Worker A contract is one function with one job. OrbCart calls it with every Line at once, a context and a time budget, and checks each answer against the contract. Only `offers` is required: | Contract | Your job | Without it | | --------------------- | ------------------------------------------------ | -------------------------------------- | | `offers` | Prices and facts for the Lines of a Cart. | Required. | | `inventory` | Check stock, or hold it from Quote to Order. | Stock limits nothing. | | `tax` on a Channel | Tax for reduced rates or markets outside the EU. | `"eu-standard"`, the EU's daily rates. | | `deliverOrder` | Hand each paid Order to your ERP. | Your ERP fetches Orders, or nobody. | | `identity` (settings) | Trust your shop's login. | Every buyer is a guest. | ```ts title="src/worker.ts" import { defineCommerce } from "@orbcart/runtime"; export default defineCommerce({ channels, offers: (lines, { currency }) => pricing.offers(lines, { currency }), deliverOrder: async (order, { signal }) => { const salesOrder = await erp.createSalesOrder(order, { idempotencyKey: order.id, signal }); return { reference: salesOrder.number }; }, }); ``` A contract that throws counts as unreachable, and the buyer never sees a guess. One that changes something, such as `deliverOrder`, is called at least once with a stable key, `order.id` here: being idempotent on it is your only duty. [Offers](/integrations) · [Stock & ERP hand-off](/integrations/inventory) · [Tax](/integrations/tax) · [Signed-in buyers](/integrations/identity) · Recipe: [Prices and stock from your ERP](/recipes/erp-prices-and-stock) ### Answer them from another system Any contract can live outside the Worker: `http()` calls an endpoint of yours, `service()` another Worker. The arguments, the time budget and the checks stay the same, and every HTTP call is signed: ```ts title="src/worker.ts" import { defineCommerce, http, service } from "@orbcart/runtime"; export default defineCommerce({ channels, offers: service("PRICES"), deliverOrder: http("https://middleware.nordgrat.de/orders"), }); ``` Your endpoint checks each call with `verifyRequest()` from `@orbcart/admin`, on Workers, Node or Deno. [Contracts in another system](/integrations/remote-contracts) ### React to events Events tell your systems what happened: an Order arose, a payment was captured, a Cart stood still. Each arrives at least once, with an `id` to deduplicate by; over HTTP, it is signed: ```ts title="src/worker.ts" members="CommerceConfig" import { http } from "@orbcart/runtime"; events: { mails: http("https://mail.nordgrat.de/orbcart", { types: ["order.created", "cart.abandoned"] }), ledger: { run: (event, { signal }) => accounting.book(event, { signal }), types: ["payment.*"] }, }, ``` Events carry no personal data, so load the Order with your secret key. OrbCart sends no emails: your mail system sends them from these events. Never hang a duty on an event; handing an Order to the ERP is `deliverOrder`, which retries until it succeeds. [Order events](/integrations/events) · [Sending the buyer's emails](/integrations/buyer-emails) ### Add fields of your own An extension is a typed value on a Line or the Cart, declared once with a schema. OrbCart checks it on every write, keeps it in the Quote and freezes it in the Order: ```ts title="src/commerce.ts" members="CommerceConfig" import { z } from "zod"; extensions: { engraving: { on: "line", schema: z.object({ text: z.string().trim().min(1).max(20) }) }, giftMessage: { on: "cart", schema: z.string().max(200) }, }, ``` A Line extension reaches `offers`, which may charge for it. A Cart extension never changes an amount. [Extensions](/commerce/extensions) · Recipe: [A gift message, end to end](/recipes/gift-message) ### Shape the storefront The React kit hands over control in layers, each one deeper: | Layer | How | | --------- | ------------------------------------------------------------------------------------ | | Look | `--orbcart-*` tokens and `data-part` selectors. Never class names. | | Wording | `labels` and `words`, over the built-in German and English. | | Parts | Slots such as `lineImage`, `paymentHelp` and `deliveryAddress`. | | Markup | `Cart`, `Line`, `Totals`, `DiscountCode` and more, or `useCheckout()` for all of it. | | Framework | The checkout store `cart.checkout()`, which `` runs on. | A slot is your component, drawn where the checkout's own would be. The checkout keeps the draft, the checks and the submission. This delivery address offers to send the parcel to the shop for pickup and keeps the buyer's own address for the bill: ```tsx title="app/delivery-address.tsx" browser import { AddressFields } from "@orbcart/react"; import type { DeliveryAddressProps } from "@orbcart/react"; const store = { company: "Nordgrat Store", line1: "Leopoldstraße 12", line2: "", postalCode: "80802", city: "München", region: "", country: "DE", }; export function DeliveryAddress({ heading, onReplace, ...fields }: DeliveryAddressProps) { return ( <> {heading} ); } ``` Pass it as ``. [React checkout](/sdks/react) · Recipes: [Add to cart](/recipes/add-to-cart), [A checkout without React](/recipes/checkout-without-react) ### Package it for other shops There is no plugin registry. A plugin is a package that fills the seams above: a component for a kit slot, a contract a shop attaches, or a helper for the shop's server. `@orbcart/plugin-dhl` is the model, with one entry point per place it runs: | Entry point | Holds | Runs in | | -------------------------------- | ----------------------------------------------------------- | ------------------ | | `@orbcart/plugin-dhl` | `PickupSearch`, `PickupLocation`: schemas both sides share. | Browser and server | | `@orbcart/plugin-dhl/react` | `DhlPickupProvider` and `DhlDeliveryAddress` for the slot. | Browser | | `@orbcart/plugin-dhl/server` | `findDhlLocations()`, with the shop's DHL key. | The shop's server | | `@orbcart/plugin-dhl/styles.css` | Styles on the kit's `--orbcart-*` tokens. | Browser | Four rules keep a plugin safe to install: * **Depend on public packages only.** The kit and the runtime never depend on a plugin. * **Keep secrets in a server entry point.** The DHL key never reaches a browser bundle. * **Decide nothing OrbCart owns.** A plugin may fill an address; prices, tax and stock stay with OrbCart and your contracts. * **Leave the shop working without it.** Removing the slot removes the plugin. An ERP connector has the same shape: a package that exports `offers`, `inventory` and `deliverOrder`. [DHL pickup points](/commerce/shipping#dhl-packstation-and-pickup-points) ## What you cannot extend yet | You want | Today | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | A payment provider of your own | Not yet. Stripe, Adyen, Mollie, PAYONE and PayPal are built in, and their contract is internal. You map methods to them. | | Shipping prices from a carrier's API | Not yet. Shipping is rules per country, by weight and restriction. | | A rule before the Quote, such as a B2B approval | No hook, by design. A contract for checkout rules comes once shops need the same one. | | Fields of your own inside `` | No slot yet. Ask in the bag, or build the page with `useCheckout()`. | | Extension values from an AI agent | Not over UCP. The agent hands the checkout to the buyer. | | A "€20.10 to free shipping" hint | The Cart does not carry the distance to a threshold yet. | | Emails sent by OrbCart | By design not. Your mail system sends them from events. | The [status page](/status) says what comes next. Start building with the [Recipes](/recipes). # Quickstart Run the reference shop on your machine in about ten minutes. Then buy something in five lines of code. OrbCart is the transaction layer between your storefront and your systems. You keep your product data and your storefront. OrbCart runs the Cart, the Quote, the Payment and the Order. The fastest way to understand it is to run **Nordgrat**, a complete example outdoor shop that ships with the source. OrbCart is pre-release. Packages are not on npm yet and the repository is not public. You need the source checkout, **Node.js 24+**, **pnpm** (the version in `package.json`) and a free **Stripe** test account. Just looking? [Try the live demo](https://demo.orbcart.dev/en). Nothing is charged or shipped. ## Run Nordgrat locally ### Install the workspace From the repository root: ```sh pnpm install ``` ### Add your test keys Create `.dev.vars` in the repository root. It holds the secrets of the commerce Worker. Generate the root secret first. It protects the environment's signing keys: ```sh openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\n' ``` Nordgrat uses three payment providers. Pick the variant that matches the accounts you have: ```dotenv title=".dev.vars" ROOT_SECRET=paste-the-generated-value STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... ``` Then point every method at Stripe in `examples/nordgrat/src/commerce.ts`: ```ts title="examples/nordgrat/src/commerce.ts" worker alternatives members="ChannelConfig" // channels.de payments: { card: "stripe", apple_pay: "stripe", google_pay: "stripe", klarna: "stripe", eps: "stripe" }, // channels.ch payments: { card: "stripe", twint: "stripe" }, ``` ```dotenv title=".dev.vars" ROOT_SECRET=paste-the-generated-value STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... ADYEN_API_KEY=... ADYEN_CLIENT_KEY=test_... ADYEN_MERCHANT_ACCOUNT=... ADYEN_HMAC_KEY=... PAYPAL_CLIENT_ID=... PAYPAL_CLIENT_SECRET=... PAYPAL_WEBHOOK_ID=... ``` [Payments](/payments) explains where each value comes from. The storefront also needs a secret for its own sign-in. Generate a second value with the same command and put it in `apps/demo/.dev.vars`: ```dotenv title="apps/demo/.dev.vars" BETTER_AUTH_SECRET=another-generated-value ``` Both files are ignored by Git. Never put their contents in browser code, URLs or logs. ### Start the shop Run each command in its own terminal, from the repository root: ```sh title="Terminal 1 · the merchant's pricing and ERP" pnpm --filter @orbcart/example-nordgrat systems ``` ```sh title="Terminal 2 · OrbCart" pnpm --filter @orbcart/demo commerce ``` ```sh title="Terminal 3 · the storefront" pnpm --filter @orbcart/demo dev ``` ```sh title="Terminal 4 · Stripe webhooks" stripe listen --latest --all-snapshot --forward-to localhost:8787/webhooks/stripe ``` Copy the signing secret that `stripe listen` prints into `STRIPE_WEBHOOK_SECRET` and restart terminal 2. You only do this once. ### Buy something Open [localhost:5173/en](http://localhost:5173/en). Add a backpack, open the Cart and check out with made-up details and a [test card](/payments/test-payments). Open **Behind the scenes** at any time. It shows every SDK call and the server's answer. `pnpm --filter @orbcart/demo commerce` runs [`orbcart dev`](/sdks/cli). It starts the commerce Worker on port 8787 and writes `ORBCART_URL`, a publishable key per market and the secret key into `apps/demo/.dev.vars`. These are OrbCart keys, not Stripe keys. ## A purchase in five lines This is the whole purchase. `shipTo` is the buyer's address and `payment` is the card field you mounted on the page: ```ts import { createCommerce } from "@orbcart/client"; const commerce = createCommerce({ url: "http://localhost:8787", key: "pk_test_..." }); const cart = await commerce.cart(); await cart.add("BACKPACK-30-GREEN"); const quote = await cart.quote({ shipTo, contact: { email: "anna@example.com" } }); const result = await quote.pay({ payment }); ``` When the card goes through, `result.status` is `"ordered"` and `result.order.number` looks like `NG-2026-004711`. The rest of these docs explain each line, and [every other outcome](/commerce/quotes#4-place-the-order). [How it works](/how-it-works) follows the same purchase through the server. ## Where things live | Path | What you change there | | ---------------------------------------- | ------------------------------------------------------ | | `examples/nordgrat/src/commerce.ts` | Markets, shipping, payment methods and discount codes. | | `examples/nordgrat/src/offers.ts` | How prices and product facts reach OrbCart. | | `examples/nordgrat/src/systems.ts` | Stock checks and the hand-off to the ERP. | | `apps/demo/app/shop/checkout-labels.tsx` | The checkout's wording. | ## Useful commands | Command | What it does | | --------------------------------------------------------- | ------------------------------------------------------ | | `pnpm check` | Types, lint, format, builds and every test. | | `pnpm test` | All tests once. | | `pnpm test:watch` | Tests in watch mode. | | `pnpm --filter @orbcart/example-nordgrat exec vitest run` | Only Nordgrat's purchase and scenario tests. | | `pnpm --filter @orbcart/docs dev` | These docs on [localhost:4200](http://localhost:4200). | ## Troubleshooting **The Worker answers every request with 500.** A provider in `payments` is missing its keys. The Worker log names the missing bindings. Add them or map the method to another provider. **A payment stays "processing".** `stripe listen` is not running, or its secret is not in `.dev.vars`. Without webhooks, OrbCart never learns the final answer. **`origin_not_allowed` in the browser.** Your storefront runs on a host that is not in the Channel's `origins`. Localhost is always allowed in test. ## Next steps # How it works Follow one purchase from cart.add to the Order in your ERP, and see what OrbCart does when something goes wrong. This page follows one purchase through every part of OrbCart. The words it uses, such as Offer, Quote and Order, are explained in [Concepts](/concepts). ```text Browser SDK ──► Worker ──► Cart object ──► your offers · inventory · tax │ ├──► payment provider ◄── its webhook │ └──► outbox ──► deliverOrder · D1 · events ``` ## The parts | Part | Where it runs | What it owns | | ------------------ | --------------------------------------------- | --------------------------------------------------------------------------- | | Client SDK | The browser, an app or an agent | Nothing binding. It sends what the buyer wants and keeps the Cart token. | | Worker | Your Cloudflare account, one per Environment | Nothing. It checks each request and routes it to the right Cart. | | Cart object | One Durable Object per Cart | The Cart, its Quotes, its Payment and its Order. The source of truth. | | Environment object | One Durable Object per Environment | API keys, Order numbers and the key that signs Quotes. | | Your contracts | Your code, another Worker or an HTTP endpoint | Prices, stock, tax and the Order hand-off. [Offers →](/integrations) | | Payment provider | Stripe, Adyen, Mollie, PAYONE or PayPal | The money. | | D1 | Your Cloudflare account, optional | A copy of the Order list without personal data, for lists and the Explorer. | ## One purchase, step by step 1. **The buyer adds a Line.** `cart.add("BACKPACK-30-GREEN")` runs in the browser. The first call creates the Cart with the Channel's publishable key and gets back a Cart token. Every later call carries that token, and every change carries a fresh `Idempotency-Key`. 2. **The Worker checks and routes.** It reads the credential from the `Authorization` header, checks the calling site against the Channel's `origins` and the body against the API schema. The Cart's ID is part of its token, so the request goes straight to that Cart's Durable Object, which checks the token itself. 3. **The Cart asks your systems.** The Cart object calls your `offers` function for the new Line and answers with the whole Cart: titles and prices, a non-binding `estimate`, and what is still `missing`. 4. **The buyer asks for a Quote.** `cart.quote()` sends the address and email. The Cart asks `offers` for binding prices, reserves stock through `inventory` if you implement it, and gets tax from the EU's rates or your `tax` function. The core calculates every amount; the runtime signs the result with the Environment's key and stores it. The Quote lasts at most 30 minutes. 5. **The buyer pays.** `quote.pay({ payment })` runs on the order button. Card details go from the provider's field straight to the provider. The Cart checks that the Quote still holds, records the buyer's consent and a new Payment in one write, and keeps the Cart still while the Payment runs. It starts the Payment with an idempotency key derived from the Payment's ID. 6. **The provider decides.** Its answer arrives with the response, by a signed webhook, or when the Cart asks again. Whichever comes first records the decision; the others find it. A 3-D Secure step in between is `requires_action`, which the SDK runs. 7. **The Order arises.** In the same Cart object: an Order number from the Environment object, then the Order with the signed Quote exactly as it was. `pay()` answers `ordered`. 8. **The follow-up steps run.** The write that creates the Order also puts its follow-up steps in the Cart's outbox: capture the money, commit the reserved stock, hand the Order to your ERP through `deliverOrder`, update the Order list in D1 and send `order.created` to your [event receivers](/integrations/events). An alarm of the Durable Object runs each step until it succeeds. If your ERP would rather poll than receive, leave out `deliverOrder` and [let it fetch Orders](/integrations/inventory#or-let-your-erp-fetch-orders). ## When something goes wrong | What happens | What OrbCart does | What the buyer sees | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | A price, stock or shipping changes before payment | The acceptance answers `quote_changed` with a new Quote and the list of changes. Nothing is charged. | The new total, and the order button again. | | The provider does not answer in time | The Payment becomes `unknown`, never `failed`. The Cart asks again after 30 seconds, then 1, 2, 4, 8 and 16 minutes, and listens for the webhook. Still unclear when the payment window ends, 30 minutes after consent by default, it cancels the Payment with the provider. | A calm waiting page that becomes the Order once decided. | | A double click, a reload or a second tab | A click on a Quote whose Payment runs is answered with that Payment. A repeated request with the same key replays the first answer. | One Payment and one Order. | | Your `offers` or `tax` function fails | No Quote. The answer is `provider_unavailable`, which can be retried. OrbCart never invents a price. | A message to try again. | | Your ERP is down | `deliverOrder` gets the same Order again, first after a second, then up to once an hour, so your ERP deduplicates by `order.id`. After 30 minutes the Order shows `handoff_failed`; the retries go on. | Nothing. Their Order exists. | | The Worker restarts mid-purchase | The decision is stored before anything follows from it. The next alarm continues where the last one stopped. | Nothing. | A payment the provider confirms after the window closed is canceled, or refunded if it was already captured. A Payment the provider reports as still processing, as with some asynchronous methods, is never given up: OrbCart waits for its webhook. ## Where the data lives * **The Cart object is the truth.** It holds the Cart, its Quotes, the Payment and the Order with the buyer's details. * **D1 is a copy.** The Order list behind `admin.orders.list()` and the Explorer can lag a few seconds, and nothing is decided from it. `admin.orders.get()` reads the Cart object itself. * **No archive yet.** A locked R2 archive of every Order is planned, not built. Today the Order lives in its Cart object only. Which personal data is kept where, and when it is deleted: [Security](/security#personal-data). ## Next * [Security](/security): what each key and token opens, and what to check before going live. * [Hand Orders to your ERP](/integrations/inventory#hand-orders-to-your-erp): the contract behind step 8. * [Quotes & payments](/commerce/quotes): every outcome of `pay()` in code. # Overview The checkout without the catalog. OrbCart runs Cart, Quote, Payment and Order for your storefront and AI agents, while your products, prices and stock stay in your systems. # Sending the buyer's emails Confirm Orders, tell buyers a payment did not go through and follow up on a Cart, from your own mail system and OrbCart's events. **OrbCart does not send emails.** It tells your systems what is due, and you send the mail from the system you already have, in your design and under your sender. Every email a buyer expects starts with an [event](/integrations/events): | Email | Event | You load | | ------------------------------- | ------------------------------------ | ---------------------------------------- | | Order confirmation | `order.created` | the Order | | Your payment did not go through | `payment.failed`, `payment.canceled` | the Cart, or the Order once there is one | | You left something in your Cart | `cart.abandoned` | the Cart | The events carry no personal data: no email address, no name. You load them with your secret key, which is also what keeps a late or repeated event harmless: you always read the current state. All three recipes run in an endpoint of yours that takes events over HTTP. Attach it once, for the types you need: ```ts title="src/worker.ts" import { defineCommerce, http } from "@orbcart/runtime"; export default defineCommerce({ channels, offers, events: { mails: http("https://mail.nordgrat.de/orbcart", { types: ["order.created", "payment.failed", "payment.canceled", "cart.abandoned"], }), }, }); ``` And take them in the endpoint, after verifying them: ```ts title="mail.nordgrat.de/orbcart.ts" import { createAdmin, verifyEvent } from "@orbcart/admin"; import type { EventOf } from "@orbcart/admin"; const admin = createAdmin({ url: env.ORBCART_URL, key: env.ORBCART_SECRET_KEY }); export async function handle(request: Request): Promise { const event = await verifyEvent(request, { url: env.ORBCART_URL }); switch (event.type) { case "order.created": await confirmOrder(event); break; case "payment.failed": case "payment.canceled": await paymentDidNotGoThrough(event); break; case "cart.abandoned": await cartLeftBehind(event); break; } return new Response(null, { status: 204 }); } ``` Any 2xx **acknowledges** the event, so return it only once the mail is safely yours: sent, or stored in your own queue. Anything else is retried for 72 hours, always with the same `id`, so **deduplicate by `event.id`**: pass it to your mail provider as its idempotency key, or keep the ids you handled. A type you do not handle is acknowledged too. ## Order confirmation `order.created` arrives once the buyer's payment is secured and the Order exists. Load the Order and send the confirmation: ```ts continues async function confirmOrder(event: EventOf<"order.created">) { const order = await admin.orders.get(event.subject); await mail.send({ idempotencyKey: event.id, to: order.contact.email, locale: order.locale, // the buyer's language, such as "de-DE" template: "order-confirmation", data: { number: order.number, lines: order.quote.lines.map((line) => ({ title: line.offer.title, quantity: line.quantity, })), total: order.quote.totals.total, shipTo: order.quote.shipTo, orderUrl: order.url, // the buyer's link to their order page }, }); } ``` Your endpoint's acknowledgement is the only receipt OrbCart looks for: there is no call to make besides returning 2xx. Whether the mail reaches the buyer is your mail system's affair. An email is a durable medium, so it should contain your terms and the right of withdrawal as shown to the buyer, not only a link. `order.quote.legal` names each text the buyer saw by type and `version`: attach your texts in exactly that version. ## Your payment did not go through A payment can end without an Order: the card was declined, 3-D Secure failed, nobody finished a redirect before the payment window of 30 minutes ended. OrbCart tells it with `payment.failed` or `payment.canceled`, with a `reason` code and no provider text: | Event | `data.reason` | | ------------------ | ----------------------------------------------------------------------------- | | `payment.failed` | `declined`, `authentication_failed`, `expired` | | `payment.canceled` | `payment_window_elapsed`, `amount_mismatch`; none if the provider canceled it | `cart_changed` and `payment_method_changed` also cancel a Payment, but the buyer did that themselves a moment ago, so mail nobody for them. And a buyer whose card was declined usually sees it on the page at once and tries again. So wait a little, and mail only the buyer who did not come back: ```ts continues async function paymentDidNotGoThrough(event: EventOf<"payment.failed" | "payment.canceled">) { const { reason } = event.data; if (reason === "cart_changed" || reason === "payment_method_changed") return; // Hand the job back to your own queue, with `event.id` as its key, to run in 15 minutes. await jobs.later({ minutes: 15, key: event.id, job: () => mailUnpaidBuyer(event) }); } async function mailUnpaidBuyer(event: EventOf<"payment.failed" | "payment.canceled">) { const { amount, method } = event.data; // After the Order, a Payment that ends later, such as a bank debit, belongs to the Order. const to = event.subject.startsWith("ord_") ? (await admin.orders.get(event.subject)).contact.email : await emailIfStillUnpaid(event.subject); if (!to) return; await mail.send({ idempotencyKey: event.id, to, template: "payment-did-not-go-through", data: { amount, method }, }); } async function emailIfStillUnpaid(cartId: string) { const cart = await admin.carts.get(cartId); // The buyer bought after all, or is paying again right now: nothing to say. return cart.lifecycle.status === "open" ? cart.contact?.email : undefined; } ``` Before the Order, `event.subject` is the Cart. A Payment exists only for a Quote, and a Quote needs the buyer's email, so a Cart that reached its Payment has one. `admin.carts.get` returns it as `contact.email`, together with the lines and `lifecycle`: `open` after the failure, `locked` while another attempt runs, `ordered` once it worked. ### What is missing: a way back into the Cart The mail should bring the buyer back to what they chose. **OrbCart has no return link for a Cart yet.** The Cart's token is a secret of the buyer's browser and must not travel in a URL, and the Admin API cannot mint another. What works today is a link to your own cart or checkout page: * In the buyer's own browser it finds the Cart again, as it always does. * For a signed-in buyer it finds their active Cart on any device ([identity](/integrations/identity)). * A guest on another device starts a new Cart: the mail can name the goods, not restore them. A short-lived return link for the Cart, like the Order's page token, is open in the plan (Paper 10 §7). Until it exists, do not promise the buyer more than your page can keep. ## You left something in your Cart `cart.abandoned` arrives once a Cart with lines stood still for 24 hours and has no Order. Set another period per Channel with [`abandonedAfterHours`](/configuration), from 1 hour to 30 days. The Cart stays as it is, and a buyer who changes it starts the next spell. ```ts continues async function cartLeftBehind(event: EventOf<"cart.abandoned">) { const cart = await admin.carts.get(event.subject); const email = cart.contact?.email; // No email yet, the buyer ordered meanwhile, or never agreed to hear from you. if (!email || cart.lifecycle.status !== "open" || !(await agreedToMarketing(email))) return; await mail.send({ idempotencyKey: event.id, to: email, template: "cart-left-behind", data: { lines: cart.lines.map((line) => ({ title: line.offer?.title, image: line.offer?.image, quantity: line.quantity, })), cartUrl: "https://nordgrat.de/cart", }, }); } ``` Four things to know: * **Not every Cart has an email.** The checkout asks for it at the contact step, so a Cart that was left before has none, and you cannot write to that buyer. * **A reminder is marketing in most countries.** It needs the buyer's consent, and OrbCart holds none for a Cart: check your own list of buyers who agreed (`agreedToMarketing` above). * **Prices are what the buyer saw.** Reading a Cart as a merchant does not borrow offers again and is no change to the Cart, so the lines show the last price they had. Ask your own system for current prices if the mail should quote them. * **The return link** has the same gap as above. ## What is not there yet * A **return link** into a Cart without its token, see above. * **Monitoring of the confirmation.** OrbCart will raise an issue when your endpoint has not taken `order.created` for 30 minutes. Until then, watch `admin.receivers.list()` for a growing `backlog`. * The mails around **withdrawal and returns**: `order.withdrawn` and `order.returned` do not exist yet. # Order events Tell your systems what happened to an Order and to the purchase before it, such as a confirmation to send or a payment to book. An event is a fact: the Order was created, a payment was captured, a payment ended without an Order, a Cart stood still. Attach a function, an HTTP endpoint or another Worker under `events` and every one of them reaches it, at least once. It is optional. Without `events` nothing is delivered, and the events stay in the [event list](#fetch-instead-of-receive). OrbCart sends no emails itself. The events tell your mail system what is due: [Sending the buyer's emails →](/integrations/buyer-emails). ## Receive events ```ts title="src/worker.ts" export default defineCommerce({ channels, offers, events: { mails: async (event) => { await mailQueue.add(event.id, event); // take it, store it, return }, ledger: { run: (event, { signal }) => accounting.book(event, { signal }), types: ["payment.captured", "payment.refunded"], timeoutMs: 5_000, // at most 10 seconds, which is also the default }, }, }); ``` Each receiver has a name that stays the same across deployments. It identifies the receiver in the delivery protocol. `types` limits what it gets: one type, or every type below a prefix like `order.*`. Without `types` a receiver gets every event. ## Receive events over HTTP An endpoint of yours, such as a middleware in front of your ERP, takes events as signed requests. Attach it with `http()`: ```ts title="src/worker.ts" import { defineCommerce, http, service } from "@orbcart/runtime"; export default defineCommerce({ channels, offers, events: { erp: http((vars) => vars.get("MIDDLEWARE_URL"), { types: ["order.*", "payment.*"] }), audit: http("https://audit.nordgrat.de/events", { previews: true, timeoutMs: 5_000 }), mails: service("MAILER", { types: ["order.created"] }), }, }); ``` The address is a string, or a function that reads it from the variables and secrets of the deployment, so each environment calls its own endpoint. If the function returns nothing, or an address that is not allowed, the attempt fails as `invalid_target` and is retried like any other. Only `https` on port 443 is allowed, with a name instead of an IP address, no user name or password, no private name such as `localhost`, `.internal` or `.local`, and never this environment's own address. Under `wrangler dev`, `http` and `localhost` work too. Each delivery is a `POST` of the event as a CloudEvents JSON body (`Content-Type: application/cloudevents+json`). Acknowledge with **any 2xx within 10 seconds**. A redirect, any other status or a timeout counts as failed, and OrbCart never follows a redirect. Answer `429` or `503` with `Retry-After` to ask for time, up to an hour. Answer **`410 Gone`** to say you want no more: the receiver is paused until someone resumes it. OrbCart stores the status and the first kilobyte of every answer in the delivery protocol, so keep personal data out of your error bodies. ### Verify a delivery Every delivery is signed with the environment's key. There is no secret to share: you verify against the keys the environment publishes. In the endpoint: ```ts title="middleware/events.ts" import { verifyEvent } from "@orbcart/admin"; export async function handle(request: Request): Promise { const event = await verifyEvent(request, { url: "https://commerce.nordgrat.de" }); if (event.type === "order.created") { await erp.register(event.subject, { idempotencyKey: event.id }); } return new Response(null, { status: 204 }); } ``` `verifyEvent` throws an `EventVerificationError` with a `code` when the delivery is not what the environment signed, so answer those with a 4xx and do not process the event. It runs on Workers, Node and Deno. It reads the request from a copy, so the body stays readable, and it fetches `/.well-known/jwks.json` of `url` once, caches the keys, and asks again for an unknown key at most once a minute. If your endpoint sits behind a proxy that rewrites the `Host` header, pass the host OrbCart sent to as `authority`: `verifyEvent(request, { url, authority: "middleware.nordgrat.de" })`. | Code | Means | | ----------------------- | ------------------------------------------------------------------------ | | `signature_missing` | No signature, or one that cannot be read. | | `wrong_purpose` | Signed for something other than an event. | | `unsupported_algorithm` | Not `ecdsa-p256-sha256`. | | `missing_component` | The signature leaves out something it must cover. | | `outside_window` | Made more than 5 minutes ago, expired, or from more than a minute ahead. | | `digest_mismatch` | The body is not the one that was signed. | | `unknown_key` | The environment publishes no such key, also after asking again. | | `keys_unavailable` | The keys could not be fetched and none is known. | | `signature_invalid` | The signature does not match: another body digest, path, host or key. | | `event_invalid` | The body is no event, or not the one the `Webhook-Id` names. | For another language, any library for [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) works. What OrbCart signs: * `ecdsa-p256-sha256` with the key from `/.well-known/jwks.json`; `keyid` is its `kid` and `tag` is `orbcart-event`. * The covered components are `@method`, `@authority`, `@path`, `@query`, `content-type`, `content-digest`, `webhook-id` and `webhook-timestamp`. A delivery cannot be redirected to another endpoint, or to the same one with other query parameters. `Content-Digest` is the SHA-256 of the body ([RFC 9530](https://www.rfc-editor.org/rfc/rfc9530)). * `Webhook-Id` is the event's `id`, the same on every attempt. `Webhook-Timestamp` and `created` are the time of **this attempt**: every attempt is signed anew, and `expires` is five minutes after. * A recorded request is worthless after that window. Inside it, the `id` protects you: a replayed request carries the id you already deduplicate by. Each environment signs with its own key, so a middleware that verifies against `live` rejects an event from `test` even if it reaches it by mistake. ## Receive events in another Worker `service("MAILER")` delivers through a Service Binding of the same Worker. The event arrives as `POST /events` with the CloudEvents JSON body, without a signature: only your account can reach a binding. Acknowledge with any 2xx, like an HTTP endpoint. ## What an event looks like An event is a [CloudEvents 1.0](https://cloudevents.io) event: ```json { "specversion": "1.0", "id": "evt_01J8Z3W6Q4XB7N2M5K9RTV0HCE_3", "type": "order.created", "source": "https://commerce.nordgrat.de", "subject": "ord_01J8Z3W6Q4XB7N2M5K9RTV0HCE", "time": "2026-10-02T10:15:02.000Z", "environment": "live", "channel": "de", "dataschema": "https://commerce.nordgrat.de/v1/openapi.json#/webhooks/order.created", "data": { "order": { "id": "ord_01J8Z3W6Q4XB7N2M5K9RTV0HCE", "number": "NG-2026-004711", "version": 3 }, "total": { "amount": 8990, "currency": "EUR" }, "lines": [{ "line": "l_1", "reference": "BACKPACK-30-GREEN", "quantity": 1 }] } } ``` `dataschema` points into your environment's own [OpenAPI description](/api-reference#endpoints), which holds every type under `webhooks`; it is there when the environment knows its public address. An event carries **no personal data**: no names, emails, addresses, phone numbers, tracking numbers, notes, reasons in words or extension values. It does carry what you report yourself as `carrier` and `reference` (up to 100 and 200 characters), so keep personal data out of those. Load the Order for the rest, with your secret key: ```ts const order = await admin.orders.get(event.subject); // for an event whose subject is an Order ``` That also gives you the current state, so a late event never works with stale data. ### What `subject` names `subject` is the Order whenever the event concerns an Order that exists, and the Cart before. An Order and its Cart share one key, so `ord_01J8…` and `cart_01J8…` belong together. The `data` always names what exists: `data.order.id` for an Order, `data.cart` for a Cart, and the Payment or Quote it is about. Events of one purchase are numbered together, in the order OrbCart told them: the number at the end of `id` rises, and it has gaps. ### The catalog | Type | When | `data` | | ----------------------- | -------------------------------------------------------- | ---------------------------------------------------------------- | | `quote.created` | A Quote was made. The same Quote again is no new event. | `cart`, `quote`, `total` | | `quote.accepted` | The buyer agreed to a Quote, and its Payment begins. | `cart`, `quote`, `payment`, `total` | | `payment.authorized` | The amount is reserved. | `cart`, `payment`, `quote`, `amount`, `method`, `provider` | | `payment.pending_funds` | A slower method was accepted, the money comes later. | as `payment.authorized` | | `payment.failed` | A Payment ended without money, or a capture was refused. | as `payment.authorized`, `reason`, `order` once there is one | | `payment.canceled` | A Payment was canceled, by the buyer or by OrbCart. | as `payment.authorized`, `reason` when OrbCart canceled, `order` | | `payment.captured` | Money was collected, also in part. | `order`, `payment`, `amount`, `provider` | | `payment.refunded` | A refund succeeded. | `order`, `payment`, `refund`, `amount`, `lines` | | `payment.refund_failed` | A refund could not be settled. | `order`, `payment`, `refund`, `amount` | | `order.created` | The Order arose. | `order`, `total`, `lines`, `journey` | | `order.fulfilled` | Units were shipped, picked up or delivered. | `order`, `lines`, `carrier`, `reference` | | `order.handed_off` | Your ERP took the Order. | `order`, `reference` | | `order.issue_opened` | Something needs a person, such as `handoff_failed`. | `order`, `issue` | | `order.issue_resolved` | It is resolved. | `order`, `issue`, `resolvedBy` when one activity did | | `cart.abandoned` | A Cart with lines stood still and has no Order. | `cart`, `lines`, `journey` | | `cart.canceled` | A Cart without an Order ended and is deleted. | `cart`, `reason` | Each Payment event also names `providerReference` once the provider has one. Never the provider's own words: `reason` is a code. * **`payment.failed`** is told once for a Payment: when the Payment itself failed (`reason` is `declined`, `authentication_failed` or `expired`), or when the provider refused to capture money an Order is owed. With today's providers a Payment that can have its capture refused is authorized or captured, and neither can fail afterwards, so the two do not tell the same Payment twice. * **`payment.canceled`** has a `reason` when OrbCart canceled the attempt: `payment_window_elapsed` (nobody finished paying), `cart_changed` or `payment_method_changed` (the buyer changed their mind while an action was open), `amount_mismatch`. A cancellation that came from the provider has none. * **`order.issue_opened`** and **`order.issue_resolved`** are told once for each change of an Order's issues. `issue` is its `type` and what it is about (`payment`, `refund`). An Order that arose before issues told keeps the ones it has quietly. * **`cart.abandoned`** comes once per spell of standing still: when a Cart with lines had no change for the Channel's idle period, 24 hours unless you set `abandonedAfterHours` (1 to 720). The Cart itself stays as it is. A buyer who changes it starts the next spell. Reading a Cart as a merchant is no change. Carts without lines and Carts with an Order are never told. * **`cart.canceled`** comes once, when a Cart without an Order ends. `reason` is `expired` when it had no change for the Channel's inactivity period, 180 days unless you set `expiresAfterDays`, or `discarded` when the agent whose checkout it was canceled it. The Cart is then deleted with everything in it, so loading it answers `cart_not_found`; its buyer's next visit starts a fresh Cart. A Cart whose Order arose never ends this way. * **`journey`** on `order.created` and `cart.abandoned` tells how the purchase went, without anything the buyer typed: `channel`, `origin` (`storefront`, or `agent` for an agent platform's checkout), `steps` (`lines`, `quote`, `address`, `shipping`, `payment`, `order`, each with the time it was first reached; a step never reached is absent), `method` (`id` of the latest Payment attempt, and whether it ran as an `express` checkout) and `friction`, counts of `quoteChanged` (the buyer had to agree again), `declined`, `challenged` (a Payment asked the buyer to act at the provider, 3-D Secure for a card), `unknown` and `recovered` (what OrbCart cleared up without the buyer). On `order.created`, `resumed` is `true` when the Cart had been told as abandoned before. The last step on `cart.abandoned` is where the buyer stopped. A Cart stored before journeys were kept has none on `cart.abandoned` until it changes. The list grows. **Ignore a type you do not know.** ## What OrbCart promises * **At least once.** Your function may be called again with the same event. The `id` is always the same, so deduplicate by it. That is your only duty. * **Acknowledged by returning.** Return without throwing within 10 seconds. Anything else counts as failed. Take the event, store it and return; do the work afterwards. * **Retried for 72 hours.** After 5 seconds, 1, 5 and 30 minutes, then every hour. After 72 hours the event is `failed` in the event list and stays there for 30 days. * **A receiver that stays away is probed, not hammered.** Once nothing succeeded for an hour there is one try at a time, an hour apart, with an event that is due. An event your receiver rejects on every try never holds up the others. When it works again, the backlog follows in order, six at a time. * **No order guaranteed.** Events of an Order usually arrive in order, but after a retry not necessarily. `data.order.version` rises with every change, so you can tell which is newer. * **Events are not duties.** Handing an Order to your ERP is [`deliverOrder`](/integrations/inventory#hand-orders-to-your-erp), which retries until it succeeds. An event can be ignored, so do not hang a duty on one. ## Fetch instead of receive A system that cannot receive calls reads the list. Newest first, seconds behind, with how far each receiver's delivery came: ```ts const page = await admin.events.list({ type: ["order.*"], limit: 50 }); const more = page.next && (await admin.events.list({ type: ["order.*"], after: page.next })); page.data[0].deliveries; // [{ receiver: "mails", status: "delivered", attempts: 1 }] ``` A delivery shows `pending` with `attempts: 0` while OrbCart is still trying. Its status and the number of attempts are written when the delivery ends: `delivered`, or `failed` after 72 hours. | Filter | Example | Lists events… | | --------- | ------------------------------- | --------------------------- | | `type` | `["order.*", "payment.failed"]` | of these types or prefixes. | | `subject` | `"ord_01J8…"` | of one Order or Cart. | | `channel` | `["de"]` | in these Channels. | | `since` | `"2026-10-01T00:00:00Z"` | that happened since. | The cursor follows the order in which events reached the list, so an event that arrives late is never skipped. Events stay in the list for 30 days. `source` is the environment's `PUBLIC_URL`, such as `https://commerce.nordgrat.de`. An environment without that variable names itself with `urn:orbcart:{merchant}:{environment}`. ## Repair a receiver Everything an operator does in the Explorer is also in the Admin SDK. Owners and developers may change receivers; everyone on the team may read them. ```ts const { data } = await admin.receivers.list(); // [{ name: "erp", kind: "http", state: "failing", backlog: 41, lastSuccess: "2026-10-02T08:14:00.000Z" }] await admin.events.resend("evt_01J8Z3W6Q4XB7N2M5K9RTV0HCE_3", { receiver: "erp" }); // one event again await admin.events.replay({ since: "2026-10-01T00:00:00Z", receiver: "erp" }); // everything since await admin.receivers.pause("erp"); // during an ERP migration; events wait, their 72 hours run on await admin.receivers.resume("erp"); // delivers the backlog at once ``` * `state` is `active`, `failing` (an hour without an acknowledgement, so only a probe goes out) or `paused`. `backlog` counts events not yet acknowledged and not yet given up on. * **Resend and replay** hand the same events, with the same `id`, to the receiver again, each with 72 hours from now. Without `receiver`, every receiver that wants the type gets them. A replay hands over at most 500 events per call; while the answer has `next`, pass it as `after`. Events stay available for 30 days. * A receiver that is **paused** gets no attempts until it is resumed. `410 Gone` from an HTTP receiver pauses it the same way. ## In tests and previews Functions and Service Bindings are called in every environment. **A preview environment (`preview-…`) calls no HTTP endpoint** unless the receiver says `previews: true`, because a preview isolates state but not outgoing calls: without the rule, every preview of your code would call the middleware of production. The events of a preview are still kept and listed. Events of a test environment name it: `environment` is `"test"` and `source` is its own address, so a test Order is never taken for a real one, and its signature is made with the test key. # Signed-in buyers Use your shop's existing login. OrbCart keeps no accounts; it trusts your ID tokens. Buyers sign in with **your** identity system: Better Auth, Auth0, Clerk, Keycloak, your own. OrbCart checks the ID token and keeps only its issuer and subject. No passwords, no profiles, no second user database. What signing in gives the buyer: * **One Cart on every device.** Lines from a guest Cart join their own Cart. * **Their own prices.** Your `offers` function sees `context.buyer`. * **Their Orders,** including earlier guest Orders to the same verified email. ## 1. Trust your identity system ```ts title="src/worker.ts" worker export default defineCommerce({ channels, offers, identity: { issuers: [{ issuer: "https://auth.example.com", audience: "shop" }], }, }); ``` OrbCart finds the keys through OpenID discovery. If your issuer has no discovery document, name the key set yourself: ```ts worker value="Issuer" { issuer: "https://shop.example.com", audience: "shop", jwks: "https://shop.example.com/api/auth/jwks" } ``` ## 2. Send the token from the storefront Give `createCommerce` a function that returns the current ID token, or `undefined` while nobody is signed in. It is asked before every request: ```ts const commerce = createCommerce({ url, key, buyer: () => auth.getIdToken(), }); ``` When OrbCart rejects a token, it asks once more with that token as `refused` and retries the request with the fresh one. If your identity system caches tokens, renew there: ```ts const commerce = createCommerce({ url, key, buyer: (asked) => auth.getIdToken({ forceRefresh: asked?.refused !== undefined }), }); ``` ## 3. Claim the Cart after sign-in Right after the buyer signs in, call `identify()`. Continue with the Cart it returns: ```ts const { access, merged } = await cart.identify(); saveCartToken(access.token); if (merged) showToast("We added the items from your other device."); ``` `access` is usually the same Cart. It is a different one when the buyer already had a Cart elsewhere that took over these Lines. ## Show the buyer's Orders ```ts const page = await commerce.orders(); const older = page.next ? await commerce.orders({ after: page.next }) : undefined; ``` Newest first, in this Channel only. ## Prices per customer group ```ts worker export const offers: OffersContract = async (lines, { buyer, currency }) => { const group = buyer ? await crm.groupOf(buyer.subject) : "retail"; return pricing.offers(lines, { group, currency }); }; ``` `buyer` is `{ issuer, subject }`. It is never a profile, so your CRM stays the source of names and groups. ## Sign-in inside the React checkout `@orbcart/client/better-auth` connects Better Auth to both the checkout and the client. A known email address is offered a sign-in right in the checkout: ```tsx import { betterAuthIdentity } from "@orbcart/client/better-auth"; const identity = betterAuthIdentity({ client: authClient, knows: (email) => fetch("/api/has-account", { method: "POST", body: email }).then((r) => r.json()), }); const commerce = createCommerce({ url, key, buyer: identity.buyer }); setCart(access)} />; ``` Your Better Auth server needs the `emailOTP` and `jwt` plugins. Rate-limit the `knows` endpoint and protect it with a bot check, such as Turnstile. ## Good to know * **Signing out ends the Cart on that device.** A buyer's Cart only opens next to their token. The next `add()` starts a fresh guest Cart. * **Someone else's Cart on a shared computer?** `identify()` notices and gives the buyer their own. * **Discount limits per buyer** count signed-in buyers by identity and guests by email. [Discounts →](/commerce/discounts) [Build the Cart →](/commerce) # Offers The one function OrbCart needs from you. Your systems know the goods; offers tells OrbCart. OrbCart has no product database. Whenever it needs a price, it asks your `offers` function. You pass Lines in, you get Offers back. No catalog import, no sync. ## The smallest offers function ```ts title="src/offers.ts" import type { OffersContract } from "@orbcart/runtime"; export const offers: OffersContract = async (lines, { currency }) => lines.map((line) => ({ reference: line.reference, title: "Trail backpack · 30 L · Green", price: { amount: 8990, currency }, taxCategory: "standard", shipping: { weightInGrams: 950 }, })); ``` Pass it to `defineCommerce({ channels, offers })`. Done. ## Connect your real systems Ask your systems once per call, for all Lines together. Never one request per Line: ```ts title="src/offers.ts" export const offers: OffersContract = async (lines, { currency, signal }) => { const references = [...new Set(lines.map((line) => line.reference))]; const products = await erp.products(references, { currency, signal }); return products.map((product) => ({ reference: product.sku, title: product.name, price: { amount: product.priceInCents, currency }, taxCategory: product.vatClass, shipping: { weightInGrams: product.weight }, })); }; ``` A reference you do not return is unavailable. The buyer sees it as such and cannot buy it. ## What OrbCart passes in Each Line has a `reference`, a `quantity` and its `configuration` (the values of your [line extensions](/commerce/extensions)). The second argument tells you the context: | Field | Example | Use it for | | ---------- | ----------------------- | --------------------------------------------------------------------- | | `currency` | `"EUR"` | The price list to read. | | `channel` | `"de"` | Market-specific assortments. | | `country` | `"AT"` | Country prices, once an address is known. | | `buyer` | `{ issuer, subject }` | Customer-group prices for [signed-in buyers](/integrations/identity). | | `codes` | `["VIP20"]` | Codes your own pricing redeems. [Discounts →](/commerce/discounts) | | `purpose` | `"display"` / `"quote"` | `quote` is the binding calculation before payment. | | `cartId` | `"cart_01J..."` | Cart-scoped pricing or logging. | | `signal` | `AbortSignal` | Pass it to `fetch`. It aborts after 3 seconds. | ## What you return | Field | Required | Example | | -------------- | -------- | -------------------------------------------------------------------------------------------- | | `reference` | yes | `"BACKPACK-30-GREEN"` | | `title` | yes | `"Trail backpack · 30 L · Green"`. Name the variant. | | `price` | yes | `{ amount: 8990, currency: "EUR" }`. Unit price, gross or net like the Channel. | | `taxCategory` | yes | `"standard"`, `"reduced"` | | `shipping` | | `{ weightInGrams: 950, restrictions: ["lithium_battery"] }`. Leave it out for digital goods. | | `image` | | `"https://cdn.example.com/backpack-400.webp"`. A picture of at most 400 px, see below. | | `deliveryTime` | | `"Ships in 3 weeks"`, when the goods take longer than shipping says. See below. | | `compareAt` | | The price before a sale, for display. | | `promotion` | | `"−20 % Summer sale"`, for display. | | `tags` | | `["summer-sale"]`, to [invalidate](#end-a-sale-early) later. | | `limits` | | `{ min: 1, max: 5 }` units per Line. | | `expiresAt` | | RFC 3339. Default: 15 minutes from now. | | `components` | | A bundle's parts, so one part can be refunded alone. | | `withdrawal` | | `{ excluded: "personalized" }` for goods without a right of withdrawal. | | `attributes` | | `{ color: "green" }`, kept in the Order. | | `appliedCodes` | | Codes your pricing redeemed for this Offer. | ### Send a small picture `image` is the URL of a picture of the goods. It is optional, and it is the one place a merchant sets it: the Explorer shows it beside every line of a cart and an order, and storefronts read it as `line.offer.image`. * **At most 400 px on the longer side.** OrbCart never scales a picture and shows it at up to 200 px, so 400 px covers high-density screens and a larger file only costs bandwidth. A WebP of 5-15 KB is typical. * **An absolute `http` or `https` URL.** Anything else is ignored: a broken picture never stops a sale. * **Keep the file where it is.** The Quote and the Order keep the URL, not the file, so old orders show the picture for as long as it is served. ```ts value="Partial" { reference: "BACKPACK-30-GREEN", /* … */ image: "https://cdn.example.com/backpack-400.webp" } ``` ### Say when goods take longer `deliveryTime` is your text for goods that take longer than the shipping method says, such as an engraving or a backorder. The buyer sees it on the Line before ordering, apart from the method's own time, and the Quote freezes it. Write a phrase that stands alone, such as `"Ships in 3 weeks"`: OrbCart shows it as is and never calculates a date. Leave it out when the shipping method's time holds. ```ts value="Partial" // An engraved bottle takes three weeks { reference: "BOTTLE-750", /* … */ deliveryTime: "Ships in 3 weeks" } ``` If the time changes before the buyer orders, the Quote reports `line_delivery_time_changed` and asks again. ### Say why something is unavailable Return a reason instead of leaving the reference out. The buyer sees it in `cart.changes`: ```ts value="UnavailableReference" { reference: "TENT-2P", unavailable: "discontinued" } ``` ## Rules that keep buyers safe * **Return unit prices.** OrbCart applies quantity, discounts and tax. * **Use integer minor units.** `8990` EUR is €89.90. * **Fail honestly.** If your ERP is down, let the error throw. Returning `[]` would tell buyers everything is sold out. * **Never invent a fallback price.** An unknown reference is unavailable, not free. ## When is offers called? When the Cart's Lines change (`purpose: "display"`), and again for the binding Quote (`purpose: "quote"`). Offers from a `quote` call are reused for the next Quote while they are valid, 15 minutes by default. That keeps a wallet sheet fast when the buyer only changes the address. ## End a sale early Tag the Offers of a promotion. When it ends, invalidate the tag from your backend: ```ts await admin.offers.invalidate({ tags: ["summer-sale"] }); ``` Open Carts ask `offers` again. A buyer in checkout sees the new price as a `quote_changed`. Placed Orders keep their price. ## Full example `examples/nordgrat/src/offers.ts` reads prices from one service and product facts from an ERP, handles sale prices and splits a bundle into parts. The recipe [Prices and stock from your ERP](/recipes/erp-prices-and-stock) builds `offers` and `inventory` over HTTP, with parsed answers and a reservation. [Check stock and hand off Orders →](/integrations/inventory) # Stock & ERP hand-off Limit purchases to what you can ship, hold it during checkout, and hand every Order to your ERP. Both are optional. Without `inventory`, stock never limits a purchase. Without `deliverOrder`, your ERP [fetches Orders](#or-let-your-erp-fetch-orders) itself. ## Check stock Start with `check`. It answers how many units you can supply right now: ```ts title="src/worker.ts" export default defineCommerce({ channels, offers, inventory: { check: async (references, { signal }) => { const stock = await erp.stock( references.map((r) => r.reference), { signal }, ); return stock.map((item) => ({ reference: item.sku, available: item.quantity })); }, }, }); ``` OrbCart calls `check` for the binding Quote (with a reservation, `reserve` answers instead, [see below](#reserve-stock-during-checkout)). A reference you do not name is unlimited, like an e-book. The buyer sees the result in `cart.changes`: | Stock | What the buyer sees | | ------ | ------------------------------------------------------------- | | Enough | Nothing. The Quote takes the full quantity. | | Some | `quantity_changed` from 3 to 2. The Quote takes 2. | | None | `unavailable` with reason `sold_out`. The Line is not quoted. | ## Reserve stock during checkout `check` alone can sell the last unit twice when two buyers pay at the same moment. Add `reserve`, `release` and `commit` to hold stock from Quote to Order. The three come together: ```ts members="CommerceConfig" inventory: { check: (references) => erp.stock(references), reserve: (hold) => erp.reserve(hold), release: (holdId) => erp.release(holdId), commit: (holdId, orderId) => erp.commit(holdId, orderId), }, ``` | Function | When OrbCart calls it | Your job | | --------- | ----------------------------------- | ---------------------------------------------- | | `reserve` | A Quote is created. | Hold `hold.references` until `hold.expiresAt`. | | `release` | The hold runs out without an Order. | Free the hold. A second call must do no harm. | | `commit` | The Order is placed. | Turn the hold into a booking for `orderId`. | Each hold has its own `hold.id`. OrbCart may call again with the same ID after a timeout. Reserving the same ID twice must hold stock once. A later `expiresAt` extends the hold. While stock is held, `quote.hold.expiresAt` is set. Use it for a countdown if you like. ## Hand Orders to your ERP `deliverOrder` receives every new Order, after the buyer has paid: ```ts members="CommerceConfig" deliverOrder: async (order, { signal }) => { const salesOrder = await erp.createSalesOrder(order, { signal }); return { reference: salesOrder.number }; }, ``` * **Called at least once.** Until your ERP answers, OrbCart retries, from every second up to every hour. An ERP that is down for an hour gets the Order when it is back. * **Deduplicate by `order.id`.** It is always the same for one Order. This is your only duty. * **Return your own number** as `reference`. It appears on the Order's hand-off. The budget is 30 seconds per attempt. The buyer never waits for it: they see their confirmation as soon as the payment is through. The recipe [Send paid Orders to your ERP](/recipes/erp-order-handoff) maps an Order to a sales order and reports shipments back. ### What's in the Order Everything your ERP needs, frozen at the moment the buyer agreed: ```ts order.number; // "NG-2026-004711" order.contact.email; // the buyer's email order.quote.lines; // reference, quantity, configuration, offer, totals, taxes order.quote.shipTo; // delivery address order.quote.shipping; // the chosen method and its price order.quote.totals; // subtotal, discount, shipping, tax, total order.quote.extensions; // your cart extensions, such as a gift message order.payment; // method, provider and its reference ``` ## Or let your ERP fetch Orders Some ERPs poll instead of receiving. Leave out `deliverOrder` and say so with `fetchOrders`: ```ts export default defineCommerce({ channels, offers, fetchOrders: true, }); ``` Every new Order then waits as `handoff: "pending"` until you acknowledge it. Read the waiting Orders with the [Admin SDK](/sdks/admin) and take each once: ```ts let page = await admin.orders.list({ handoff: ["pending"] }); while (true) { for (const summary of page.data) { const order = await admin.orders.get(summary.id); if (order.handoff === "delivered") continue; // taken, the list is just behind const salesOrder = await erp.createSalesOrder(order); // keyed by order.id await admin.orders.acknowledge(order.id, { reference: salesOrder.number }); } if (!page.next) break; page = await admin.orders.list({ handoff: ["pending"], after: page.next }); } ``` * **The list is a fast index, seconds behind.** An Order you just acknowledged may still be listed on the next poll, and a brand-new one may not be listed yet. `get()` is always current, so check `handoff` there. Poll again later for what is not listed yet. * **Deduplicate by `order.id`** in your ERP, as with `deliverOrder`. If you crash between `createSalesOrder` and `acknowledge`, the next poll takes the Order again and must find the sales order it already made. * **Acknowledging twice changes nothing.** The first `reference` stays. * **A waiting Order is not a failure.** Unlike `deliverOrder`, which raises `handoff_failed` after 30 minutes, a fetching ERP may wait as long as it likes. Filter with `handoff: ["pending"]` to see the queue. ## Report shipments back When your ERP ships units, record them with [`admin.orders.fulfill()`](/sdks/admin#report-shipments). [Choose where tax comes from →](/integrations/tax) # Contracts in another system Let your ERP, a Laravel shop or another Worker answer offers, stock, tax and order handoff, over HTTP or a Service Binding. Every contract of OrbCart, [`offers`](/integrations), [`inventory`](/integrations/inventory), [`tax`](/integrations/tax) and `deliverOrder`, is a function. It does not have to live in the Worker of OrbCart. Attach an HTTP endpoint or another Worker instead, and OrbCart calls it with the same arguments, the same time budget and the same rules for a wrong answer. ```ts title="src/worker.ts" import { defineCommerce, http, service } from "@orbcart/runtime"; export default defineCommerce({ channels, offers: service("PRICES"), // another Worker, through a Service Binding inventory: { check: http((vars) => vars.get("SHOP_URL") + "/commerce/stock"), reserve: http((vars) => vars.get("SHOP_URL") + "/commerce/reserve"), release: http((vars) => vars.get("SHOP_URL") + "/commerce/release"), commit: http((vars) => vars.get("SHOP_URL") + "/commerce/commit"), }, deliverOrder: http("https://middleware.nordgrat.de/edge/orders", { timeoutMs: 20_000 }), }); ``` An HTTP address follows the [rules of event endpoints](/integrations/events#receive-events-over-http): `https` on port 443, a name instead of an IP address, nothing private. A contract takes no `types` and no `previews`, which belong to events. A preview environment calls an HTTP contract like every other, so read its address from the deployment's variables if it should call something else. ## The call Each call is a `POST` with `Content-Type: application/json`. The body names the arguments of the contract and carries the `context`, as you know it from the function: | Contract | Service Binding path | Body | | ------------------- | -------------------- | ------------------------------ | | `offers` | `/offers` | `lines`, `context` | | `tax` | `/tax` | `draft`, `context` | | `deliverOrder` | `/deliver-order` | `order`, `context` | | `inventory.check` | `/inventory/check` | `references`, `context` | | `inventory.reserve` | `/inventory/reserve` | `hold`, `context` | | `inventory.release` | `/inventory/release` | `holdId`, `context` | | `inventory.commit` | `/inventory/commit` | `holdId`, `orderId`, `context` | `context` is what a function gets, without the `signal`: `channel`, `environment`, `requestId` and the fields of the contract, such as `currency` and `purpose` for `offers`. An HTTP endpoint is called at exactly the address you gave. Answer with a `2xx` and the JSON the contract describes, which is the value its function returns. An empty body is `{}`. Anything else, a redirect included, counts as unreachable, and a call that changes something is then repeated with the same key. Every call carries two headers: * `Orbcart-Request-Id`: the request, for finding it in the diagnosis. A repeat has a new one. * `Idempotency-Key`, on the calls that change something (`deliverOrder`, `inventory.reserve`, `release` and `commit`): the Order's or the hold's ID, the same in every repeat. Be idempotent on it, and scope it by endpoint: `reserve`, `release` and `commit` of one hold carry the same key, so a handler that stores only the key would answer a `commit` with the stored answer of the `reserve`. ## Verify an HTTP call An HTTP call is signed with the environment's key, like an [event](/integrations/events#verify-a-delivery). Check it in the endpoint: ```ts title="middleware/orders.ts" import { verifyRequest } from "@orbcart/admin"; export async function handle(request: Request): Promise { const { body, idempotencyKey } = await verifyRequest(request, { url: "https://commerce.nordgrat.de", }); const { order } = body as { order: { id: string } }; const reference = await erp.createSalesOrder(order, { idempotencyKey }); return Response.json({ reference }); } ``` `verifyRequest` throws a `RequestVerificationError` with a `code` when the call is not what the environment signed; answer those with a 4xx. A signature made for an event never passes here, and a contract's never passes `verifyEvent`. A Service Binding is not signed: only your own account can reach it. To try your endpoint against the real thing, `@orbcart/runtime/testing` builds the signed request: ```ts import { signedContractCall } from "@orbcart/runtime/testing"; const request = await signedContractCall( env, "deliverOrder", "https://middleware.nordgrat.de/edge/orders", { order }, ); ``` The signature covers `@method`, `@authority`, `@path`, `@query`, `content-type`, `content-digest` and `orbcart-request-id`, and `idempotency-key` where the call carries one, with `tag="orbcart-contract"`. For another language, any library for [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) verifies it against the keys at `/.well-known/jwks.json`. # Tax Pick one tax source per Channel. Use the EU's daily rates, or bring your own amounts. Every Channel has exactly one tax source. OrbCart never guesses a reduced rate from a product name, and never falls back to another source. If tax cannot be determined, there is no Quote. Your business stays responsible for how each Offer is classified. ## Choose a source | Channel setting | Use it when | Who calculates | | -------------------- | ------------------------------------------------------------------ | ----------------------------------------------------- | | `tax: "eu-standard"` | You sell standard-rated goods to EU countries. | OrbCart, with the EU's daily national standard rates. | | `tax: yourFunction` | Reduced rates, non-EU markets, or your ERP or tax service decides. | You return complete amounts. | | *no setting* | Legacy only. A small built-in table, **not** EU-wide coverage. | OrbCart, for that table only. | ## EU standard VAT ### Opt in on the Channel ```ts title="src/worker.ts" members="Channels" de: { currency: "EUR", pricesIncludeTax: true, locale: "de-DE", tax: "eu-standard", shipping: { DE: [standard], AT: [standard], FR: [standard] }, }, ``` Only Offers with `taxCategory: "standard"` can be quoted. Don't use this mode for books, food or other reduced-rate goods. ### Add the Cron trigger ```jsonc title="wrangler.jsonc" "triggers": { "crons": ["*/15 * * * *"] } ``` This does **not** call the EU every 15 minutes. It imports once a day, around 20:00 UTC, and uses the other runs only to retry a failed import. ### Prime and check the first import After the first deploy, from your Worker's directory: ```sh pnpm exec orbcart tax sync --env test pnpm exec orbcart tax status --env test ``` Add `--worker ` if the deployed Worker is not `-test`, and `--config ` for a non-default config file. ### Read tax status | `state` | Meaning | | ----------- | ----------------------------------------------------------------- | | `ready` | Today's import completed. | | `waiting` | Today's rates are there. The next import is not due yet. | | `retrying` | The import failed and will try again (after 15, 30, 60, 120 min). | | `exhausted` | Today's import gave up. Check the Worker logs. | Before real orders, check that `availableOn` includes today. `missing` lists countries without a clear rate. Only buyers there get no Quote. `tax sync` exits with an error unless rates for today are available, so a setup script can rely on it. `tax status` always exits 0. Read its output. ### Where eu-standard applies All 27 EU member states, at their national standard rate. Shipping to a non-EU country, such as Switzerland, Norway or the UK, fails at startup. Use a tax function there. Special territories outside EU VAT get no Quote. The checkout asks for the postal code where it matters: | Country | Excluded | | ------- | ----------------------------------------------------------------------------------------------- | | DE | Heligoland, Büsingen | | AT | Jungholz, Mittelberg | | ES | Canary Islands, Ceuta, Melilla | | FR | Overseas departments and collectivities (postal codes 97, 984 to 989). Monaco counts as France. | | IT | Livigno, Campione d'Italia | | PT | Madeira, Azores | | FI | Åland | | GR | Mount Athos | | CY | Kyrenia district | ## Your own tax function Use this for reduced rates, a non-EU market, or a tax service. OrbCart sends the exact amounts after discounts. You return the tax for every Line and for shipping. Nordgrat's Swiss shop charges Swiss VAT this way: ```ts title="src/swiss-vat.ts" import type { TaxAmount, TaxContract, TaxDraft } from "@orbcart/runtime"; // Swiss rates in tenths of a percent, so the arithmetic stays in whole numbers. const rates = new Map([ ["standard", 81], ["reduced", 26], ]); export const swissVat: TaxContract = (draft) => ({ // A Line without a known rate gets no tax, so OrbCart refuses to quote it. lines: draft.lines.flatMap(({ id, amount, taxCategory }) => { const tenths = rates.get(taxCategory); return tenths === undefined ? [] : [{ id, taxes: [vat(amount.amount, taxCategory, tenths, draft)] }]; }), ...(draft.shipping && { shipping: [vat(draft.shipping.amount.amount, "standard", 81, draft)], }), }); function vat(amount: number, taxCategory: string, tenths: number, draft: TaxDraft): TaxAmount { const { country, currency, pricesIncludeTax } = draft; const tax = pricesIncludeTax ? Math.round((amount * tenths) / (1000 + tenths)) // included in a gross price : Math.round((amount * tenths) / 1000); // added to a net price return { percentage: String(tenths / 10), country, taxCategory, taxableAmount: { amount: pricesIncludeTax ? amount - tax : amount, currency }, amount: { amount: tax, currency }, rule: "ch-mwst@2024-01-01", }; } ``` Then set `tax: swissVat` on the Channel. The full version is `examples/nordgrat/src/swiss-vat.ts`. ### What you get `draft` contains `country`, `currency`, `pricesIncludeTax`, `shipTo`, `billTo`, the `lines` (each with `id`, `reference`, `taxCategory`, `quantity`, `subtotal`, `discount` and the discounted `amount`) and the selected `shipping`. ### What OrbCart checks * Every Line appears by `id`, and shipping when it is charged. * Amounts are integers in the Channel's currency. * **Gross prices:** taxable amount plus tax equals the discounted amount. * **Net prices:** taxable amount equals the discounted amount. A wrong or missing answer means no Quote, never a guess. `rule` is your name for the rule and its version. It is frozen into the Order. [Let buyers sign in with your login →](/integrations/identity) # Express checkout Apple Pay, Google Pay and PayPal buttons right in the bag. Buy without filling in a form. An express button skips the checkout form. The wallet supplies the address and contact, OrbCart quotes each step, and the buyer pays in the sheet. `` shows express buttons automatically for every method that supports them. This page is for buttons elsewhere, such as in the bag or on a product page. ## Two kinds of express | Mode | Methods | How it works | | -------- | --------------------- | -------------------------------------------------------------------- | | `sheet` | Apple Pay, Google Pay | The buyer picks address and card in the wallet sheet and pays there. | | `review` | PayPal | The buyer approves at PayPal, then confirms on your review page. | `cart.paymentMethods` tells you which methods offer express: their `express` field is `"sheet"` or `"review"`. ## In React `ExpressCheckout` shows the provider's own button for every express method of the Cart, follows the purchase and says what went wrong in the buyer's language: ```tsx title="BagExpress.tsx" import { useNavigate } from "react-router"; import type { Cart, CommerceCart } from "@orbcart/client"; import { ExpressCheckout } from "@orbcart/react"; export function BagExpress({ cart, current }: { cart: CommerceCart; current: Cart }) { const navigate = useNavigate(); return ( navigate(`/en/checkout?preparation=${preparation}`)} onOrdered={(order) => navigate(`/orders/${order.token}`)} /> ); } ``` * **`onOrdered`** gets each Order once. Show your confirmation page. * **`onReviewed`** takes PayPal's approval to your checkout page. The checkout there finds `?preparation=` in the address and shows the review by itself. Without `onReviewed`, only the wallets that pay in their sheet are offered. * **`returnTo`** is that page. A buyer PayPal redirects instead of using its popup comes back there, in the language they shopped in. * **`pending`** keeps the buttons waiting while a change of the bag is on its way, so a sheet never shows the old total. * **`paymentOptions={{ appearance: "inherit" }}`** dresses the buttons in your theme as they appear, as on the [checkout](/sdks/react#theme-it). For markup of your own, `useExpressCheckout` gives you the methods and a mount function, and `ExpressButton` renders one provider's button. ## Without React ```ts const button = await cart.mountExpress("apple_pay", element, { accepted: (result) => { if (result.status === "ordered") showConfirmation(result.order.number); }, reviewed: (review) => goToReviewPage(review.preparation), failed: (error) => console.error(error), }); if (!button.available) hideWalletSection(); ``` `accepted` receives the same outcomes as [`quote.pay()`](/commerce/quotes#4-place-the-order). ## 3-D Secure in a wallet Some banks ask for 3-D Secure even after Apple Pay or Google Pay. OrbCart opens the challenge in a dialog above the page, so the buyer never loses sight of it. Nothing to build. ## Setup checklist * **Apple Pay with Stripe:** register your domain as a payment method domain in Stripe. * **Apple Pay with Adyen:** serve Adyen's domain file at `/.well-known/apple-developer-merchantid-domain-association` as `text/plain`, and test with an Apple sandbox tester. * **Google Pay:** nothing extra in test. In live, Google requires your merchant registration. * **Content Security Policy:** allow `pay.google.com` and Apple's Pay SDK. * **HTTPS:** wallets only show on secure origins. `localhost` counts as secure. Stripe test cards cannot be added to Apple Pay. Use a real card in your wallet with Stripe's test keys: Stripe creates a test token and charges nothing. [Find test cards and accounts →](/payments/test-payments) # Payment providers Map each payment method to a provider. Swap a provider by changing one word. You choose payment methods per Channel and name the provider that carries each one: ```ts title="src/worker.ts" members="ChannelConfig" payments: { card: "adyen", apple_pay: "stripe", google_pay: "adyen", paypal: "paypal", klarna: "adyen", }, ``` Your storefront never names a provider. Change `card: "adyen"` to `card: "stripe"`, deploy, and the checkout follows. No frontend change. ## Methods and providers | Method | Stripe | Adyen | Mollie | PAYONE | PayPal | Shows for | | ------------- | :----: | :---: | :----: | :----: | :----: | ---------------------------------------------------------------------------------------------- | | `card` | ✓ | ✓ | ✓ | ✓ | | Every country | | `apple_pay` | ✓ | ✓ | | | | Every country, on Apple devices | | `google_pay` | ✓ | ✓ | | | | Every country | | `paypal` | | | | | ✓ | EUR, GBP and USD. Not yet in CHF. | | `klarna` | ✓ | ✓ | ✓¹ | | | Countries Klarna serves | | `ideal` | ✓ | ✓ | ✓ | ✓² | | Netherlands, EUR. Shown as "iDEAL \| Wero". | | `bancontact` | ✓ | ✓ | ✓ | | | Belgium, EUR | | `eps` | ✓ | | ✓¹ | ✓² | | Austria, EUR | | `przelewy24` | ✓ | | ✓¹ | | | Poland, EUR | | `twint` | ✓ | ✓ | ✓ | | | Switzerland, CHF | | `pay_by_bank` | | ✓ | ✓ | ✓² | | Adyen: DE, FR, FI. Mollie: AT, BE, DE, ES, FI, FR, IE, IT, NL. PAYONE: AT, BE, DE, FR, NL. EUR | ¹ Built, not yet proven against Mollie's sandbox. ² Built, not yet proven against PAYONE's sandbox. A method shows only where it applies. OrbCart judges by the **billing country** (or the delivery country without a separate billing address) and the Channel's currency. `quote.paymentMethods` already contains only what fits. PayPal, Klarna, iDEAL, EPS, Przelewy24, TWINT and Pay by Bank send the buyer to the provider. Set the Channel's `returnUrl` to a page on your `origins`. Without it, every request fails and the Worker log names the Channel. ## Stripe ### Set the secrets ```sh wrangler secret put STRIPE_SECRET_KEY # sk_test_… or sk_live_… wrangler secret put STRIPE_PUBLISHABLE_KEY # pk_test_… or pk_live_… wrangler secret put STRIPE_WEBHOOK_SECRET # whsec_… from the endpoint below ``` ### Create the webhook endpoint Point it at `https:///webhooks/stripe`. Create it with API version `2026-08-26.dahlia`, not your account's default. Locally, forward events with the Stripe CLI: ```sh stripe listen --latest --all-snapshot --forward-to localhost:8787/webhooks/stripe ``` Apple Pay and Google Pay through Stripe need your storefront's domain registered as a payment method domain in Stripe. ## Adyen ### Set the secrets ```sh wrangler secret put ADYEN_API_KEY # API credential with the Checkout role wrangler secret put ADYEN_CLIENT_KEY # test_… or live_… wrangler secret put ADYEN_MERCHANT_ACCOUNT wrangler secret put ADYEN_HMAC_KEY # hex, from the webhook below ``` In `live`, also set `ADYEN_LIVE_URL_PREFIX` from your Customer Area. ### Allow your storefront Add your storefront's origins (and `http://localhost:5173` for development) to the client key's allowed origins in Adyen's Customer Area. ### Create the webhook A standard webhook to `https:///webhooks/adyen`, JSON format, with HMAC signing. Keep **exactly one** webhook per URL. A second one signs with its own key and every message it sends is refused. Adyen's webhooks need a public URL. Locally, card payments still complete; steps that wait for a webhook, such as the capture, only finish with a tunnel. **Method notes:** Klarna and TWINT are enabled in the Customer Area. TWINT needs a merchant account that accepts CHF. Apple Pay needs Adyen's domain file at `/.well-known/apple-developer-merchantid-domain-association` on your storefront, served as `text/plain` without a redirect. ## Mollie ### Set the secrets ```sh wrangler secret put MOLLIE_API_KEY # test_… in test, live_… in live wrangler secret put MOLLIE_WEBHOOK_URL # https:///webhooks/mollie ``` The API key belongs to one website profile in Mollie's Dashboard; the card fields read the profile from it. A `live` environment refuses a `test_` key and the other way round. ### Activate the methods Activate each method you map to `"mollie"` in the website profile, in test mode and in live mode. Mollie sends its webhook to the address each payment names, so there is nothing to register. Mollie refuses an address it cannot reach, such as `localhost`: leave `MOLLIE_WEBHOOK_URL` unset in local development. Payments still complete when the buyer returns, and captures and refunds learn their outcome on their next attempt. It is required in `live`. **Method notes:** a card's 3-D Secure runs on Mollie's page, so `returnUrl` is needed for cards too. TWINT needs a profile that accepts CHF. Mollie's card fields load from `js.mollie.com`: allow it in your storefront's `script-src` and `frame-src`. ## PAYONE OrbCart speaks PAYONE's E-Payment API, the one at `payment.payone.com`. Sign up for a test account at [signup.preprod.payone.com](https://signup.preprod.payone.com/). ### Set the secrets ```sh wrangler secret put PAYONE_MERCHANT_ID # your PSPID wrangler secret put PAYONE_API_KEY_ID wrangler secret put PAYONE_API_SECRET wrangler secret put PAYONE_WEBHOOK_KEY_ID wrangler secret put PAYONE_WEBHOOK_SECRET ``` Create the API key and the webhook key in PAYONE's portal under Developer. The portal shows each secret only once. A `test` environment talks to PAYONE's test platform, `live` to its live platform. ### Register the webhook Add `https:///webhooks/payone` as the webhook address in the portal. OrbCart checks every message's signature. ### Activate the methods Activate each method you map to `"payone"` in the portal. **Method notes:** a card's 3-D Secure runs on the bank's page, so `returnUrl` is needed for cards too. PAYONE's card field loads from `payment.payone.com` (in test `payment.preprod.payone.com`): allow it in your storefront's `script-src` and `frame-src`. Klarna, Bancontact, Apple Pay and Google Pay through PAYONE follow. ## PayPal ```sh wrangler secret put PAYPAL_CLIENT_ID wrangler secret put PAYPAL_CLIENT_SECRET wrangler secret put PAYPAL_WEBHOOK_ID # the webhook of this environment's PayPal app ``` Point the webhook of your PayPal app at `https:///webhooks/paypal`. The buyer approves at PayPal and confirms the order back on your checkout. ## Test and live Use sandbox keys in your `test` environment and live keys in `live`. Never mix them. A provider's test keys never move real money. See [Test payments](/payments/test-payments) for cards and test accounts that work. ## Common questions **Can I use two providers at once?** Yes. Each method has its own provider, as in the example at the top. **What happens when a provider is down?** The buyer sees a calm message and can choose another method. A payment whose outcome is unclear stays `pending` until the provider answers. It is never guessed. **Who captures the money?** OrbCart. Cards and wallets are authorized at the order and captured as a follow-up step. Refunds go through [`admin.orders.refund()`](/sdks/admin#refund). **Can I add another provider?** Not from merchant code yet. Unzer is on the [roadmap](/status). [Add Apple Pay and Google Pay buttons →](/payments/express) # Test payments Cards, accounts and passwords that work in each provider's sandbox. Every method below runs against its provider's test environment. Nothing is charged. The values are the providers' own public test data, so they work in any sandbox account where the method is enabled. A method only shows where it applies, such as Bancontact for a Belgian billing address in EUR. Use a matching test address. The full list is in [Payment providers](/payments#methods-and-providers). ## Card | Provider | Succeeds | Asks for 3-D Secure | Expiry and security code | | -------- | --------------------- | --------------------- | ----------------------------- | | Stripe | `4242 4242 4242 4242` | `4000 0027 6000 3184` | any future date, any 3 digits | | Adyen | `4111 1111 4555 1142` | `4917 6100 0000 0000` | `03/30`, `737` | **Declines:** Stripe refuses `4000 0000 0000 0002`. With Adyen, the cardholder name `DECLINED` makes the card fail. Adyen's 3-D Secure page accepts the password `password`. Which provider carries the card? Look at `payments.card` in your Channel, or at `quote.paymentMethods`. ## Bancontact Card number `6703 4444 4444 4449`, expiry `03/30`. Bancontact has no security code. Every payment runs 3-D Secure; the test page accepts the password `password`. The field takes only the Bancontact brand. A buyer who wants to pay with the Visa or Mastercard side of a co-badged card chooses the card method. ## Pay by Bank The buyer chooses their bank at Tink. In the sandbox: 1. Choose **Tink Demo Bank**, then **Open banking**, then **Password and OTP With Templates**. 2. Confirm with **Weiter** and log in with the country's test user below. 3. Choose **2FA App Code** and enter `1234`. 4. Pick any account, confirm, and enter `1234` once more to approve the payment. | Billing country | Username | Password | | --------------- | ----------- | -------- | | Germany | `u83188312` | `zhx571` | | France | `u77894411` | `mzw990` | | Finland | `u06516046` | `kam413` | To see a failure, log in with `u92721594` / `nbs589` (login refused) or `u91902655` / `jtx720` (temporary bank error). The buyer returns to the checkout and can pay another way. Pay by Bank starts a bank transfer. Adyen confirms the payment once the transfer is started; if the money never arrives, Adyen books a chargeback after eight business days. ## PayPal Log in at PayPal's sandbox with a personal sandbox account from your [PayPal developer dashboard](https://developer.paypal.com/dashboard/accounts). After approval, the buyer confirms the order back on your checkout. ## TWINT The order button leads to Adyen's TWINT simulator. Choose **authorised** to pay, or **cancelled**, **refused** or **error** to see the buyer return to the checkout. The two **pending** buttons decide later, after the chosen delay, through Adyen's webhook. # Add to cart and a mini cart An add-to-cart button for your product pages and a bag in the header that shows the server's Lines and total. You build a button that puts the green trail backpack in the bag, and a header bag that counts the units and opens to show each Line and the estimated total. It survives reloads and starts afresh after a purchase. **You need** a React Router storefront and Nordgrat running from the [Quickstart](/getting-started). Copy `ORBCART_URL` and `ORBCART_PUBLISHABLE_KEY` from the file [`orbcart dev`](/sdks/cli) wrote into your storefront's `.env` as `VITE_ORBCART_URL` and `VITE_ORBCART_PUBLISHABLE_KEY`. The publishable key is safe in the browser. ## 1. Connect once ```ts title="app/commerce.ts" import { createCommerce } from "@orbcart/client"; export const commerce = createCommerce({ url: import.meta.env.VITE_ORBCART_URL, key: import.meta.env.VITE_ORBCART_PUBLISHABLE_KEY, }); ``` ## 2. Keep the Cart token in a cookie The token opens the Cart and the buyer's details, so only your server keeps it, in an HttpOnly cookie ([Security](/security#keys-and-tokens)). Add `app/cart-cookie.server.ts` and the `/cart-token` route from [Keep the Cart across reloads](/commerce#in-a-cookie). ## 3. Hold the Cart on every page `CartProvider` opens the buyer's Cart from the cookie, creates the first one with the first Line and moves to the next one after a purchase. `onCartChange` says when to save a new token: ```tsx title="app/root.tsx (trimmed)" import type { CommerceCart } from "@orbcart/client"; import { CartProvider, OrbCartProvider } from "@orbcart/react"; import "@orbcart/react/styles.css"; import { Outlet, useLoaderData } from "react-router"; import type { LoaderFunctionArgs } from "react-router"; import { cartCookie } from "./cart-cookie.server"; import { commerce } from "./commerce"; import { MiniCart } from "./mini-cart"; export async function loader({ request }: LoaderFunctionArgs) { const token: unknown = await cartCookie.parse(request.headers.get("Cookie")); return { cartToken: typeof token === "string" ? token : undefined }; } export default function App() { const { cartToken } = useLoaderData(); return (
Nordgrat
); } function rememberCart(cart: CommerceCart) { void fetch("/cart-token", { method: "POST", body: new URLSearchParams({ token: cart.token }) }); } ``` ## 4. The button ```tsx title="app/add-to-bag.tsx" import { CommerceError, describe } from "@orbcart/client"; import { useShoppingCart } from "@orbcart/react"; import { useState } from "react"; const words = describe("en-GB"); export function AddToBag({ reference }: { reference: string }) { const { add } = useShoppingCart(); const [adding, setAdding] = useState(false); const [problem, setProblem] = useState(); async function addToBag() { setAdding(true); setProblem(undefined); try { await add(reference); } catch (error) { if (!(error instanceof CommerceError)) throw error; setProblem(words.problem(error.problem.code)); } finally { setAdding(false); } } return ( <> {problem &&

{problem}

} ); } ``` Put `` on the product page. The browser sends only the reference; the price comes from your [`offers`](/integrations) function. ## 5. The mini cart ```tsx title="app/mini-cart.tsx" import { isSubmitted, unitsIn } from "@orbcart/client"; import type { CommerceCart } from "@orbcart/client"; import { Cart, Totals, useCart, useShoppingCart } from "@orbcart/react"; export function MiniCart() { const { cart } = useShoppingCart(); // There is no Cart before the first Line, so an empty bag costs no request. return cart ? : Bag (0); } function Bag({ cart }: { cart: CommerceCart }) { const bag = useCart({ cart }); if (!bag.cart) return Bag; // An ordered Cart stays until the buyer shops on; the next add starts a fresh one. if (isSubmitted(bag.cart)) return Bag (0); const { estimate } = bag.cart; return (
Bag ({unitsIn(bag.cart)}) Check out
); } ``` `useCart` and the button share one queue per Cart, so the count changes when the server has answered, never before. ## What the buyer sees * **Add to bag** turns the header into "Bag (1)". Open it for the backpack at €89.90, a quantity stepper and the total, marked as without shipping until a method is chosen. * **More than is in stock** is said under its Line, and the quantity stays what the server allows. * **A reference your `offers` does not sell** shows as unavailable, with the reason. * **A reload or a second tab** shows the same bag. **After the purchase** the bag is empty, and the next add starts a new Cart in every tab. ## Next * [React checkout](/sdks/react): the `/checkout` page this bag links to, in one component. * [Carts](/commerce): the same steps with the plain client, for other frameworks. * [Free shipping and a welcome code](/recipes/free-shipping-and-codes): add a code field to this bag. # A checkout without React Your own checkout page in plain TypeScript, with the checkout store of @orbcart/client. Details, the binding total, a card field and every outcome of the order button. You build a checkout page from your own markup: the buyer types their details, sees the exact total with a card field, and orders. Every outcome, from 3-D Secure to a changed price, reaches the page through one store, so the page only draws what the store says. Vue, Svelte or Solid subscribe to the same store; [``](/sdks/react) is this store with React markup. **You need** a Cart with a Line, such as from [Add to cart](/recipes/add-to-cart), and its token rendered into the page by your server. `card` must be mapped to a provider in the Channel's [`payments`](/payments). ## 1. The markup ```html title="checkout.html"

``` ## 2. The page's code ```ts title="src/checkout.ts" import { createCommerce, describe, formatMoney } from "@orbcart/client"; import type { CheckoutState, MountedPayment } from "@orbcart/client"; const locale = "en-GB"; const words = describe(locale); const commerce = createCommerce({ url: import.meta.env.VITE_ORBCART_URL, key: import.meta.env.VITE_ORBCART_PUBLISHABLE_KEY, }); // `savedToken` is yours: the Cart token your server rendered into this page. const checkout = commerce.openCart(savedToken).checkout(); const form = document.querySelector("#details")!; const field = document.querySelector("#payment-field")!; const total = document.querySelector("#total")!; const notice = document.querySelector("#notice")!; const orderButton = document.querySelector("#order")!; orderButton.textContent = words.orderButton; // the wording the law asks for, in the buyer's language let card: Promise | undefined; checkout.subscribe(render); // Also resumes a purchase the buyer comes back to, such as from PayPal or their bank. void checkout.start(); form.addEventListener("submit", (event) => { event.preventDefault(); const data = new FormData(form); const value = (name: string) => String(data.get(name) ?? ""); void checkout.submitDetails({ contact: { email: value("email") }, shipTo: { name: value("name"), line1: value("line1"), postalCode: value("postalCode"), city: value("city"), country: value("country"), }, }); }); orderButton.addEventListener("click", async () => { const method = checkout.state.paymentMethod; if (method) await checkout.placeOrder({ payment: card ? await card : method.id }); }); function render(state: CheckoutState) { if (state.order) { form.hidden = field.hidden = orderButton.hidden = true; notice.textContent = `Thank you. Your order number is ${state.order.number}.`; return; } // Back from the provider, the purchase resumes by itself: nothing to type meanwhile. form.hidden = state.resuming !== undefined; notice.textContent = state.notice ? words.notice(state.notice).join(" ") : ""; total.textContent = state.quote ? formatMoney(state.quote.totals.total, locale) : ""; orderButton.hidden = state.step === "details"; orderButton.disabled = state.step !== "quoted" || state.busy !== undefined; // The card field appears with the first Quote and outlives every later one. const method = state.paymentMethod; if (state.quote && method?.flow === "inline") card ??= checkout.mountPayment(method.id, field); } ``` The store runs one step at a time, in the order the buyer took them. Its steps never throw a refusal: what the buyer must hear lands in `state.notice`, and `describe()` words it in English, German, French or Dutch. `state.paymentMethods` lists what this buyer can use. Draw them as choices, call `checkout.choosePayment(id)`, and mount the field again when the chosen method is `inline`. A `redirect` method, such as PayPal, needs no field: `placeOrder` sends the buyer to the provider, and `start()` picks the purchase up on their way back. ## What the buyer sees | Moment | The page shows | | ------------------------------- | ------------------------------------------------------------------------------------------- | | A field is empty or wrong | The notice names it, such as a postal code that does not fit the country. | | Details complete | The exact total, the card field and "Order with obligation to pay". | | The bank asks for 3-D Secure | The bank's check, opened from the card field, then the order number. | | A price changed since the total | The notice says what changed and the new total stands. One more click orders at that total. | | The card is declined | The notice says so, the button is ready again for another card. | | The provider is slow | The notice says the page updates by itself; it turns into the order number once decided. | | The buyer reloads or comes back | `start()` finds the running purchase. It never starts a second payment. | ## Next * [Quotes & payments](/commerce/quotes): the single calls under the store, `quote()`, `mountPayment()` and `pay()`, for a server or an agent. * [Errors](/api-reference/errors): every code a notice can carry. * [Test a purchase](/recipes/test-a-purchase): prove the flow against your own Worker. # Send paid Orders to your ERP Hand every paid Order to your ERP exactly once, keep OrbCart's Line IDs there, and report shipments back so the buyer's order page shows the tracking. You make every paid Order arrive in your ERP as a sales order, exactly once, even when the ERP is down for an hour. When the warehouse ships, the ERP reports it back, and the buyer's order page shows the parcel with its tracking. **You need** a commerce Worker, and an ERP client such as `ask` from [Prices and stock from your ERP](/recipes/erp-prices-and-stock). Below, `erp` stands for it. The backend part needs the secret key from [`orbcart dev`](/sdks/cli). ## 1. Map the Order to a sales order `deliverOrder` gets each new Order after the buyer has paid. Hand it over and return the ERP's number: ```ts title="src/erp.ts" import type { DeliverOrderContract } from "@orbcart/runtime"; export const deliverOrder: DeliverOrderContract = async (order, { signal }) => { const { shipTo, shipping, lines, totals } = order.quote; const salesOrder = await erp.createSalesOrder( { externalId: order.id, number: order.number, // such as NG-2026-004711 email: order.contact.email, shipTo, shippingMethod: shipping?.id, lines: lines.map((line) => ({ line: line.id, // keep it: shipments and refunds name Lines by it sku: line.reference, quantity: line.quantity, total: line.totals.total, })), total: totals.total, }, // OrbCart calls again until the ERP answers, always with the same order.id. { idempotencyKey: order.id, signal }, ); return { reference: salesOrder.number }; }; ``` Deduplicating by `order.id` is your one duty. Most ERPs take an external ID or an idempotency key for this; if yours does not, look the ID up before you create. The Order holds every amount frozen as the buyer agreed, so never recalculate one. ## 2. Attach it ```ts title="src/worker.ts" import { defineCommerce } from "@orbcart/runtime"; import { deliverOrder, inventory, offers } from "./erp"; export * from "@orbcart/runtime/objects"; export default defineCommerce({ channels, offers, inventory, deliverOrder }); ``` ## 3. Report shipments back When the ERP ships, it calls your backend. Tell OrbCart with the Admin SDK and the secret key: ```ts title="backend/erp-shipments.ts" import { createAdmin } from "@orbcart/admin"; import { z } from "zod"; const admin = createAdmin({ url: env.ORBCART_URL, key: env.ORBCART_SECRET_KEY }); // What the ERP posts when a delivery leaves the warehouse. const Delivery = z.object({ externalId: z.string(), deliveryNumber: z.string(), trackingNumber: z.string(), items: z.array(z.object({ line: z.string(), quantity: z.int().positive() })), }); export async function handle(request: Request): Promise { const delivery = Delivery.parse(await request.json()); await admin.orders.fulfill(delivery.externalId, { reference: delivery.deliveryNumber, // the same delivery twice changes nothing carrier: "DHL", tracking: delivery.trackingNumber, lines: delivery.items, }); return new Response(null, { status: 204 }); } ``` Check the ERP's call the way its docs say before you trust the body. A delivery may hold part of an Order: name only the Lines and quantities that left. ## What the buyer sees * **The confirmation at once.** The buyer never waits for the ERP; `deliverOrder` runs after the payment is secured. * **Nothing, when the ERP is down.** OrbCart retries from every second up to every hour. After 30 minutes the Order shows `handoff_failed` in the Explorer, and the next success clears it. * **The parcel on the order page,** with carrier and tracking, once you call `fulfill`. Your systems hear it as [`order.fulfilled`](/integrations/events#the-catalog). ## Variations * **Your ERP polls instead?** Leave out `deliverOrder`, set `fetchOrders: true` and acknowledge each Order: [Or let your ERP fetch Orders](/integrations/inventory#or-let-your-erp-fetch-orders). * **Your middleware lives outside Cloudflare?** Attach `deliverOrder: http("https://…")` and check each call with `verifyRequest()`: [Contracts in another system](/integrations/remote-contracts). * **A gift message or an engraving on the packing slip?** They travel in the Order: [A gift message, end to end](/recipes/gift-message). ## Next * [Orders & refunds](/sdks/admin): refunds by Line, partial shipments and the Order list. * [Order events](/integrations/events): tell other systems, such as accounting, what happened. # Prices and stock from your ERP Answer OrbCart's offers and inventory contracts from an ERP over HTTP, with one request per call, parsed answers and a reservation that never sells the last unit twice. You connect the ERP that already knows your articles, prices and stock. Every price a buyer sees comes from it, a sold-out article is never sold, and the last unit is held for the buyer who is paying. There is no catalog import and nothing to sync. **You need** a commerce Worker from the [Configuration](/configuration) page, and an ERP with an HTTP API for articles, stock and reservations. Store its address and token as Worker variables, such as `wrangler secret put ERP_TOKEN`. ## 1. The ERP adapter One file speaks to the ERP. It asks once per call for every Line together, parses what comes back and throws when the ERP fails: ```ts title="src/erp.ts" import { env } from "cloudflare:workers"; import type { InventoryContract, OffersContract } from "@orbcart/runtime"; import { z } from "zod"; // What the ERP answers, parsed at the border: a malformed answer fails loudly instead of selling at a wrong price. const Article = z.object({ sku: z.string(), name: z.string(), priceInCents: z.int().nonnegative(), vatClass: z.enum(["standard", "reduced"]), weightInGrams: z.int().nonnegative(), discontinued: z.boolean(), }); const Quantity = z.object({ sku: z.string(), quantity: z.int().nonnegative() }); async function ask(path: string, body: unknown, answer: z.ZodType, signal: AbortSignal) { const response = await fetch(new URL(path, env.ERP_URL), { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.ERP_TOKEN}` }, body: JSON.stringify(body), signal, }); // Throwing tells OrbCart the ERP is unreachable. Nothing is sold at a guess. if (!response.ok) throw new Error(`ERP ${path} answered ${response.status}`); return answer.parse(await response.json()); } const items = (references: readonly { reference: string; quantity: number }[]) => references.map(({ reference, quantity }) => ({ sku: reference, quantity })); export const offers: OffersContract = async (lines, { currency, signal }) => { const skus = [...new Set(lines.map((line) => line.reference))]; const articles = await ask("/articles", { skus, currency }, z.array(Article), signal); return articles.map((article) => article.discontinued ? { reference: article.sku, unavailable: "discontinued" } : { reference: article.sku, title: article.name, price: { amount: article.priceInCents, currency }, taxCategory: article.vatClass, shipping: { weightInGrams: article.weightInGrams }, }, ); }; export const inventory: InventoryContract = { check: async (references, { signal }) => { const skus = references.map(({ reference }) => reference); const stock = await ask("/stock", { skus }, z.array(Quantity), signal); return stock.map(({ sku, quantity }) => ({ reference: sku, available: quantity })); }, // Called again with the same hold.id after a timeout: the ERP must hold the stock once. reserve: async (hold, { signal }) => { const body = { id: hold.id, items: items(hold.references), until: hold.expiresAt }; const held = await ask("/reservations", body, z.array(Quantity), signal); return held.map(({ sku, quantity }) => ({ reference: sku, reserved: quantity })); }, release: async (holdId, { signal }) => { await ask(`/reservations/${holdId}/release`, {}, z.unknown(), signal); }, commit: async (holdId, orderId, { signal }) => { const booked = await ask( `/reservations/${holdId}/commit`, { orderId }, z.array(Quantity), signal, ); return booked.map(({ sku, quantity }) => ({ reference: sku, committed: quantity })); }, }; ``` Your ERP's paths and field names differ; the shape stays. Three rules carry it: * **One request per call.** `offers` gets every Line of the Cart at once. A request per Line would spend the 3-second budget. * **Unit prices in minor units.** OrbCart applies quantity, discounts and tax. `8990` is €89.90. * **Tax categories your Channel can quote.** `eu-standard` quotes only `standard`. Selling `reduced` articles needs a [`tax` function](/integrations/tax#your-own-tax-function). * **Fail honestly.** An empty answer would tell every buyer that everything is sold out. Throw instead. ## 2. Attach it ```ts title="src/worker.ts" import { defineCommerce } from "@orbcart/runtime"; import { inventory, offers } from "./erp"; export * from "@orbcart/runtime/objects"; export default defineCommerce({ channels, offers, inventory }); ``` Need more than 3 seconds? Attach `offers: { run: offers, timeoutMs: 5_000 }` ([Time budgets](/configuration#time-budgets)). ## 3. Try it Add an article to a Cart and quote it, as in the [Quickstart](/getting-started). The Worker log shows each call to the ERP. Then set an article's stock to 1 in the ERP and ask for 3. ## What the buyer sees | In the ERP | The buyer sees | | ------------------------------------- | ------------------------------------------------------------------------- | | A price | That price, in the bag and in the Quote. | | `discontinued: true` | The Line as unavailable, with the reason, and no way to buy it. | | Stock of 1 for a quantity of 3 | The Quote takes 1 and says why (`quantity_changed`). | | Stock of 0 | The Line as sold out. The Quote leaves it out. | | The last unit, held for another buyer | Sold out while that buyer's hold lasts. | | The ERP is down | "Our systems are not responding right now. Please try again in a moment." | ## Variations * **Your middleware already speaks OrbCart's contract?** Attach it as `offers: http("https://…")` and skip the adapter: [Contracts in another system](/integrations/remote-contracts). * **Prices and facts in two systems,** such as a pricing service beside the ERP? Ask both in `offers` with `Promise.all`, as Nordgrat does in `examples/nordgrat/src/offers.ts`. * **Customer-group prices?** `offers` gets `buyer` for a [signed-in buyer](/integrations/identity#prices-per-customer-group). ## Next * [Offers](/integrations): every field an Offer can carry, such as `image`, `compareAt` and `tags`. * [Stock & ERP hand-off](/integrations/inventory): when OrbCart calls `check`, `reserve`, `release` and `commit`. * [Send paid Orders to your ERP](/recipes/erp-order-handoff): the other direction. # Free shipping and a welcome code Free standard shipping from €50 without a code, 10 % off with WELCOME10, a code field in the bag, and a test that proves both. You declare two promotions: standard shipping is free from €50 of goods, and the code `WELCOME10` takes 10 % off. OrbCart applies both, spreads them over the Lines and shows them in the Cart, the Quote and the Order. No discount code is written by hand. **You need** a Channel with a `standard` shipping rule, as on [Shipping](/commerce/shipping). ## 1. Declare both on the Channel ```ts title="src/worker.ts" members="ChannelConfig" discounts: [ // No code: applies by itself from €50 of goods. Express stays paid. { id: "free-shipping", scope: "shipping", percentage: 100, minimumSubtotal: 5000, methods: ["standard"] }, { id: "welcome", code: "WELCOME10", percentage: 10 }, ], ``` `minimumSubtotal` counts the goods before discounts, in minor units of the Channel's currency. A Swiss Channel with the same line reads CHF 50. ## 2. Let the buyer enter the code [``](/sdks/react) shows a code field already. For the bag, the kit has one that joins the Cart's queue: ```tsx title="app/mini-cart.tsx" browser elements import { DiscountCode } from "@orbcart/react"; ; ``` Put it below `Totals` in the `Bag` of [Add to cart](/recipes/add-to-cart#5-the-mini-cart). Without React, call the client yourself: ```ts browser await cart.enterCode("welcome10"); // any spelling ``` ## 3. Prove it in a test With the `vitest.config.ts` from [Testing](/testing#test-your-worker), as in [Test a purchase](/recipes/test-a-purchase): ```ts title="test/discounts.test.ts" import { env, exports } from "cloudflare:workers"; import { createCommerce } from "@orbcart/client"; import { issueKey, publishStandardRates } from "@orbcart/runtime/testing"; import { beforeEach, expect, it } from "vitest"; const worker = (request: Request) => exports.default.fetch(request); const shipTo = { name: "Anna Berg", line1: "Bergstraße 12", postalCode: "10115", city: "Berlin", country: "DE", }; beforeEach(async () => { const today = new Date().toISOString().slice(0, 10); await publishStandardRates(env, { on: today, rates: { DE: "19" } }); }); it("ships a backpack for free and takes 10 % off with WELCOME10", async () => { const key = await issueKey(env, { kind: "publishable", channel: "de" }); const cart = await createCommerce({ url: "https://commerce.test", key, fetch: worker }).cart(); await cart.add("BACKPACK-30-GREEN"); // €89.90, over the threshold await cart.enterCode("welcome10"); const quote = await cart.quote({ shipTo, contact: { email: "anna@example.com" } }); expect(quote.discounts).toEqual( expect.arrayContaining([ expect.objectContaining({ discount: "welcome", scope: "order" }), expect.objectContaining({ discount: "free-shipping", scope: "shipping" }), ]), ); }); ``` ## Next [Discounts](/commerce/discounts#what-the-buyer-sees) says what the buyer sees for each case, such as express chosen or a typo in the code, and has the variations: a code for free shipping, redemption limits and codes your own pricing redeems. # A gift message, end to end Declare a gift message once, let the buyer write it in the bag, and print it on the packing slip. Checked on the server, kept in the Quote and frozen in the Order. You add a field OrbCart does not know: a gift message of up to 200 characters. The buyer writes it in the bag, the server checks it against your schema, and your ERP prints it on the packing slip. No table, no endpoint and no migration of your own. **You need** a commerce Worker and a React storefront, such as the one from [Add to cart](/recipes/add-to-cart). The [Extensions](/commerce/extensions) page has every rule. ## 1. Declare it An extension is a name, where it sits and a schema. `on: "cart"` puts it on the whole purchase: ```ts title="src/commerce.ts" import type { CommerceConfig } from "@orbcart/runtime"; import { z } from "zod"; export const commerce = { channels, offers, extensions: { giftMessage: { on: "cart", schema: z.string().trim().min(1).max(200) }, }, } satisfies CommerceConfig; ``` `src/worker.ts` passes it on as `defineCommerce(commerce)`. A schema OrbCart cannot describe as JSON Schema, such as one with a `transform`, fails the deployment, not a buyer. ## 2. Let the buyer write it The field saves when the buyer leaves it. It goes through the Cart's checkout store, so it waits its turn behind any other change of the Cart: ```tsx title="app/gift-message.tsx" browser import { CommerceError, describe } from "@orbcart/client"; import type { Cart, CommerceCart } from "@orbcart/client"; import { useState } from "react"; const words = describe("en-GB"); export function GiftMessage({ cart, current }: { cart: CommerceCart; current: Cart }) { const saved = current.extensions.giftMessage; const [text, setText] = useState(typeof saved === "string" ? saved : ""); const [problem, setProblem] = useState(); async function save() { setProblem(undefined); const checkout = cart.checkout(); try { await (text.trim() ? checkout.setExtension("giftMessage", text) : checkout.removeExtension("giftMessage")); } catch (error) { if (!(error instanceof CommerceError)) throw error; setProblem(words.problem(error.problem.code)); } } return (