Recover abandoned carts with a workflow

React to a cart the platform marked abandoned with an Integration Studio workflow that sends a reminder through your own mail or CRM, bring the buyer back by reopening the cart, and set the retention windows so the cart is still there when they click.

A buyer fills a cart and leaves. Once the cart has sat untouched past your window, the Carts app marks it abandoned and announces it with a cart.abandoned event. A cart that is reopened and abandoned again gets a new event. A workflow reads the cart and the buyer, and sends a reminder through your own mail server or CRM. The buyer follows the link into your storefront, and your storefront reopens the cart with its lines as they were. This guide builds the workflow, the storefront's reopen call, and the retention settings that decide whether the cart still exists when the buyer comes back.

This is for integrators who run a storefront on the platform and want a reminder flow built as a workflow instead of a listener they host. You need a tenant with the Carts app and Integration Studio installed, access to Cockpit (the admin interface at app.revenexx.com) to change the app's settings and build a workflow, an SMTP server or a CRM with an HTTPS API for the reminder itself, and a storefront whose server side can call the API (see Sign a buyer in). The Carts app's scope is carts.* (see which scope covers which app). New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant.

Integration Studio is experimental and its schemas are drafts, so expect a workflow built today to need a migration later. This guide was written from the Carts app's source (version 2.0.0) and the public API. The end-to-end run in step 8 has not been executed against a tenant, and the Carts topics in your tenant's event catalog have not been read from a live tenant (as of 2026-10), so step 2 has you read them yourself.

What the platform does, and what it leaves to you

The Carts app abandons a cart and says so. It sends nothing to a buyer. Everything after the announcement is yours.

Responsible for
The platformMarking an idle cart abandoned on a 15-minute tick, one cart.abandoned event per abandonment, reopening on request, and deleting idle carts only if you set a retention window
The workflowReading the cart and the buyer, deciding whether to send, and handing the reminder to your mail server or CRM
Your storefrontThe recovery link, checking that the person who clicks it owns the cart, and the reopen call
YouThe windows, consent to send marketing mail, and how many reminders one buyer gets

How a cart ends up abandoned

A cart is active while a buyer fills it. These are the moves it can make:

Cart lifecycle
active ──► abandoned ──► active      (reopened)
   │
   ├──► ordered    final: the record of a sale, never deleted
   └──► merged     final: folded into another cart
StatusMeaning
activeThe cart in use. Only an active cart accepts new lines
abandonedIdle past the window, or abandoned by hand. The one ending you can undo
orderedConverted to an order. Final
mergedFolded into another cart, with merged_into_cart_id pointing at the survivor. Final

Two things mark a cart abandoned, and the event says which in reason:

reasonWho did itMoment
idle_sweepThe platform's cart-maintenance tick, every 15 minutesThe cart's last change is older than abandon_after_minutes
requestedA caller of POST /v1/carts/{id}/abandon, such as your storefront or an operator in CockpitImmediately

Idle is measured from the buyer's last change to the cart. The tick that abandons a cart does not count as a change, so noticing an idle cart never restarts its clock. That matters for step 1.

Before you start: three keys

Create them in Cockpit under API keys (see create an API key), with custom scopes. Narrow scopes limit which operations a key can call.

KeyScopesFor
Setup key, used from your own machinecarts.create, carts.items.create, carts.get, carts.maintenance.run, carts.abandonSteps 1 and 8 (carts.abandon is for the test abandonment in step 8)
Workflow key, used only inside Integration Studiocarts.get, carts.items.list, customers.contacts.getSteps 4 and 5, as a Revenexx API credential (see Reaching Revenue Cloud)
Storefront key, held by your storefront's servercarts.get, carts.reopen, carts.activate, carts.claim, carts.merge_intoStep 6. Add carts.items.create if your storefront adds lines to a reopened cart, and carts.abandon if it abandons carts itself

Step 2 also reads the event catalog, which has no documented scope name. Run it with a development key with * (see which scope covers which app), or skip it and type the topics from step 2. Do not keep a * key beyond that read: revoke it afterwards.

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."             # the setup key
export REVENEXX_DEV_API_KEY="rvxk_..."         # a development key with *, for the catalog read in step 2 only
export REVENEXX_STOREFRONT_API_KEY="rvxk_..."  # the storefront key, for step 6

