Skip to content

Abandoned checkouts and cart capture

In active development — E3 / M49: the hosted checkout recovery contract below is the bounded #5338 update. It is not yet a production availability claim. The legacy anonymous cart capture API remains shipped and separate. Examples reuse the api() helper.

Save hosted checkout contact early

PUT /api/v1/public/checkout/sessions/:token/contact accepts the tenant header and checkout token. The request has a required valid email and optional name and phone; a successful response is { saved: true }.

await api(`/public/checkout/sessions/${checkoutToken}/contact`, {
  method: "PUT",
  body: JSON.stringify({ email: "dana@example.com" }),
});

cartonium checkout saves a valid email while the shopper fills in Contact, uses the signed-in shopper's selected email, and awaits the save before continuing. The shopper does not need to reach payment. This is a contact-only snapshot: it does not verify account ownership, attach a Customer, reprice, extend the checkout expiry, bind checkout, or create a payment attempt. Existing identity verification at checkout binding still applies.

The operation only accepts open, unbound, unexpired one-time product sessions without a completed purchase or active payment. Invalid, expired, completed, or foreign-tenant tokens return 404; invalid contact data returns 400. An unchanged contact does not refresh the inactivity timer.

Review abandoned hosted checkouts (merchant)

GET /api/v1/merchant/abandoned-checkouts requires merchant authentication and tenant membership. It accepts limit, cursor, and an optional email search. The response contains items, nextCursor, and hasMore. Each item provides the checkout id, contact email/name, currency, saved total, item count, last activity, recoveryStatus, canSend, and an optional unavailableReason. No public checkout token is returned.

Candidates are hosted one-time product checkouts inactive for at least one hour and updated within the last 90 days, with contact email, no completed purchase, and no unresolved payment. The server uses its hosted session classification or a contact-save event, never anonymous cart metadata. Bookings, subscriptions, and legacy cart captures do not populate this list.

Request a manual recovery email (merchant)

POST /api/v1/merchant/abandoned-checkouts/:id/recovery-email requires an Owner, Admin, or Staff membership with orders:operate. It takes no recipient, cart, or link override: the server selects those from the scoped checkout and tenant configuration. The response contains outboxEventId and status.

Repeated requests reuse the durable intent for the unchanged checkout. Status is QUEUED, ACCEPTED, BLOCKED, or FAILED; the list additionally uses NOT_SENT before a request exists. ACCEPTED means provider acceptance, not inbox delivery. Eligibility, checkout completion, contact changes, recipient suppression, and the isolated marketing transport are checked again at send time. Request success does not guarantee delivery. Missing or ineligible checkouts, unavailable sending configuration, or a changed checkout can prevent the request or block later processing.

The email links back to the tenant's storefront cart. It does not restore the exact cart on another browser or device, carry checkout authorization, revive an expired payment session, or reserve the old prices and inventory. The shopper starts a new checkout from the storefront's current cart. Recovery emails include an unsubscribe action and respect existing recipient opt-outs.

No automatic recovery emails or reminder schedules are enabled. The stable template identifier remains ABANDONED_CART_RECOVERY; its merchant label is Abandoned checkout recovery. Template previews and test sends remain separate from merchant recovery requests.

Migrate a storefront from legacy capture

These steps apply to an approved tenant migration, starting in staging. Keep production capture in place until the matched storefront/platform rollout is qualified and its production promotion is authorized. Existing integrations outside that approved rollout should retain legacy capture while hosted recovery remains in active development.

In the migration's staging storefront, remove its optional POST /api/v1/public/abandoned-carts instrumentation: cart-close, idle, navigation and beforeunload handlers, plus any imperative capture calls. Remove unused capture helpers and their local identity generation after checking all callers. This is a storefront change; the shared capture and merchant review APIs remain supported for existing integrations.

Keep the shopper's persisted cart, checkout handoff, pending-checkout records, purchase reconciliation and ordinary cart pricing intact. Removing the capture hook requires no cart reset, historical capture deletion or credential cleanup. Do not replace it with a checkout CREATE on abandonment, or turn a legacy cart session id or capture token into a hosted checkout token.

