Custom checkout flow

Drive the Brainerce checkout step by step from your own UI. The real endpoints, the 30-minute expiry, the payment gate, and what the platform will not let you build.

Brainerce's checkout is a set of endpoints you drive yourself, so you can put your own UI between any two steps. What you cannot do is change what the steps are. This recipe covers the transitions that exist, the constraints that bite, and, just as important, the customizations people ask for that the platform does not support.

First: which surface are you on?

Almost every mistake in a custom checkout comes from mixing the two surfaces up. They are not interchangeable.

Storefront (/vc/{salesChannelId}/…)Public API (/v1/…)
SDK clientnew BrainerceClient({ salesChannelId: 'vc_…' })new BrainerceClient({ apiKey: 'brainerce_…' })
Called fromThe shopper's browserYour server
Can take a paymentYesNo
Idempotency-KeyIgnoredHonoured

The /v1 surface has no payment endpoints at all: no payment intent, no capture, no refund. An API-key integration can create a checkout and drive it right up to the edge, but it cannot charge the card. POST /v1/checkout/{id}/complete then fails with 400 PAYMENT_REQUIRED, because completion requires an already-captured payment.

So: money moves on /vc. Use /v1 for server-side reads, draft orders, and back-office work.

The real state machine

These are the transitions that exist. Both surfaces expose the same set (payment aside), so the paths below are written with the /v1 prefix; swap in /vc/{salesChannelId} for the storefront.

POST   /v1/checkout                            # create from a cart (cartId required)
GET    /v1/checkout/{id}                       # read current state
PATCH  /v1/checkout/{id}/customer              # email, name, phone, shopper note
PATCH  /v1/checkout/{id}/shipping-address
PATCH  /v1/checkout/{id}/billing-address
GET    /v1/checkout/{id}/shipping-rates
PATCH  /v1/checkout/{id}/shipping-method
POST   /v1/checkout/{id}/coupon                # apply
DELETE /v1/checkout/{id}/coupon                # remove
POST   /v1/checkout/{id}/gift-card             # apply a gift card (a tender, not a discount)
DELETE /v1/checkout/{id}/gift-card/{tenderId}  # remove one, by tenderId, never by code
POST   /v1/gift-cards/balance                  # optional pre-check; not checkout state
POST   /v1/checkout/{id}/complete              # Idempotency-Key REQUIRED
DELETE /v1/checkout/{id}                       # abandon, releases reservations

The storefront surface adds the steps that only make sense in a browser:

PATCH  /vc/{salesChannelId}/checkout/{id}/delivery-type
PATCH  /vc/{salesChannelId}/checkout/{id}/pickup-location
GET    /vc/{salesChannelId}/checkout/{id}/custom-fields
PATCH  /vc/{salesChannelId}/checkout/{id}/custom-fields
POST   /vc/{salesChannelId}/payment/intent          # the payment step
POST   /vc/{salesChannelId}/payment/sdk-confirm
GET    /vc/{salesChannelId}/checkout/{id}/payment-status

A gift card does not move total. It reduces providerAmountDue, which the checkout carries alongside tenders ({ tenderId, amountApplied }[]) — the order is still worth what it is worth, and tax is still calculated on that. Render the card below the total and an "Amount due" line, never inside the discount block. Both mutations only work while the checkout is still editable, so they belong before the payment step; afterwards they return CHECKOUT_LOCKED.

There is no PATCH /checkout/{id}. A checkout has no general-purpose update endpoint, and the SDK has no updateCheckout() method. Each field has its own route or it cannot be set.

You can:

  • Insert UI steps between transitions: a "delivery instructions" page between address and shipping, an upsell between shipping and payment, a terms screen before payment. Anything that fits inside the 30-minute window below.
  • Skip transitions. Completion only enforces an email address, a shipping address (unless every item is downloadable), and a captured payment. A shipping method is optional.
  • Read state mid-flow. GET /checkout/{id} returns the full checkout: totals, applied discounts, custom-field values, expiresAt.

Three constraints that will shape your design

A checkout expires 30 minutes after it is created

Not 30 minutes of inactivity, but 30 minutes from creation, fixed and not configurable. After that, POST /checkout/{id}/complete throws 400 Checkout session has expired, and a background job flips the status to EXPIRED.

The current deadline is on the checkout itself:

const checkout = await client.getCheckout(checkoutId);
// checkout.expiresAt — ISO timestamp, or null

This rules out any flow where a human has to act between checkout creation and payment: manager sign-off, a callback from your ERP, an overnight credit check. Do that work before you create the checkout, not inside it.

If a shopper needs longer, you can start over from the same cart. A cart may back more than one checkout, because Checkout.cartId is not unique and createCheckout doesn't require the cart to be in any particular state, so passing the same cartId again works and the items are still there.

