Cart Sessions

Stage a purchase with line items, add-ons, and a discount code before converting to a Checkout Session

Cart Sessions are the staging primitive that sits in front of a Checkout Session. A cart holds the line items, selected add-ons, and an optional discount code while the customer is still composing their purchase. When the customer is ready to pay, you convert the cart and RevKeen returns a Checkout Session with a session_token for the hosted /p/{session_token} URL.

A Cart Session is the only path that produces an Invoice, Subscription, or Order from cart-like state — no other API path takes a partial purchase and turns it into a billable artifact.

Best For

  • Multi-step purchase composition where line items, add-ons, and a discount code are decided across multiple requests.
  • Drawer-cart UIs on a merchant storefront that need a persistent server-side cart before checkout.
  • Flows that want cart mutations recorded as domain events in the transactional outbox alongside the state change, for analytics.

If you have everything needed for a purchase in one request, use a Checkout Session directly and skip the cart.

Lifecycle

        create / mutate
              v
            open  ── convert ──>  converted  ── (checkout session takes over)
              │
              ├── inactivity > 60 min + has line items ──>  abandoned
              │
              └── now > expiresAt ──>  expired
StatusMeaning
openMutable. Line items, add-ons, and discount code can change.
convertedA Checkout Session has been materialized from this cart. Cart is no longer mutable.
abandonedSweep transitioned the cart after > 60 min of inactivity with line items. Records commerce.cart.abandoned in the domain-event outbox; it is not delivered as a public webhook.
expiredSweep transitioned the cart after it crossed expiresAt (default 24 hours). No event emitted.

Cart expiry, abandonment cutoff, and sweep cadence are platform defaults today. Per-merchant configurability ships in a follow-up.

Endpoints

MethodPathPurpose
POST/v2/cart-sessionsCreate an empty cart for a currency.
GET/v2/cart-sessions/:idFetch the current cart state.
POST/v2/cart-sessions/:id/line-itemsAdd a line item.
PATCH/v2/cart-sessions/:id/line-items/:lineIdUpdate a line item.
DELETE/v2/cart-sessions/:id/line-items/:lineIdRemove a line item.
POST/v2/cart-sessions/:id/add-onsToggle an add-on product on or off.
POST/v2/cart-sessions/:id/discount-codeSet or clear the discount code.
POST/v2/cart-sessions/:id/contactCapture customer email + marketing/recovery consent.
POST/v2/cart-sessions/:id/convertConvert the cart into a Checkout Session.

All write endpoints require the cart:write scope; reads require cart:read.

Add-Ons

Add-ons are pre-defined optional products attached to a cart's addOnsOffered. The customer toggles them on or off; toggling is idempotent (sending selected: true for an already-selected product is a no-op and emits no event).

await client.cart.sessionsToggleAddOn(cart.data.id, {
  product_id: "22222222-2222-4222-8222-222222222222",
  selected: true,
});

Discount Codes

A cart can carry one discount code at a time. The code is validated against the merchant's active discounts and priced into the cart total immediately — total_minor reflects the discount as soon as it is applied, and convert carries the discounted amount onto the checkout session.

await client.cart.sessionsApplyDiscountCode(cart.data.id, { code: "SAVE10" });

// Clear with code: null
await client.cart.sessionsApplyDiscountCode(cart.data.id, { code: null });

Convert

Convert is the atomic boundary from cart to checkout. It runs inside one transaction that:

  1. Locks the cart with a compare-and-swap (open -> converted) — concurrent callers serialize, the loser falls through to the idempotent re-read branch.
  2. Creates the checkout_session row carrying the cart snapshot (line items, add-ons, currency, totals).
  3. Sets cart.converted_to_checkout_session_id so the cart points at its successor.
  4. Emits commerce.cart.converted and commerce.checkout.started through the outbox.

Convert is idempotent. A second POST /:id/convert after success returns the existing Checkout Session and emits no further events.

Validation that runs inside the lock and rolls back the entire transaction on failure:

Error codeCause
CART_SESSION_EMPTYThe cart has no line items.
CART_SESSION_NOT_FOUNDNo cart for this id under the calling merchant.
CART_SESSION_CLOSEDThe cart is already abandoned or expired.

On rollback the cart status is restored to open — the customer can retry after fixing the cause.

