Currency

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 marketingCampaignId here. The validator accepts the field and checks the campaign belongs to your workspace, but AbandonedCartConfig has no campaign column — the migration only added it to AbandonedCartReminder. Passing it (including null) makes the write fail. The campaign column exists on AbandonedCartReminder (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 bindingripllo.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 checkoutSessionId marks a second reminder recovered if the customer has another unrecovered one in the window — there is no unique constraint on recoveredBySessionId. 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 createdAtsentAt 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.recovered event — 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.
CurrencyRupiah is paid by transfer or QRIS; US dollars settle through PayPal.