Abandoned cart
The abandoned cart resource governs the recovery flow that emails buyers when they leave items in their cart without checking out. Ripllo owns the configuration, the suppression list, the reminder history, the recovery accounting, and the one-click public unsubscribe page.
It does not own the cart itself. The partner platform (Storlaunch) detects abandonment, snapshots the cart, and calls Ripllo to record the reminder. When the buyer eventually completes a checkout, the partner platform calls back to mark the cart recovered. Ripllo's role is to track configuration, enforce the opt-out list, keep the reminder log, and compute recovery rate.
Ripllo does not send the reminder email today. Email-send infrastructure is deliberately not part of this module yet — the partner platform composes and sends, then tells Ripllo about it via POST /reminders. The config fields below (enabled, delayHours, emailSubject, emailPreview) are stored for the partner's sweep job to read; Ripllo itself never acts on them.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/abandoned-cart/config |
Get config |
PATCH |
/api/v1/abandoned-cart/config |
Update config |
GET |
/api/v1/abandoned-cart/reminders |
Recent reminders |
GET |
/api/v1/abandoned-cart/stats |
Recovery stats |
GET |
/api/v1/abandoned-cart/suppressions |
List suppressions |
POST |
/api/v1/abandoned-cart/suppressions |
Add a suppression |
DELETE |
/api/v1/abandoned-cart/suppressions/:email |
Remove a suppression |
POST |
/api/v1/abandoned-cart/reminders |
Record a reminder (partner platform, signed) |
POST |
/api/v1/abandoned-cart/recover |
Mark a cart recovered (partner platform, signed) |
GET |
/api/v1/abandoned-cart/unsubscribe |
Public unsubscribe page (HTML, no auth) |
Get config
GET /api/v1/abandoned-cart/config
Returns the merchant's abandoned-cart config. If no config exists yet, Ripllo returns the default stub so the dashboard never has to special-case "first visit":
{
"data": {
"enabled": false,
"delayHours": 4,
"emailSubject": "You left something in your cart",
"emailPreview": "Come back to finish your order",
"discountCodeId": null
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Update config
PATCH /api/v1/abandoned-cart/config
Partial update. Sends only the fields you want to change.
Request body
| Field | Type | Notes |
|---|---|---|
enabled |
boolean | Master on/off, read by the partner's sweep job. Ripllo stores it but never consults it — POST /reminders records a row whether enabled is true or false. |
delayHours |
integer (1–168) | How long after detected abandonment the partner should send the reminder. Default 4. 168 = 7 days. |
emailSubject |
string (≤200) | Email subject line for the partner's template. Liquid-style {{ first_name }} placeholders are a partner-side convention, not rendered by Ripllo. |
emailPreview |
string (≤200) | Preview text shown in the inbox before the email is opened. |
discountCodeId |
string | null | A discount-code ID to inject into the reminder email as a one-click apply link. Must exist in this workspace. Pass null to remove. |
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION |
Shape wrong, delayHours out of range, supplied discountCodeId not found in this workspace, or a marketingCampaignId that isn't in this workspace. |
Don't send
marketingCampaignIdhere. The validator accepts the field and checks the campaign belongs to your workspace, butAbandonedCartConfighas no campaign column — the migration only added it toAbandonedCartReminder. Passing it (includingnull) makes the write fail. The campaign column exists onAbandonedCartReminder(and campaign detail reads reminders by it), but no endpoint sets it yet — so abandoned cart currently can't be attached to a campaign at all.
await ripllo.abandonedCart.updateConfig({
enabled: true,
delayHours: 6,
emailSubject: '{{ first_name }}, your cart is still waiting',
discountCodeId: '<discountCodeId>',
});
Recent reminders
GET /api/v1/abandoned-cart/reminders
Returns the most recent reminder rows for the merchant, newest first. Useful for dashboard "recent activity" widgets and for manually verifying delivery during setup.
Query parameters
| Param | Default | Notes |
|---|---|---|
limit |
50 |
Capped at 200. Only the upper bound is enforced — a zero or negative value is not corrected upward. |
Response
{
"data": {
"items": [
{
"id": "clx2k9f0a0000v8pq7h3m1abc",
"accountId": "acc_01HX...",
"customerId": "cus_storlaunch_4471",
"cartId": "cart_storlaunch_19823",
"email": "alice@example.com",
"cartSnapshot": { "items": [] },
"valueAtSend": 250000,
"currencyAtSend": "IDR",
"discountCodeId": null,
"marketingCampaignId": null,
"externalSource": "storlaunch",
"externalRef": "cart_storlaunch_19823",
"sentAt": "2026-05-13T10:42:00.000Z",
"recoveredAt": null,
"recoveredBySessionId": null
}
]
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Recovery stats
GET /api/v1/abandoned-cart/stats
Aggregate metrics over a rolling window.
Query parameters
| Param | Default | Notes |
|---|---|---|
windowDays |
30 |
Capped at 365. Only the upper bound is enforced — a zero or negative value is not corrected upward. |
Response
{
"data": {
"remindersSent": 412,
"cartsRecovered": 87,
"recoveryRate": 0.21116504854368932,
"recoveredValueAtSend": 18230000,
"currency": "IDR"
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
The response does not echo windowDays back — it's a request parameter only.
recoveryRate is the raw quotient cartsRecovered / remindersSent (0 when nothing was sent). It is not rounded — round it yourself before display. recoveredValueAtSend is the sum of valueAtSend for recovered reminders whose sentAt falls in the window — the window is keyed on send time, not recovery time, so a cart sent 40 days ago and recovered yesterday is outside a 30-day window. It is the value at abandonment, not the eventual checkout total, which can differ if the buyer added items between abandonment and recovery. currency is the currencyAtSend of the first recovered row, or null when nothing was recovered; the sum is not currency-aware, so a merchant selling in two currencies gets a mixed total.
List suppressions
GET /api/v1/abandoned-cart/suppressions
Returns buyers who have opted out of abandoned-cart emails. The list is per-merchant: an opt-out at one merchant doesn't affect any other. Newest first.
Query parameters
| Param | Default | Notes |
|---|---|---|
limit |
200 |
Clamped to [1, 500]. |
Response
{
"data": {
"suppressions": [
{
"id": "clx2k9f0a0001v8pq2r7ndef",
"accountId": "acc_01HX...",
"email": "bob@example.com",
"abandonedCartOptOut": true,
"optedOutAt": "2026-05-12T15:33:00.000Z"
}
]
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Add a suppression
POST /api/v1/abandoned-cart/suppressions
Manually suppress a buyer (typically after a "stop emailing me" support ticket). Does exactly what the public unsubscribe page does, but with merchant credentials instead of a signed token. Suppression changes are not written to the audit log — today only API-key and webhook-endpoint mutations are audited.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
email |
string | yes | RFC-valid email. Normalised to lowercase before insert. |
Returns 201 Created with the BuyerEmailPreference row.
Remove a suppression
DELETE /api/v1/abandoned-cart/suppressions/:email
Lifts the suppression. The buyer will be eligible for future reminders again. The row is not deleted — abandonedCartOptOut is flipped to false so the historical opt-out remains queryable.
The three /suppressions routes have no SDK binding — ripllo.abandonedCart exposes only getConfig, updateConfig, listReminders, stats, recordReminder and markRecovered. Call suppressions over raw HTTP:
await fetch(`${baseUrl}/api/v1/abandoned-cart/suppressions/alice%40example.com`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
Record a reminder
POST /api/v1/abandoned-cart/reminders
Authenticated. Called by the partner platform after it has sent (or is about to send) a reminder for a timed-out cart. Ripllo enforces the opt-out list, dedupes on the partner's anchor, and writes the reminder row. It does not send email and does not read config.enabled.
Sign the call like any other partner request — Authorization: Ripllo-HMAC-SHA256 …, plus X-Ripllo-On-Behalf-Of: acc_<merchantAccountId> when you're a platform acting for a downstream merchant (see Authentication). The workspace comes from the authenticated principal, not from the body.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
accountId |
string | no | Taken from the authenticated principal. If you send it, it must match — a different value is rejected with 403 ACCOUNT_MISMATCH. |
customerId |
string | yes | The buyer's partner-side customer ID. Opaque to Ripllo, and the key /recover matches on. |
cartId |
string | yes | Partner's cart identifier. Not a dedupe key — see below. |
email |
string | yes | Buyer's email. Checked against the suppression list, and used to upsert a Contact. |
cartSnapshot |
any (object) | yes | Free-form JSON snapshot of the cart at abandonment time. Stored so the dashboard can show what the buyer saw. |
valueAtSend |
integer | yes | Cart subtotal at snapshot time, in smallest currency unit. |
currencyAtSend |
string | yes | ISO 4217, exactly 3 characters. |
discountCodeId |
string | no | Override the config-level code for this one reminder. |
externalSource |
string | no | Recommended: "storlaunch". Half of the dedupe key. |
externalRef |
string | no | Partner's own cart/order ID. Convention: same as cartId. The other half of the dedupe key. |
Deduplication
The unique constraint is (accountId, externalSource, externalRef), and the replay check only runs when both externalSource and externalRef are present. Send both on every call — an at-least-once cron that sends only cartId creates a fresh reminder row on every retry, inflating remindersSent.
Response
{
"data": {
"id": "clx2k9f0a0000v8pq7h3m1abc",
"created": true
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
There is no status and no duplicate field. created: false with a non-empty id is the replay signal — that (accountId, externalSource, externalRef) already had a reminder.
If the buyer is on the suppression list, no row is created at all and the response is:
{ "data": { "id": "", "created": false, "reason": "opted_out" }, "error": null, "meta": { "…": "…" } }
Note the empty id. Suppressed carts are therefore invisible to remindersSent — if you need "carts abandoned" including opted-out buyers, count them partner-side.
As a side effect, a successful call upserts a Contact for that email (source: "abandoned_cart") and fires the abandoned_cart funnel trigger, so an active abandoned-cart funnel auto-enrols the buyer. Both are best-effort: a failure there is logged and does not fail the request.
Mark a cart recovered
POST /api/v1/abandoned-cart/recover
Authenticated — same signed partner call as POST /reminders. Called by the partner platform when a buyer completes a checkout that originated from a reminded cart.
Matching rule
Ripllo does not match on checkoutSessionId. It picks the most recent unrecovered reminder for (accountId, customerId) whose sentAt is inside the recovery window, stamps recoveredAt, and records the session id in recoveredBySessionId for reference only.
- Recovery window: 72 hours, hard-coded, measured back from
completedAt(or now). A reminder sent more than 72 hours before the checkout can never be marked recovered. - Not idempotent. Calling twice with the same
checkoutSessionIdmarks a second reminder recovered if the customer has another unrecovered one in the window — there is no unique constraint onrecoveredBySessionId. Dedupe on your side: call once per completed checkout, and don't replay the webhook that triggers it.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
accountId |
string | no | Taken from the authenticated principal. If sent, it must match — otherwise 403 ACCOUNT_MISMATCH. |
customerId |
string | yes | The partner-side customer ID used when the reminder was recorded. This is what the lookup matches on. |
checkoutSessionId |
string | yes | Partner's session ID. Stored on the row; not used for matching or dedupe. |
completedAt |
ISO 8601 | no | When the checkout completed. Defaults to "now" at request receipt. Anchors the 72-hour window. |
Response
{
"data": {
"recovered": true,
"reminderId": "clx2k9f0a0000v8pq7h3m1abc"
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
There is no cartId in the response. If no matching reminder exists (the checkout was for a cart that was never abandoned, or the reminder is older than 72 hours), the response is {"recovered": false} — the reminderId key is absent, not null.
Public unsubscribe
GET /api/v1/abandoned-cart/unsubscribe?accountId=acc_…&email=…&token=…
Renders HTML. Public, no auth. The one-click unsubscribe target for the link in a reminder email. The token is HMAC-SHA256(accountId:email:abandoned-cart) under a service-wide unsubscribe secret (UNSUBSCRIBE_SECRET, falling back to SESSION_COOKIE_SECRET) — there is no per-merchant key material. A leaked token only lets a third party unsubscribe that one buyer from that one merchant. Tampered links return a 401 page; a missing parameter returns 400.
The token is stable for a given (accountId, email), so links stay valid indefinitely.
This endpoint is the buyer-facing equivalent of the merchant-facing POST /suppressions. On success, the buyer sees a confirmation page.
Because the partner platform sends the email, the partner has to put this URL in the template: {baseUrl}/api/v1/abandoned-cart/unsubscribe?accountId=…&email=…&token=…. There is no endpoint that mints the token yet — the buildUnsubscribeUrl helper lives inside the Ripllo backend and is not reachable over HTTP, so today a partner composing the email must compute the same HMAC from a shared UNSUBSCRIBE_SECRET. A mint endpoint is a known gap.
The objects
AbandonedCartConfig
| Field | Type | Notes |
|---|---|---|
id |
string | Opaque cuid (e.g. clx2k9f0a0000v8pq7h3m1abc). No type prefix — don't pattern-match on one. |
accountId |
string | Owning workspace. Unique. |
enabled |
boolean | Master switch, for the partner's sweep job to read. |
delayHours |
integer | 1–168. |
emailSubject, emailPreview |
string | Template strings for the partner's email. |
discountCodeId |
string | null | The bundled discount code. |
createdAt, updatedAt |
ISO 8601 |
AbandonedCartReminder
| Field | Type | Notes |
|---|---|---|
id |
string | Opaque cuid. No type prefix. |
accountId, customerId |
string | |
cartId |
string | Partner's ID. Not unique — uniqueness is on (accountId, externalSource, externalRef). |
email |
string | |
cartSnapshot |
object | Free-form JSON. |
valueAtSend, currencyAtSend |
integer / string | Snapshot at abandonment time. |
discountCodeId |
string | null | The code bundled into this specific reminder. |
marketingCampaignId |
string | null | Optional parent campaign. Null for reminders fired outside a campaign. |
externalSource, externalRef |
string | null | Partner anchors, and the dedupe key. |
sentAt |
ISO 8601 | When the row was recorded. Defaults to insert time. |
recoveredAt |
ISO 8601 | null | Set on /recover. |
recoveredBySessionId |
string | null | The partner checkout-session id passed to /recover. Reference only — nothing matches on it. |
There is no status column and no delivery state. Ripllo never learns whether the email was delivered, opened or bounced; a row means "the partner told us it sent this". There is also no separate createdAt — sentAt is the creation timestamp.
BuyerEmailPreference
| Field | Type | Notes |
|---|---|---|
id |
string | Opaque cuid. No type prefix. |
accountId |
string | Per-merchant scope. |
email |
string | Lowercased. Unique per (accountId, email). |
abandonedCartOptOut |
boolean | Currently the only preference flag; more channels will land here as Ripllo gains them. |
optedOutAt |
ISO 8601 | null | When the opt-out happened. null for rows where abandonedCartOptOut is false. |
Events
| Event type | Fires on | Status |
|---|---|---|
ripllo.abandoned_cart.reminder_sent.v1 |
A reminder row is recorded. | Reserved — not currently emitted. |
ripllo.abandoned_cart.recovered.v1 |
POST /abandoned-cart/recover sets recoveredAt. |
Reserved — not currently emitted. markRecovered writes no outbox row; loyalty is the only service that emits today. Poll GET /stats or GET /reminders for recovery instead. |
ripllo.abandoned_cart.suppressed.v1 |
A buyer is added to the suppression list. | Reserved — not currently emitted. |
Next
abandoned_cart.recoveredevent — the reserved wire format for the recovery signal.- Discount codes — the codes you can bundle into reminder emails.
- Channels — where a merchant's providers are configured, for the broadcast sends Ripllo does perform. Abandoned-cart reminders don't go through it — the partner sends those.