When the shopper chooses hosted checkout, keep using the storefront's selected checkout creation and handoff contract. cartonium checkout owns the early Contact save described above. No extra storefront Contact call is needed for that hosted flow. A shopper who leaves the storefront before entering hosted checkout and saving contact does not become a hosted recovery candidate.

Test the matched storefront, API, cartonium checkout and Admin staging builds before coordinating production promotion:

  • Add, edit and reload a cart, close its drawer, navigate away and leave the page. Those actions must no longer send legacy capture requests, and the cart must persist normally.
  • Start checkout through the existing handoff and verify Contact saves without reaching payment. After the documented inactivity period, verify the eligible hosted checkout appears in Admin; anonymous cart snapshots remain excluded. Verify a changed cart and checkout return still behave correctly.
  • Verify manual recovery separately with approved synthetic recipients and sending configuration. A template test or an empty error window does not prove recovery. Verify the email returns to the current storefront cart; it does not promise exact cart restoration or payment-session revival.
  • Bind the results to the exact tested builds, tenant origin and selected commerce mode. Deployment environment alone does not select Live or Sandbox data. Repeat affected checks if those inputs change.

The hosted feature remains in active development until its production release is qualified. Keep the legacy API available during a coordinated rollout and rollback; removing one storefront's caller does not retire it for other tenants.

Abandoned cart capture and review

The following legacy API accepts anonymous storefront snapshots. Anonymous cart capture does not enable recovery emails. It does not populate the hosted abandoned-checkout page or establish hosted checkout identity. Existing integrations can continue using the capture and merchant review APIs.

Capture a cart (public)

POST /api/v1/public/abandoned-carts — tenant header, no auth. Call it as the cart changes; it upserts on sessionId, so repeated calls update one row rather than piling up duplicates.

Because the endpoint is anonymous and the sessionId isn't a secret, updating a cart requires a capture token: a high-entropy value your storefront generates once, stores next to the sessionId, and sends on every call. The first capture binds it; later updates must present the same token or are rejected with 403. Generate it client-side and reuse it — the API never returns it. (A capture sent without a token still works, but creates an unprotected row anyone who learns the sessionId could overwrite, so always send one.)

// Generate + persist both once per browser, then reuse on every capture.
function captureIds() {
  let sessionId = localStorage.getItem("lc_cart_session");
  if (!sessionId) {
    sessionId = crypto.randomUUID();
    localStorage.setItem("lc_cart_session", sessionId);
  }
  let captureToken = localStorage.getItem("lc_cart_token");
  if (!captureToken) {
    const bytes = crypto.getRandomValues(new Uint8Array(32));
    captureToken = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
    localStorage.setItem("lc_cart_token", captureToken);
  }
  return { sessionId, captureToken };
}
 
const { sessionId, captureToken } = captureIds();
 
await api("/public/abandoned-carts", {
  method: "POST",
  body: JSON.stringify({
    sessionId,                    // required, 8–200 — your storefront session id
    captureToken,                 // your generated token, 16–128 — send it every time
    snapshot: {                   // required — frozen cart state
      items: [{ itemId: "…", variantId: "…", name: "Trail Pack 38L", quantity: 1, unitPriceInCents: 18900 }],
      subtotalInCents: 18900,
      totalInCents: 18900,
    },
    email: "dana@example.com",    // optional — capture once you have it
  }),
});
// → { ok: true, wasCreated: boolean }   (no token returned — you already hold it)

email is optional so you can capture the cart before you know who it belongs to, then fill it in once the shopper enters it. Storing an address is not consent or authorization to send recovery email. Once an email is set, it's frozen — a later capture won't overwrite it with a different address.

Review captures (merchant)

GET /api/v1/merchant/abandoned-carts?filter=pending — open to any member. filter is pending (not yet recovered, the default), recovered, or all.

Interpreting legacy recorded state

recoveredAt and the recovered / all filters remain part of the stored contract, but order creation does not currently stamp a captured cart as recovered. Likewise, recoveryEmailCount and recoveryEmailSentAt are recorded fields, not evidence that a recovery automation is enabled. Do not infer a send or conversion from capture success.