REVENEXX_TENANT is the value you send as X-Revenexx-Tenant. The shell examples use jq to pick values out of the answers.

1. Set the windows

Open Apps → Installed Apps → Carts → Settings in Cockpit. Three settings decide the timing, and all three are market-scoped: the tick holds each cart against the value of its own market, and a market you have not set inherits the tenant value. How settings resolve is in Settings.

Setting in CockpitKeyDefaultRange
Abandon a cart after (minutes)abandon_after_minutes1440 (one day)0 to 525600. 0 switches abandonment off
Delete customer carts after (days)cart_ttl_days00 to 3650. 0 never deletes
Delete guest carts after (days)guest_cart_ttl_days00 to 3650. 0 never deletes

abandon_after_minutes is your reminder delay. The workflow in this guide has no wait step, and the node catalog ships none (as of 2026-10): the event arrives when the window ends. A buyer who last touched the cart at 09:00 with the default setting is announced on the first tick after 09:00 the next day, up to 15 minutes late. At 0 a market abandons nothing. A market that sets its own window uses it, even if the tenant value is 0, and a market that inherits a tenant 0 produces no idle_sweep events. If every market is at 0, only abandonments by hand (reason: requested) emit an event.

The two delete settings count from the buyer's last change, not from the abandonment. They run on the same clock as the abandon window, and a delete removes the cart and its lines. With cart_ttl_days at 1 and the default window of one day, a cart can be abandoned and deleted in the same pass: the event goes out, and by the time your workflow reads the cart or the buyer clicks the link, it is a 404. Set cart_ttl_days to the abandon window, plus how long your recovery link should work, plus a margin. A window of one day and a link that works for 14 days means 15 days or more. A delete window shorter than the abandon window deletes idle active carts with no cart.abandoned event at all. An abandonment by hand counts as a change and restarts the clock. An ordered cart is never deleted at any setting. A merged cart is, like any other.

The delete settings are the only thing in this app that removes carts on its own, so ask before you set one. A dry run reports what a window would do and writes nothing:

Dry run
curl -s -X POST https://api.revenexx.com/v1/carts/maintenance/run \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"dry_run": true}' \
  | jq '{dry_run, abandon: {found: .abandon.found, capped: .abandon.capped}, purge: {found: .purge.found, would_delete_items: .purge.would_delete_items, capped: .purge.capped}}'

found names the carts the window would abandon or delete. Each half examines the 1000 oldest carts per pass, oldest first, and says capped: true when there may be more. A tenant with a very large number of idle carts can see carts held back for a few ticks.

2. Look up the topics

The event is called cart.abandoned. A workflow trigger matches the topic, which puts the vendor and the app in front. Read the topics from your tenant's event catalog instead of typing them:

Read the topics
curl -s https://api.revenexx.com/v1/events/catalog \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_DEV_API_KEY" \
  | jq -r '.emits[] | select(.app == "carts" and (.name | IN("cart.abandoned", "cart.reopened", "cart.merged", "cart.ordered"))) | .topic'

By the naming rule on the Event catalog page, it should answer with revenexx.carts.cart.abandoned, revenexx.carts.cart.reopened, revenexx.carts.cart.merged and revenexx.carts.cart.ordered. If your catalog lists something else, use what it lists. REVENEXX_DEV_API_KEY is the development key from the environment block above.

The catalog also lists cart.created, cart.updated and cart.deleted. Ignore them: cart.updated fires on every line write, so a workflow subscribed to it, or to a revenexx.carts.> pattern that includes it, starts a run for every click in every cart.

3. Start the workflow on the event

In Cockpit, open Integration Studio → Workflows → New and add an Event trigger. Enter the topic as the Subject:

Subject
revenexx.carts.cart.abandoned

Save, and leave the switch in the editor's header on Paused. You activate the workflow in step 8, once the nodes behind it exist. See Event triggers for subject patterns and for the underscore note.

The event's own fields sit under data:

ExpressionValue
${{ trigger.payload.data.cart_id }}The cart. The reference for everything below
${{ trigger.payload.data.contact_id }}The signed-in owner. null on a guest cart
${{ trigger.payload.data.session_key }}The guest's session. null on a customer cart
${{ trigger.payload.data.reason }}idle_sweep or requested
${{ trigger.payload.data.market }}The cart's market code on idle_sweep, the request's market on requested, or null
${{ trigger.payload.data.item_count }}The sum of the line quantities, not the number of lines
${{ trigger.payload.data.subtotal }}The cart total, net of shipping and tax
${{ trigger.payload.data.currency }}The cart's currency code
${{ trigger.payload.data.abandon_after_minutes }}The window the cart was held against
${{ trigger.payload.data.last_activity_at }}The buyer's last change, not the abandonment time
${{ trigger.payload.data.abandoned_at }}When the platform marked it
${{ trigger.payload.id }}The event's id, not the cart's

The event does not carry the cart's lines, the buyer's email or the cart's name. It says which cart, whose, and how much. Steps 4 and 5 fetch the rest. The cart_id is the cart; ${{ trigger.payload.id }} is the event. Reaching for the wrong one gives every later call an id no cart has, and because both ids are in the payload, nothing refuses the expression: the mistake shows only in what those later calls answer.

Add two If condition nodes behind the trigger. Conditions are not expressions: pick the field with the expression picker, then an operator and a value.

  1. reason equals idle_sweep. A requested abandonment comes from a person or your own code, a minute ago, on purpose. A reminder now would be wrong. Wire the false port to nothing, or to a No Operation node.
  2. contact_id is not empty. A guest cart has no address on the platform, so there is nobody to write to. The node's operator list is not documented, so find the one that tests for empty in the dropdown (as of 2026-10).

The trigger de-duplicates on the event id, and the platform gives a sweep's event one fixed id per cart and UTC day, so a delivery repeated within the day starts one run. Delivery is at least once, so keep the reminder step in step 5 safe to run twice anyway.

4. Read the cart and the buyer

Add three Generic API Call nodes with the Revenexx API credential from the workflow key. See the node catalog.

NodeResource and operationIdYou read
CartCarts, get one cart (GET /v1/carts/{id})${{ trigger.payload.data.cart_id }}status, name, currency, subtotal
LinesCarts, list items (GET /v1/carts/{cart_id}/items)${{ trigger.payload.data.cart_id }}items[] with name, sku, quantity, unit_price, line_total
BuyerCustomers, get one contact (GET /v1/customers/contacts/{id})${{ trigger.payload.data.contact_id }}email, first_name, last_name, locale, status

Order matters in two places.

Check the cart's status right after the first node, with another If condition: continue only when status equals abandoned. The run starts a little after the abandonment, and the buyer may have come back and ordered in between. A cart that is active, ordered or merged by now needs no reminder.

Check the buyer before you send. status is invited, active or blocked, and a blocked contact gets no mail. A contact also carries a registration_status of pending, approved or rejected, which you may want to check as well. email can be empty on a contact that exists, so route that case to a Stop and Error node or to a branch your team reads, instead of sending to nothing. Mail to someone who never agreed to marketing mail is a legal question that the platform does not answer for you: the contact record has no consent field, so keep that decision in your own system (as of 2026-10).

A cart with more lines than one page answers page.hasMore: true. A reminder usually names the first few lines and links to the rest, so you do not need to page through them.

5. Send the reminder through your own channel

The reminder leaves from your own mail server or CRM. The platform sends no cart mail. Pick one.

Mail. Add a Send Email node (SMTP). It takes a stored credential of the SMTP type, which you create under Integration Studio → Credentials and pick on the node (see Credentials). from, to, subject and the body accept expressions:

Mail fields
to:      ${{ nodes.buyer.outputs.response.email }}
subject: You left something in your cart
body:    Hello ${{ nodes.buyer.outputs.response.first_name }},
         your cart ("${{ nodes.cart.outputs.response.name }}") is still saved.
         Pick it up here: https://shop.example.com/cart/recover?cart=${{ trigger.payload.data.cart_id }}

buyer and cart are the ids you gave the two nodes from step 4, and the exact path after outputs. depends on the port the node resolves, so confirm each expression with the picker in the node's input panel. See Expressions. Render the lines from the Lines node with a JSON Transform or Set Fields node first, if you want them in the mail.