One catch worth designing around: when a checkout expires, the worker also flips its cart to ABANDONED, unless the shopper has touched the cart more recently than the checkout, which protects someone who is still shopping. The SDK drops an ABANDONED cart on the shopper's next fetch. So keep the cartId server-side and create the replacement checkout from it directly, rather than assuming the shopper's browser still has the cart. Expiry also releases any inventory that checkout had reserved.

The payment webhook creates the order, not completeCheckout()

On a real payment, the provider's webhook creates the order in the same transaction that marks the payment captured. By the time your code could call completeCheckout(), the order already exists, so the call becomes an idempotent read that returns the existing orderId with the message Order already created.

The practical consequence: completeCheckout() cannot veto an order. Returning an error from your own backend at that point blocks nothing; the card is already charged.

completeCheckout() genuinely creates the order in exactly one case: a sandbox checkout, where the payment intent id starts with sandbox_. That is why the call looks decisive in test mode and turns out to be a no-op in production.

Idempotency-Key is required on complete, and only works on /v1

POST /v1/checkout/{id}/complete is the single route in the product that requires the header. Omit it and you get a 400 before any work happens.

curl -X POST https://api.brainerce.com/api/v1/checkout/chk_123/complete \
  -H "Authorization: Bearer brainerce_live_..." \
  -H "Idempotency-Key: 5f8c1c1e-2b0a-4c4a-9a3f-6b1c9b0a2d4e"

Other /v1 mutating routes accept the header and honour it (retries within 24h replay the original response), but do not require it. On the storefront /vc surface the header is silently ignored, because no route there opts into idempotency. Do not rely on it from the browser.

The SDK never sends the header on any surface. completeCheckout() takes one argument and has no options bag:

const { orderId } = await client.completeCheckout(checkoutId);

If you need the guarantee, make that one call with raw HTTP against /v1 and set the header yourself.

Because the key is mandatory here, this route also fails closed: if the once-only lock cannot be taken, you get a 503 with code: "IDEMPOTENCY_LOCK_UNAVAILABLE" and nothing is processed — no order, no charge. Retry the identical request with the same key. Do not mint a fresh one; that is what 409 IDEMPOTENCY_KEY_REUSED asks for, and it means the opposite. See Idempotency.

Pattern 1: Collect extra data at checkout

There is no metadata bag on a checkout or an order. Arbitrary JSON has nowhere to go. What exists instead is two purpose-built fields, and they are better for the job.

Checkout custom fields: a gift message, a company tax ID, a floor number. You define them once in the dashboard under Checkouts → Custom Fields (they can carry a surcharge, and can be limited to certain products or to shipping-only orders). Your storefront fetches the applicable ones and sends back the values:

// Which fields apply to this checkout right now?
const fields = await client.getCheckoutCustomFields(checkoutId);

// Send the values back. Pass EVERY field the shopper filled — the map is
// replaced wholesale, not merged, and totals are recalculated.
const checkout = await client.setCheckoutCustomFields(checkoutId, {
  gift_message: 'Happy birthday, Mom!',
  delivery_instructions: 'Leave at the back door',
});
// checkout.surchargeAmount and checkout.total reflect any surcharge

Storefront surface only. getCheckoutCustomFields / setCheckoutCustomFields resolve to /vc/{salesChannelId}/… or /stores/{storeId}/…. There is no equivalent route on the API-key /v1 surface, so these calls fail in admin mode.

This is also where required fields are enforced. The check runs inside setCheckoutCustomFields, so a storefront that never calls it completes the order with no values at all, required or not. If you are building your own checkout UI, calling this is on you.

A free-text shopper note: one order-level string, no dashboard setup, capped at 2000 characters. It rides along with the customer step and is copied onto the order:

await client.setCheckoutCustomer(checkoutId, {
  email: '[email protected]',
  firstName: 'Jane',
  lastName: 'Doe',
  notes: 'Please ring the bell twice',
});

Both land on the order and both are included in the order.created webhook payload. See Observability.

Pattern 2: Custom validation before the shopper pays

Because the webhook creates the order the moment payment captures, your gate has to sit before the payment step, meaning before createPaymentIntent, not before completeCheckout. That is the last point where refusing still costs the shopper nothing.

// your-storefront.com/api/checkout/authorize-payment
// Called from your checkout UI right before it renders the payment element.
export async function POST(req: Request) {
  const { checkoutId } = await req.json();
  const checkout = await brainerce.getCheckout(checkoutId);

  // Money fields are STRINGS on the checkout — parse before comparing.
  if (parseFloat(checkout.total) > YOUR_FRAUD_REVIEW_CEILING) {
    return Response.json({ error: 'Please contact us to place this order' }, { status: 400 });
  }

  // Custom rule: hazmat items can't ship to some regions.
  const hasHazmat = checkout.lineItems.some((i) => HAZMAT_SKUS.has(i.product.sku));
  if (hasHazmat && !servesHazmat(checkout.shippingAddress?.country)) {
    return Response.json({ error: 'We cannot ship these items to your address' }, { status: 400 });
  }

  // Custom rule: age check for restricted goods.
  if (hasRestrictedItems(checkout) && !(await yourIdentityProvider.isOver21(checkout.email))) {
    return Response.json({ error: 'Age verification required' }, { status: 400 });
  }

  return Response.json({ ok: true });
}

