Recover abandoned carts with a workflow
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.
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 platform | Marking 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 workflow | Reading the cart and the buyer, deciding whether to send, and handing the reminder to your mail server or CRM |
| Your storefront | The recovery link, checking that the person who clicks it owns the cart, and the reopen call |
| You | The 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:
active ──► abandoned ──► active (reopened)
│
├──► ordered final: the record of a sale, never deleted
└──► merged final: folded into another cart
| Status | Meaning |
|---|---|
active | The cart in use. Only an active cart accepts new lines |
abandoned | Idle past the window, or abandoned by hand. The one ending you can undo |
ordered | Converted to an order. Final |
merged | Folded 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:
reason | Who did it | Moment |
|---|---|---|
idle_sweep | The platform's cart-maintenance tick, every 15 minutes | The cart's last change is older than abandon_after_minutes |
requested | A caller of POST /v1/carts/{id}/abandon, such as your storefront or an operator in Cockpit | Immediately |
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.
| Key | Scopes | For |
|---|---|---|
| Setup key, used from your own machine | carts.create, carts.items.create, carts.get, carts.maintenance.run, carts.abandon | Steps 1 and 8 (carts.abandon is for the test abandonment in step 8) |
| Workflow key, used only inside Integration Studio | carts.get, carts.items.list, customers.contacts.get | Steps 4 and 5, as a Revenexx API credential (see Reaching Revenue Cloud) |
| Storefront key, held by your storefront's server | carts.get, carts.reopen, carts.activate, carts.claim, carts.merge_into | Step 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.
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 Cockpit | Key | Default | Range |
|---|---|---|---|
| Abandon a cart after (minutes) | abandon_after_minutes | 1440 (one day) | 0 to 525600. 0 switches abandonment off |
| Delete customer carts after (days) | cart_ttl_days | 0 | 0 to 3650. 0 never deletes |
| Delete guest carts after (days) | guest_cart_ttl_days | 0 | 0 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.
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:
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:
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:
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:
| Expression | Value |
|---|---|
${{ 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.
reasonequalsidle_sweep. Arequestedabandonment comes from a person or your own code, a minute ago, on purpose. A reminder now would be wrong. Wire thefalseport to nothing, or to a No Operation node.contact_idis 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.
| Node | Resource and operation | Id | You read |
|---|---|---|---|
| Cart | Carts, get one cart (GET /v1/carts/{id}) | ${{ trigger.payload.data.cart_id }} | status, name, currency, subtotal |
| Lines | Carts, list items (GET /v1/carts/{cart_id}/items) | ${{ trigger.payload.data.cart_id }} | items[] with name, sku, quantity, unit_price, line_total |
| Buyer | Customers, 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:
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.
subtotaland 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'sprice_snapshot_modesetting 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.
contact_id with the signed-in contact's id.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 answers | Do this |
|---|---|
404 | The cart is gone, usually deleted by a retention window. Say so and start a fresh cart |
status: abandoned | Reopen it, below |
status: active | The buyer already came back. Continue with that cart |
status: ordered | The buyer already bought. Show the order, not the cart |
status: merged | Follow merged_into_cart_id to the cart that survived |
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, callPOST /v1/carts/<CART_ID>/activateafterwards. - Reopen before any write. An abandoned cart refuses new lines with a
400until 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.
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.
| Topic | What it tells you | Use it to |
|---|---|---|
revenexx.carts.cart.reopened | The buyer came back. Carries cart_id, abandoned_at and reopened_at | Stop any follow-up you planned, and measure how long buyers took |
revenexx.carts.cart.ordered | The cart became an order. Carries cart_id, order_ref, subtotal and every line | Count the recovery. A reopened cart is a buyer who came back, an ordered cart is a sale |
revenexx.carts.cart.merged | One cart folded into another. Carries source_cart_id, target_cart_id, strategy, moved_lines | Close 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:
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:
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
| Symptom | Likely cause |
|---|---|
| The workflow never starts | It 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 cart | abandon_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 404 | A delete setting removed it. Raise cart_ttl_days or guest_cart_ttl_days (step 1) |
| Runs start for carts you did not expect | reason 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 recipient | A guest cart (contact_id is null), or a contact with no email (step 4) |
| A buyer got the mail twice | A retried send node, or the cart was reopened and abandoned again. See step 5 |
Adding a line to a returning buyer's cart answers 400 | The cart is still abandoned. Reopen it first (step 6) |
Reopen answers 400 | The cart is not abandoned. The message names its status |
403 on a call | The 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.