If you have no online payment method enabled and live for the cart (or none that can collect the cart's currency), convert returns 503 with code: "NO_PAYMENT_METHODS_AVAILABLE" and the cart stays open. Enable a payment method in Settings → Payments, then convert again; retrying without changing your configuration returns the same response.

TypeScript Example

This server-side example uses @revkeen/sdk@1.20260822.1541. Replace product UUIDs with products owned by your staging merchant and configure the add-on and discount before running. Store the merchant secret key on your server. For a browser storefront use server-priced product references.

import { RevKeenClient } from "@revkeen/sdk";

const apiKey = process.env.REVKEEN_API_KEY;
if (!apiKey) throw new Error("Set REVKEEN_API_KEY to your staging API key");
const client = new RevKeenClient({ apiKey, baseUrl: "https://staging-api.revkeen.com/v2" });

// 1. Create an empty cart
const cart = await client.cart.sessionsCreate({
  currency: "GBP",
  mode: "payment",
  add_ons_offered: ["22222222-2222-4222-8222-222222222222"],
});

// 2. Add a line item
await client.cart.sessionsAddLineItem(cart.data.id, {
  product_id: "11111111-1111-4111-8111-111111111111",
  name: "Annual plan",
  quantity: 1,
  unit_price_minor: 9900,
  currency: "GBP",
});

// 3. Toggle an add-on
await client.cart.sessionsToggleAddOn(cart.data.id, {
  product_id: "22222222-2222-4222-8222-222222222222",
  selected: true,
});

// 4. Apply a configured discount code (omit if your merchant has none).
await client.cart.sessionsApplyDiscountCode(cart.data.id, { code: "SAVE10" });

// 5. Convert into a hosted Checkout Session
const result = await client.cart.sessionsConvert(cart.data.id);

const sessionToken = result.data.checkout_session.session_token;
if (!sessionToken) throw new Error("Checkout Session has no hosted token");
const checkoutUrl = new URL(`/p/${encodeURIComponent(sessionToken)}`, "https://pay.staging.revkeen.com");
// Redirect the customer to this URL from your application.
console.log(checkoutUrl.href);

cURL Example

# Convert an open cart into a Checkout Session
curl -X POST "https://api.revkeen.com/v2/cart-sessions/${REVKEEN_CART_ID}/convert" \
  -H "x-api-key: $REVKEEN_API_KEY"
{
  "data": {
    "cart_session": {
      "id": "11111111-1111-4111-8111-111111111111",
      "status": "converted",
      "converted_to_checkout_session_id": "22222222-2222-4222-8222-222222222222"
    },
    "checkout_session": {
      "id": "22222222-2222-4222-8222-222222222222",
      "session_token": "rvk_cs_example",
      "status": "pending",
      "currency": "GBP",
      "amount_minor": 9900
    }
  }
}

The JSON above is an abbreviated response. Cart conversion returns session_token and amount_minor; it does not return a url field. Use the checkout host matching your API environment (pay.staging.revkeen.com for staging, pay.revkeen.com for production). Never log real hosted session tokens.

Idempotency and Retries

OperationIdempotent?Behaviour on retry
Toggle add-onYesSame desired state -> no-op, no event.
Apply discount codeYesSame code (including null) -> no-op, no event.
ConvertYesReturns the existing Checkout Session, emits no further events.
Add / update / remove line itemYes per cart versionA duplicate write to the same product on the same line is treated as an update.

All mutations also write to the domain-event outbox inside the same transaction as the state change, so emitted events always match committed cart state.

Abandonment and Recovery

A background sweep runs every 15 minutes (cart-abandonment-sweep on cron-worker). It scans for open carts that have been inactive longer than the cutoff (60 minutes by default) and transitions them:

  • Has line items -> abandoned, records commerce.cart.abandoned v1.0 in the outbox (not a public webhook).
  • No line items -> left as-is; the next sweep window catches it once it crosses expiresAt.
  • Past expiresAt (regardless of contents) -> expired, no event.

To run your own recovery flow (email, SMS, retargeting), use the contact captured on the cart (POST /v2/cart-sessions/:id/contact) and read current cart state (GET /v2/cart-sessions/:id). The commerce.cart.* events are internal outbox events and are not delivered as public webhooks. commerce.cart.recovered for re-engaged carts ships in a follow-up.

Events

Cart lifecycle transitions are recorded as commerce.* domain events in the transactional outbox, in the same transaction as the state change. These are internal events: they are not delivered as public webhooks.

EventWhen it fires
commerce.cart.createdCart was created.
commerce.cart.updatedLine items added, updated, or removed.
commerce.cart.addon_toggledAdd-on selection changed.
commerce.cart.discount_appliedDiscount code was set or cleared.
commerce.cart.convertedCart converted into a pending Checkout Session.
commerce.cart.abandonedSweep transitioned a stuck open cart with line items past the inactivity cutoff.

For events delivered as public webhooks, see the event catalogue.

  • Checkout Sessions -- The hosted payment surface that a converted cart redirects to.
  • Checkout Links -- Reusable URLs for static product sales without server-side cart composition.
  • Webhook events -- The public event catalogue. Cart lifecycle events are not public webhooks; read current cart state from the Cart Sessions API.

Owner: david · Reviewed 1 Oct 2026 · Next review 29 Dec 2026

On this page