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 hosted Checkout Session URL to redirect to.
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 need server-side mutation events (add-on toggled, discount applied) for analytics or webhook consumers.
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. Triggers commerce.cart.abandoned for downstream recovery. |
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. |
PATCH | /v2/cart-sessions/:id | Add, update, or remove line items. |
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/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.cartSessions.toggleAddOn(cart.id, {
product_id: "prod_priority_support",
selected: true,
});Discount Codes
A cart can carry one discount code at a time. Today the code is stored only — pricing math is not applied yet (totals are unchanged). Convert blocks with CART_SESSION_DISCOUNT_PENDING if the cart has a discount code, so a cart with a discount can't accidentally convert before pricing lands.
await client.cartSessions.applyDiscountCode(cart.id, { code: "SAVE10" });
// Clear with code: null
await client.cartSessions.applyDiscountCode(cart.id, { code: null });Until the discount-pricing slice ships, you must clear the discount code (code: null) before calling convert, or convert returns CART_SESSION_DISCOUNT_PENDING.
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_DISCOUNT_PENDING | The cart has a discount_code set. Clear it first. |
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.
TypeScript Example
import { Revkeen } from "@revkeen/sdk";
const client = new Revkeen({ apiKey: process.env.REVKEEN_API_KEY });
// 1. Create an empty cart
const cart = await client.cartSessions.create({
currency: "GBP",
mode: "payment",
});
// 2. Add a line item
await client.cartSessions.addLineItem(cart.data.id, {
product_id: "prod_main",
name: "Annual plan",
quantity: 1,
unit_price_minor: 9900,
currency: "GBP",
});
// 3. Toggle an add-on
await client.cartSessions.toggleAddOn(cart.data.id, {
product_id: "prod_priority_support",
selected: true,
});
// 4. Apply a discount code (stored only today — pricing math follows)
// Skip or clear before convert until the pricing slice ships.
await client.cartSessions.applyDiscountCode(cart.data.id, { code: null });
// 5. Convert into a hosted Checkout Session
const result = await client.cartSessions.convert(cart.data.id);
return redirect(result.data.checkout_session.url);cURL Example
# Convert an open cart into a Checkout Session
curl -X POST "https://api.revkeen.com/v2/cart-sessions/cart_123/convert" \
-H "x-api-key: $REVKEEN_API_KEY"{
"data": {
"cart_session": {
"id": "cart_123",
"status": "converted",
"converted_to_checkout_session_id": "cs_456"
},
"checkout_session": {
"id": "cs_456",
"url": "https://pay.revkeen.com/p/rvk_cs_456",
"status": "pending",
"currency": "GBP",
"amount_cents": 9900
}
}
}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, emitscommerce.cart.abandonedv1.0. - No line items -> left as-is; the next sweep window catches it once it crosses
expiresAt. - Past
expiresAt(regardless of contents) ->expired, no event.
Subscribe to commerce.cart.abandoned via webhooks to trigger your own recovery flow (email, SMS, retargeting). commerce.cart.recovered for re-engaged carts ships in a follow-up.
Events
| 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. |
See the event catalogue for the canonical list and envelope format.
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 -- Subscribe to
commerce.cart.*for analytics, recovery, and integration hand-offs.