CRM. Add an HTTP Request node that posts the cart id, the contact and abandoned_at to your CRM's API, and let the CRM's own automation send the mail. Use an HTTP Bearer Token credential, as in Send low-stock alerts to the ERP. Key the CRM record on cart_id and abandoned_at together, so a repeated run updates it instead of creating a second.

Three decisions that are yours:

  • The link. The platform has no recovery URL. The link points at a route on your storefront, which does step 6. A link that carries only the cart id, like the one above, is safe only if your storefront does what step 6 says: have the buyer sign in and check that the cart is theirs. The platform does not enforce it, and without that check, anyone holding the link can reopen the cart.
  • A second send. A cart that is reopened and abandoned again on a later UTC day produces a new event with a new abandoned_at, and so a new run. Decide whether a buyer gets one reminder per cart, or one per abandonment, and keep that count in your CRM or mail system. The platform does not.
  • Prices. subtotal and the line prices are the cart as the buyer left it, net of tax and shipping. By default an order is booked at the price the buyer was shown, and the Carts app's price_snapshot_mode setting can switch that to the live price (see Carts). Quote a price in the mail only if you have decided which one applies.

Set Max attempts to 1 on the send node and On error to Route to error output, both in its Settings tab, then wire the error port to something your team reads. A node is tried three times by default, and an SMTP send that timed out after the server accepted the mail would otherwise go out again. See Failure handling. There is no alert on a failed run (as of 2026-10), so check Integration Studio → Runs filtered to failed.

6. Bring the buyer back

The buyer follows your link to your storefront. The storefront's server, not the browser, calls the API with the storefront key.

The reopen route checks the cart's status and nothing else. It does not check who is asking. Anyone whose request reaches it with a cart id reopens that cart, so checking that the buyer owns the cart is your storefront's job. Have the buyer sign in first, read the cart, and compare its contact_id with the signed-in contact's id.
Read the cart
curl -s "https://api.revenexx.com/v1/carts/<CART_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_STOREFRONT_API_KEY" \
  | jq '{id, status, contact_id, merged_into_cart_id}'

What the storefront does with each answer:

Cart answersDo this
404The cart is gone, usually deleted by a retention window. Say so and start a fresh cart
status: abandonedReopen it, below
status: activeThe buyer already came back. Continue with that cart
status: orderedThe buyer already bought. Show the order, not the cart
status: mergedFollow merged_into_cart_id to the cart that survived
Reopen the cart
curl -s -X POST "https://api.revenexx.com/v1/carts/<CART_ID>/reopen" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_STOREFRONT_API_KEY"

The cart is active again, with every line and both totals as they were, and abandoned_at cleared. A cart in any other status answers 400 naming the status it holds. Reopening also counts as a change, so the idle clock starts again.

Two things to know about a reopened cart:

  • Reopening does not make the cart current. If your storefront resumes the buyer's cart by is_current, call POST /v1/carts/<CART_ID>/activate afterwards.
  • Reopen before any write. An abandoned cart refuses new lines with a 400 until it is reopened. A storefront that adds to the cart without checking its status hits that error on exactly the buyers this guide brings back.

The buyer already started a new cart

A buyer who found your shop again without the link may hold a fresh active cart. Reopen the old one, then fold one into the other with POST /v1/carts/{id}/merge-into. The path id is the cart you give away, and the body's target_cart_id names the cart that survives. Both must be active, different and in the same currency. See Carts.

A guest returns

A guest cart gets no reminder, because it has no address, but its owner may come back in the same browser session and then sign in. Reopen it first, then claim the session. POST /v1/carts/claim hands over only the active carts of a session, and a cart the guest had abandoned stays with the session.

Claim a guest's carts
curl -s -X POST https://api.revenexx.com/v1/carts/claim \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_STOREFRONT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"session_key":"<SESSION_KEY>","contact_id":"<CONTACT_ID>","target_cart_id":"<CUSTOMERS_CART_ID>","strategy":"merge"}'

Take session_key from your storefront's own server-side session, never from a request parameter: the route hands the carts of whatever session it is given to whatever contact it is given.

Without target_cart_id the guest carts are adopted as they stand. With it they fold into that cart, and the app's cart_merge_strategy setting (Cart merge strategy, default merge) decides what happens to the target's own lines: merge keeps them and adds the guest lines, replace clears them first. strategy in the body overrides the setting for one call, and the answer says which one ran. The merge strategy applies to a claim only, not to merge-into.