Only when that returns ok does your UI create the payment intent and render the payment element.

Two things to note in the sample. checkout.total is a string, so parseFloat it rather than comparing it as a number. And line items are on checkout.lineItems, not checkout.items.

Anything slower than the shopper's patience does not belong here at all; see the 30-minute expiry above. Approval workflows have to run before the checkout is created.

Pattern 3: One basket, several orders

A marketplace where each seller's items become their own order, so each seller sees only their own.

Because a checkout is created from a cart, and each checkout produces exactly one order, this means one cart and one checkout per seller:

// One server-side cart per vendor.
const checkouts = await Promise.all(
  vendorGroups.map(async ({ vendorId, items }) => {
    const cart = await client.createCart();
    for (const item of items) {
      await client.addToCart(cart.id, {
        productId: item.productId,
        variantId: item.variantId,
        quantity: item.quantity,
      });
    }
    const checkout = await client.createCheckout({ cartId: cart.id });
    return { vendorId, checkout };
  })
);

createCheckout takes { cartId }. It will not accept a list of items, and cartId is required. addToCart takes the cart id as its first positional argument, then the item.

The honest caveat: there is no way to pay for several checkouts at once. Each one needs its own payment intent, so the shopper is charged N separate times and sees N confirmations. Each also runs its own independent 30-minute clock from the moment it was created. If a single charge matters more than separate orders, use one cart and split the fulfilment on your side after the order lands.

Pattern 4: Replacing the payment step

If you need a processor Brainerce doesn't support natively, such as a national bank's direct-debit or a country-specific gateway, there is no self-serve path today: the app platform is not open to third-party payment providers. Ask Brainerce to add the processor rather than routing the payment outside the checkout, which leaves the order with no tender record against it.

Observability

Subscribe to checkout.completed and order.created to drive your own systems. Both carry the full order, so you don't need a follow-up API call:

{
  "id": "evt_9f2c...",
  "type": "order.created",
  "createdAt": "2026-08-23T10:04:11.522Z",
  "data": {
    "orderId": "ord_xyz",
    "order": {
      "orderNumber": "ORD-20260823-0007",
      "status": "PENDING",
      "financialStatus": "paid",
      "totalAmount": 129.9,
      "currency": "USD",
      "notes": "Please ring the bell twice",
      "customFieldValues": { "gift_message": "Happy birthday, Mom!" }
    }
  }
}

Your checkout custom fields and the shopper note both round-trip into that payload. Note that the payload deliberately omits internal fields and signed download URLs.

Don't read status as the payment state: a freshly paid order arrives as status: "PENDING", which is the fulfilment pipeline saying nobody has worked it yet. Payment is financialStatus: "paid". Verify the signature on every delivery; see Verify a webhook signature.

What Brainerce does not support

Worth knowing before you design around something that isn't there:

  • No partial or deposit payments. A checkout is paid in full or not at all. There is no way to charge a percentage now and the balance later, and no endpoint to charge an existing order again, because /v1/orders has no payment routes. Gift cards are not an exception: a card and the provider together settle the order in full at completion, which is why providerAmountDue exists as its own field. (When cards cover the whole order, providerAmountDue is "0.00" and completion is allowed with no provider charge at all.)
  • No gift-card sales from the storefront. There is no gift-card product type, and nothing on the storefront surface issues a card. A storefront only redeems: apply, remove, check a balance. Issuing is a permission-gated merchant operation, and it is on /v1: POST /v1/gift-cards with an admin API key carrying gift_cards:issue, alongside seven other admin routes (see Gift cards (administration)). ⛔ A storefront must never hold gift_cards:issue. It mints stored value, and an API key anywhere a browser can reach hands a stranger the ability to create money.
  • No subscriptions or recurring billing. The marketplace ships payment, shipping and platform-connector apps; there is no subscription app, and nothing reads recurring-billing instructions off a cart.
  • No refunds or fulfilment over the API. Both are dashboard-only. See Orders.
  • No arbitrary checkout or order metadata. Use checkout custom fields or the shopper note (Pattern 1). Line items are the one exception: addToCart accepts a metadata object per item, which is stored on the cart line.
  • No long-lived checkout. 30 minutes from creation, always.
  • No idempotency on the storefront surface. /vc routes ignore the header.