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| Status | Meaning |
|---|---|
open | Mutable. Line items, add-ons, and discount code can change. |
converted | A Checkout Session has been materialized from this cart. Cart is no longer mutable. |
abandoned | Sweep 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. |
expired | Sweep 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
| Method | Path | Purpose |
|---|---|---|
POST | /v2/cart-sessions | Create an empty cart for a currency. |
GET | /v2/cart-sessions/:id | Fetch the current cart state. |
POST | /v2/cart-sessions/:id/line-items | Add a line item. |
PATCH | /v2/cart-sessions/:id/line-items/:lineId | Update a line item. |
DELETE | /v2/cart-sessions/:id/line-items/:lineId | Remove a line item. |
POST | /v2/cart-sessions/:id/add-ons | Toggle an add-on product on or off. |
POST | /v2/cart-sessions/:id/discount-code | Set or clear the discount code. |
POST | /v2/cart-sessions/:id/contact | Capture customer email + marketing/recovery consent. |
POST | /v2/cart-sessions/:id/convert | Convert 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:
- Locks the cart with a compare-and-swap (
open -> converted) — concurrent callers serialize, the loser falls through to the idempotent re-read branch. - Creates the
checkout_sessionrow carrying the cart snapshot (line items, add-ons, currency, totals). - Sets
cart.converted_to_checkout_session_idso the cart points at its successor. - Emits
commerce.cart.convertedandcommerce.checkout.startedthrough 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 code | Cause |
|---|---|
CART_SESSION_EMPTY | The cart has no line items. |
CART_SESSION_NOT_FOUND | No cart for this id under the calling merchant. |
CART_SESSION_CLOSED | The 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
| Operation | Idempotent? | Behaviour on retry |
|---|---|---|
| Toggle add-on | Yes | Same desired state -> no-op, no event. |
| Apply discount code | Yes | Same code (including null) -> no-op, no event. |
| Convert | Yes | Returns the existing Checkout Session, emits no further events. |
| Add / update / remove line item | Yes per cart version | A 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, recordscommerce.cart.abandonedv1.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.
| Event | When it fires |
|---|---|
commerce.cart.created | Cart was created. |
commerce.cart.updated | Line items added, updated, or removed. |
commerce.cart.addon_toggled | Add-on selection changed. |
commerce.cart.discount_applied | Discount code was set or cleared. |
commerce.cart.converted | Cart converted into a pending Checkout Session. |
commerce.cart.abandoned | Sweep transitioned a stuck open cart with line items past the inactivity cutoff. |
For events delivered as public webhooks, see the event catalogue.
Related
- 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