7. Close the loop

Three more events say how a recovery went. Give each its own workflow, with its own Subject, instead of a pattern.

TopicWhat it tells youUse it to
revenexx.carts.cart.reopenedThe buyer came back. Carries cart_id, abandoned_at and reopened_atStop any follow-up you planned, and measure how long buyers took
revenexx.carts.cart.orderedThe cart became an order. Carries cart_id, order_ref, subtotal and every lineCount the recovery. A reopened cart is a buyer who came back, an ordered cart is a sale
revenexx.carts.cart.mergedOne cart folded into another. Carries source_cart_id, target_cart_id, strategy, moved_linesClose a reminder you sent for the source, and find the target

cart.ordered carries every line because retention may delete the cart long before you read the feed. cart.merged is published for the target, the cart that survives, and a claim that adopts a guest cart without a target publishes no merge event at all (as of 2026-10), so a guest cart changing owner shows up only as the cart's own update. A refused reopen or merge publishes nothing.

Key your records on cart_id. A reminder sent for a cart that was later merged belongs to the source_cart_id in the event.

8. Test it once

Do this on a test tenant, because it changes a setting that applies to every cart on it. You need a contact with your own email address (see Storefront accounts), and its id.

Test step 1. Set Abandon a cart after (minutes) to 1 for the test, and leave the delete settings at 0.

Test step 2. Create a cart for the contact and add a line:

Create a test cart
CART_ID=$(curl -s -X POST https://api.revenexx.com/v1/carts \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contact_id":"<CONTACT_ID>","name":"Reminder test"}' | jq -r '.id')

curl -s -X POST "https://api.revenexx.com/v1/carts/$CART_ID/items" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sku":"ACME-4711-BLK","quantity":2,"unit_price":19.90}'

Test step 3. Activate the workflow: flip the switch in the editor's header from Paused to Active. Check the switch after you save: a workflow with unfilled required settings is saved paused.

Test step 4. Wait more than a minute, then run the tick by hand. It does exactly what the 15-minute tick does, so every cart on the tenant that is idle past the window is abandoned and announced:

Run the tick now
curl -s -X POST https://api.revenexx.com/v1/carts/maintenance/run \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}' | jq '{abandoned: .abandon.abandoned, cart_ids: .abandon.cart_ids}'

Test step 5. Open Integration Studio → Runs: one run for your cart, and the mail in your inbox. Then follow the link through your storefront, or call the reopen route from step 6 yourself, with the storefront key.

Test step 6. Set the window back to what you decided in section 1.

A cart you abandon with POST /v1/carts/{id}/abandon fires the event too, with reason: requested, and the workflow ends at the first condition. That is a way to test the trigger without waiting, and it proves the reason filter.

Troubleshooting

SymptomLikely cause
The workflow never startsIt is still Paused, or the subject does not match the topic from step 2 segment for segment. Nothing warns you
No event for an idle cartabandon_after_minutes is 0, the cart is still inside its window or its market's window, the cart is ordered, or the next 15-minute tick has not run. A cart abandoned by the tick again on the same UTC day as an earlier abandonment announces nothing new, so re-test with a fresh cart
The event arrives and the cart is a 404A delete setting removed it. Raise cart_ttl_days or guest_cart_ttl_days (step 1)
Runs start for carts you did not expectreason is requested: your storefront or an operator abandoned them. The first condition in step 3 filters them out
The run starts but the mail has no recipientA guest cart (contact_id is null), or a contact with no email (step 4)
A buyer got the mail twiceA retried send node, or the cart was reopened and abandoned again. See step 5
Adding a line to a returning buyer's cart answers 400The cart is still abandoned. Reopen it first (step 6)
Reopen answers 400The cart is not abandoned. The message names its status
403 on a callThe key lacks the scope for that operation (the key tables list the scopes each step needs)

Where to go next

  • Carts — every cart route, the lifecycle, claiming and merging.
  • Event catalog — the topics and the fields each event carries.
  • Choose an event surface — when a workflow is the right consumer of an event and when a webhook is.
  • Failure handling — retries, error routing and timeouts for the nodes above.
Was this page helpful?