Skip to content

Abandoned cart recovery

Your storefront reports in-progress carts; litecommerce stores them, and a recovery cron emails customers who left one behind. Examples reuse the api() helper.

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. A cart with no email can be stored but can't be emailed a recovery. 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.

How recovery works

The recovery send is live and automated:

  1. A scheduled cron sweeps carts that have an email and aren't yet recovered (recoveredAt IS NULL).
  2. It sends a recovery email via Resend and stamps recoveryEmailSentAt and recoveryEmailCount. The send is capped (an unconverted cart gets at most a couple of nudges spaced out over ~a day, never an indefinite stream).

You don't trigger any of this — capturing the cart (with an email) is all your storefront does; the platform handles the send cadence.

Conversion attribution isn't wired yet. recoveredAt exists as a column and the recovered / all merchant filters read it, but nothing stamps it when a captured cart turns into an order today — so in practice every captured cart stays in the pending list, and the send cap (not a conversion signal) is what stops the emails. Closing that loop — matching a new order back to its cart and stamping recoveredAt — is a planned follow-up that pairs naturally with order placement at hosted checkout.