Carts
A cart is a workspace with an owner and a status. It is not a draft order: the conversion to an order is one explicit call, and after it the cart is closed and readable forever as the record of what became what.
Ownership
curl -X POST https://api.revenexx.com/v1/carts \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"contact_id":"…","name":"August reorder","is_current":true}'
The one thing a cart requires is an owner — contact_id for a signed-in customer or session_key for a guest, never neither. Everything else is defaulted: the name Cart, currency EUR, status active, and subtotal, discount_total, shipping_total, tax_total and grand_total all 0 (shipping_total stays 0 until the storefront sets one).
No column is unique, so one owner may hold as many carts as they like — unless the tenant's multi_cart_enabled is off, in which case a second active cart for the same owner answers 409 naming the cart that already exists (a storefront that hits that wants to fill that cart).
is_current: true makes the new cart current in the same call, clearing the flag on every sibling of the same owner.
organization_id (optional, uuid) names the buyer's company, as the customers app knows it. It is stored as sent.
attribution (optional object, null until written) records where the cart came from. It holds:
- the UTM values:
utm_source,utm_medium,utm_campaign,utm_termandutm_content; campaign_ref, a campaign in the campaigns app;click_idandclick_id_kind(gclid,fbclid,li_fat_idormsclkid);landing_url,referrer,first_touch_atandlast_touch_at;consent(ads,analytics,policy_versionandpurposes) andobjection_notice_shown.
The storefront writes it whole on each touch while the cart is open. A write that carries attribution to an ordered or merged cart is a 409 with attribution_frozen; other fields are not affected. Attribution does not copy itself into an order: the storefront sends it with POST /v1/orders/place, which stores its own copy. When carts merge, the surviving cart keeps its own attribution, or takes the merged cart's when it has none.
The lifecycle
| Status | Meaning |
|---|---|
active | The workspace. |
abandoned | Timed out or abandoned by hand. The one reversible ending. |
ordered | Handed to order management. Final; no route deletes it. |
merged | Folded into another cart. Final. |
POST /v1/carts/{id}/activate writes exactly one thing: is_current on this cart, cleared on every other cart of the same owner. It does not change the status, and only an active cart may be made current. Read it back with GET /v1/carts?is_current=true plus the owner — that filter is the only way to see what this route wrote.
POST /v1/carts/{id}/abandon moves an active cart to abandoned, stamps abandoned_at and clears is_current. The lines are untouched throughout.
POST /v1/carts/{id}/reopen takes an abandoned cart back to active with its lines exactly as they were — what a storefront calls when a buyer follows a recovery mail. It also clears abandoned_at, so the abandonment funnel counts carts that are still abandoned. It does not restore is_current.
ordered and merged are final and answer 400 naming the status the cart holds.
DELETE /v1/carts/{id} removes the cart and every line in it. There is no soft delete and no undo.
- An
orderedcart is refused with400, permanently. It is the source record of a sale, and no flag or route makes it deletable. active,abandonedandmergedcarts can be deleted. A merged cart's lines were copied into its target, which keeps them.- The delete does not clear
merged_into_cart_idon carts merged into the deleted one, so those pointers can dangle. - The maintenance sweep also deletes non-ordered carts past a retention window, and it can remove abandoned carts on its own. The tenant's default window is
0, which means never, but a market can set its own.
A cart a buyer walked away from is abandoned instead, which keeps the row.
Items
curl -X POST "https://api.revenexx.com/v1/carts/{cart_id}/items" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"sku":"ACME-4711-BLK","quantity":6,"unit_price":19.90,"currency":"EUR"}'
| Route | Does |
|---|---|
GET /v1/carts/{cart_id}/items | List the lines. |
POST /v1/carts/{cart_id}/items | Add one. |
PUT /v1/carts/{cart_id}/items | Replace all lines in one call. |
GET /v1/carts/{cart_id}/items/{id} | Read one line. The cart in the path must own it: a line of another cart answers 404. |
PUT/DELETE /v1/carts/{cart_id}/items/{id} | Change or remove one line. Both need an active cart. |
POST /v1/carts/{cart_id}/items/priced | Add a product priced for the cart's buyer. See Carts opened for a customer. |
A line's type is product, configuration or custom. A line carries unit_price, line_total, tax_rate, unit, position, and a snapshot of what the buyer was shown. A plain product line with the same product/sku and the same unit_price folds into an existing line by adding its quantity; configured and custom lines always land as new rows.
Totals, shipping and vouchers
The cart prices itself: the storefront hands in what the promotions app and the shipping app answered, and the cart computes the rest.
curl -X PUT "https://api.revenexx.com/v1/carts/{id}" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"voucher_codes":["FRUEHJAHR25"],"discount_lines":[{"label":"Frühjahrsaktion","detail":"Gutschein FRUEHJAHR25 · 10 %","amount":1.2,"voucher_code":"FRUEHJAHR25"}],"shipping_total":4.9,"shipping_method":"Standard"}'
All four fields are optional. discount_lines, shipping_total and shipping_method are accepted only on an active cart; voucher_codes on any.
| Field | Does |
|---|---|
voucher_codes | Replaces the codes the buyer entered, each exactly as typed, in the order entered. A discount line naming a code no longer in the list is dropped, and the totals are recomputed. |
discount_lines | Replaces the stored discounts (null clears them, at most 50 items). Each item: label (required, the promotion's name as the buyer reads it), amount (required, 0 or more, what it takes off, net), detail (a second line under the label), promotion_id, and voucher_code, which must be one of the cart's voucher_codes: the codes sent in the same call, or the stored ones when the call sends none. A line naming any other code answers 400 with voucher_not_held. |
shipping_total | Shipping as the shipping app quoted it, net (0 or more). The cart recomputes tax and the total. A shipping reduction is a discount line, not a lower amount here. |
shipping_method | The method shipping_total was quoted for, as the storefront names it (Standard, Express). Free text: nothing interprets it. |
discount_lines is the storefront's answer from the promotions app, stored as sent, after light normalising (label trimmed, amount rounded to 4 decimals, any other key dropped); until a storefront prices the cart it is null. The computed totals are never taken from a caller: sending discount_total, tax_total, grand_total or totals_computed_at answers 400 with server_owned_field.
On an active cart these are computed on every line write and every change to its vouchers, discounts or shipping. On an abandoned cart only a voucher_codes change reprices it (lines and pricing need reopen first); a voucher_codes change on an ordered or merged cart is stored but reprices nothing. All amounts are net, in the cart's currency:
| Field | Computed as |
|---|---|
subtotal | The sum of every line's line_total, before shipping and tax. Recomputed after every line write. |
discount_total | The sum of discount_lines, capped at subtotal so a cart never owes the buyer money. |
tax_total | Each line's tax rate on its line total after its share of the discount (spread over lines in proportion to their totals, as an order spreads it), plus tax on shipping split over the cart's rates in proportion to the discounted goods. |
grand_total | subtotal - discount_total + shipping_total + tax_total. Today's figure, not a promise: placing the order prices it again. |
totals_computed_at | When discount_total, shipping_total, tax_total and grand_total were last computed. Null until the cart is first repriced, including on a cart created before the figures existed. |
voucher_codes is buyer intent, not a result. The cart never evaluates a code: the storefront asks the promotions app what the codes are worth and sends the answer as discount_lines. Until it sends new lines, the stored discount stays in the totals, even for a code that has since expired. Keeping the codes means a returning buyer does not re-type them; an expired code stays in the list.
Filters on GET /v1/carts. subtotal, discount_total, shipping_total, tax_total and grand_total (numbers), shipping_method (string) and totals_computed_at (one exact date-time) match exactly; there are no ranges. is_guest (boolean) keeps only guest carts (true) or only customer carts (false), and organization_id (uuid) keeps the carts of one company. ?discount_total=0 finds carts nothing was taken off, and ?shipping_total=0 finds carts shipping free or not quoted yet. They find carts; they are not a way to add carts up.
Claiming a guest cart at login
curl -X POST https://api.revenexx.com/v1/carts/claim \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"session_key":"sess_…","contact_id":"…"}'
The route a storefront fires the moment somebody signs in with a basket already filled. It hands every active cart of one session_key to a contact_id, and the body picks between two landings:
- Without
target_cart_idthe session carts are adopted as they stand — same carts, same lines,contact_idset andsession_keycleared. Nothing copied, nothing closed. - With
target_cart_idthey are folded into that cart, which survives while each session cart is closed asmerged.
organization_id (optional, uuid) names the contact's company. It is written onto every cart that is adopted as it stands; omit it and an adopted cart keeps what it had.
The response's adopted and merged arrays say which happened to each. With a target, the tenant's cart_merge_strategy decides what happens to the target's own lines: merge keeps them and folds the session lines in, replace clears them first. Send strategy to override it for one call; the answer echoes which ran and how many lines a replace removed.
Carts opened for a customer
An operator takes an order by phone, fax, mail or in the field. POST /v1/carts/on-behalf opens an active cart owned by the contact. It records how the order arrived and your note in metadata. The answer is the new cart, with its id, which the next calls need. The cart is never made current, so the buyer's own basket stays where it was.
curl -X POST https://api.revenexx.com/v1/carts/on-behalf \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"contact_id":"<CONTACT_ID>","channel":"phone","note":"Call back after 14:00"}'
contact_idis required.channelis one ofphone,fax,emailorfield(defaultphone).nameandcurrencyare optional.organization_idis stored as sent and never looked up.channel_idis stored for attribution.- One active cart per contact. With the tenant's
multi_cart_enabledoff, a contact who already has an active cart is refused with409, and the message names that cart's id. - Lines go in with
POST /v1/carts/{cart_id}/items/priced, naming aproduct_idor asku, with an optionalquantity(default 1). The line gets the price the shop shows this buyer. An agreedunit_priceoverrides the price, never the tax rate. For a product the prices app cannot price, sendunit_priceandtax_ratetogether. Without an agreed price, a product the prices app cannot price answers422no_price. Without a tax rate it answers422no_tax_rate. - Checkout is the same as for any stored cart (see Conversion to an order), so the customer's approval rules and budgets apply.
- These calls are for the operator. A request that acts for a contact is refused with
403. A buyer opens their own carts withPOST /v1/carts.
Merging
Which cart survives is the whole question, and the answer is the target.
| Route | Source | Use when |
|---|---|---|
POST /v1/carts/merge | source_cart_id in the body | You hold both ids. |
POST /v1/carts/{id}/merge-into | the path | You hold one id — a Cockpit row action, a detail page. |
The source's lines are copied into the target; the target keeps every line it already had and its totals are recomputed. The source keeps its own line rows and is closed with status: "merged" and merged_into_cart_id pointing at the target, so a merged cart stays readable as the record of what went where.
merge-into, read the path id as "the cart I am giving away". Getting the two the wrong way round is the mistake this route exists to make hard.Both carts must be active and must differ. The tenant's line limits are enforced on the target as the copies land (422).
Conversion to an order
curl -X POST "https://api.revenexx.com/v1/carts/{id}/order" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"order_ref":"ORD-000123"}'
An active cart becomes ordered, ordered_at is stamped, and order_ref — order management's own number — is stored on the cart, which is what lets anyone get from an order number back to the cart behind it. Nothing moves out of ordered.
The conversion applies the two tenant decisions a cart cannot make for itself:
price_snapshot_mode(snapshot|live) settles which of a line's two prices is charged — the snapshot the buyer was shown, or the currentunit_price— and the cart's subtotal is rewritten to match, so cart and order can never disagree. The response'spricingblock reports the mode, the lines it rewrote, and the subtotal on both sides.convert_reserves_stock(never|request|require) decides whether inventories is asked to hold the lines. Atrequirea refusal answers409and the cart stays active and unchanged. The reservation is attempted before anything is written.
This route does not create the order. POST /v1/orders/place does.
Bulk lines: import and export
The carts app keeps its own small import/export surface, separate from the shared /v1/io one.
curl -X POST https://api.revenexx.com/v1/carts/import \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"target_cart_id":"…","csv":"sku,quantity\nACME-4711-BLK,6\nACME-4712,2\n"}'
This is the bulk-order path a buyer pastes a spreadsheet into. With target_cart_id the lines land in that cart, which must be active, and the profile's apply_mode decides the fate of the lines already there (replace clears first; insert and append both add). Without a target a new cart is created, and an owner is then required.
profile_id names an import profile; without one the payload is read ad hoc — as Excel when xlsx is present, as CSV when csv is present, as JSON otherwise. xlsx carries an .xlsx workbook, base64-encoded, in place of csv ({"target_cart_id":"…","xlsx":"<base64 of the file>"}). The first sheet is read, its first non-empty row is the header, and empty rows are skipped. Without a profile the header names are the line fields themselves; with one they are what its mapping expects (the bundled Excel template reads sku, name, quantity, unit_price). A file that is not an .xlsx workbook, or cannot be opened, answers 400 invalid_xlsx. Lines fold into identical product lines exactly as a single add does, so imported_lines counts the lines read and the cart may have gained fewer rows. A payload that parses to no line at all is a 400, not a quiet no-op.
curl -X POST "https://api.revenexx.com/v1/carts/{id}/export" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"format":"csv"}'
The JSON form is {cart: {…}, items: […]} and is exactly what carts/import takes back, so an export round-trips. The CSV form is the lines only, header first, and "format":"xlsx" returns the same lines as an Excel workbook, base64-encoded, in the answer's content, with encoding: "base64" to say so. json is the default. Nothing is stored and nothing about the cart changes; a cart of any status can be exported, including one already ordered.
To export many carts at once, use POST /v1/carts/export. It renders every cart a filter selects, one page at a time, through the profile profile_id names or, without one, in the canonical shape format picks (json, csv or xlsx; json by default). Each cart's document is what the single-cart export above answers for it the same way: JSON gives {carts: [{cart, items}, …]}, each element importable on its own through carts/import; CSV gives every cart's lines under one header, each row led by cart_id.
curl -X POST "https://api.revenexx.com/v1/carts/export" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"profile_id":"<PROFILE_ID>","status":["active","abandoned"],"created_from":"2026-09-01","created_to":"2026-09-30","market":"<MARKET_CODE>","limit":50}'
The filters are contact_id, status (one value, an array or a comma-separated list), created_from and created_to (a bare created_to date includes the whole day) and market (its code, case-insensitive; a cart in no market is left out). filter in the answer echoes what was applied. The call never answers "all": it scans at most limit carts (1 to 200, default 50) in id order and returns next_cursor. Send it as cursor and keep calling until next_cursor is null. A page can hold fewer carts than limit, even none, and still have a next one, because the end of the date range and the market are checked against each scanned cart. Nothing is stored and no cart changes.
400 covers an unknown status, an unreadable date, a range that ends before it starts and a limit outside 1 to 200. 404 is an unknown profile_id. A switched-off profile is 422 profile_inactive: nothing runs and nothing falls back to the canonical shape.
Profiles live at /v1/carts/io/profiles, with POST /v1/carts/io/profiles/defaults to seed the bundled templates. Handing an import profile to the export route is a 400 — a profile only runs the way it declares.
| Route | Does |
|---|---|
GET /v1/carts/io/profiles | Lists the profiles. Filters include direction, is_template and active. The call can restore a deleted bundled template: it re-creates any missing template, in its shipped shape, checked about once every ten minutes per tenant. A template you edited or switched off is left alone, and a failed re-create does not fail the list. A picker for export sends ?direction=export&active=true: export refuses an inactive profile with 422 profile_inactive. |
POST /v1/carts/io/profiles | Creates a profile. name (unique in the tenant) and direction are required. entity defaults to whole carts, format to JSON and apply_mode to insert. Creating a profile moves no data. |
GET /v1/carts/io/profiles/{id} | Reads one profile: its direction, entity, format, mapping, apply_mode, whether it is a template and whether it is active. Runs nothing. |
DELETE /v1/carts/io/profiles/{id} | Deletes a profile. Nothing in the app points at a profile, so nothing is orphaned; a later run that names its profile_id answers 404. A deleted bundled template comes back, in its shipped shape, on a later read of GET /v1/carts/io/profiles (see that row) or on POST /v1/carts/io/profiles/defaults. To retire a template, switch it off with active: false instead. |
GET /v1/carts/io/fields | The fields a profile's column mapping can name, for entity=carts (default) or cart_items. role=key lists the match keys only. |
POST /v1/carts/io/profiles/{id}/copy | Copies a profile under a new name, which must be unique. Use it to change a bundled template without editing the template. |
PUT /v1/carts/io/profiles/{id} | Edits a profile in place, bundled templates included. active: false switches it off: it is no longer offered, and a run through it is refused with 422 profile_inactive until it is switched back on. |
The maintenance sweep
POST /v1/carts/maintenance/run does two sweeps in one pass:
abandon_after_minutesmarks active carts untouched past the window as abandoned. Nothing else in the platform setsabandoned_at, so without this the abandonment funnel is empty by construction.cart_ttl_days/guest_cart_ttl_daysthen delete carts past their retention window, line items included. Both default to0(never).
An ordered cart is never touched at any setting — it is the source record of a sale. Send dry_run for the same counts and cart ids while writing nothing. The platform runs this on a schedule; it is idempotent, so calling it by hand between ticks is safe.
Which codes open carts hold
Before an app deletes a promotion or a voucher code for good, it can ask which codes open carts still hold. A code sitting in a cart that can still become an order would point at nothing once the promotion is gone.
curl "https://api.revenexx.com/v1/carts/held-codes?code=FRUEHJAHR25,B2B-10&status=active,abandoned" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
| Parameter | Does |
|---|---|
code | One code, or several separated by commas (at most 200). Left out, every code an open cart holds is listed. |
status | The cart statuses that count, comma-separated. Default active,abandoned, so an ordered cart does not count. |
limit / offset | Paging: 50 rows by default, 200 at most. Raise offset by limit until page.hasMore is false. |
Open means active or abandoned, and abandoned counts because it is the reversible ending. A code is matched trimmed and case-insensitively and reported upper-cased, so a tenant whose codes are case-sensitive gets a count that is at worst too high.
The answer lists items, one per code and sorted by code, each with carts: how many carts in the asked statuses hold that code. The top-level carts counts the distinct carts behind all the listed codes, across every page, so a cart that holds two of them counts once. page.hasMore tells a truncated list from a complete one. The answer names no cart and no owner.
400 means code names no code or more than 200, or status names a status a cart cannot have. The other errors are 401, 403, 429 and 502.
Vocabularies
The enums this app owns are published as vocabularies. A vocabulary is a closed set of values with a title, a description and a badge tone for each value, which a select or a status chip can use without a copy of its own.
| Route | Does |
|---|---|
GET /v1/carts/vocabularies | Lists the names, each with its title and description. No values. |
GET /v1/carts/vocabularies/{name} | One vocabulary with its values, in the order a select should offer. |
The names are io-apply-modes, io-directions, io-entities, io-formats, item-types and statuses.
Each value carries key, title, description (which can be null), tone and final. final only means something in statuses, where it is true for the two endings, ordered and merged. In io-apply-modes, io-directions, io-entities, io-formats and item-types it is always false. Every vocabulary carries closed, always true for carts: the set is exhaustive, so a value outside it is stale data.
curl "https://api.revenexx.com/v1/carts/vocabularies/statuses" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
Where to go next
- Recover abandoned carts with a workflow — react to
cart.abandoned, send a reminder and reopen the cart. - Orders — what happens after the hand-over.
- Order lists — a saved collection that fills a cart.
- Stock — what
convert_reserves_stockcalls. - Storefront accounts — the login that triggers a claim.