payload-abandoned-cart
v1.0.1
Published
Marks idle Payload carts abandoned, sends a recovery sequence through payload.sendEmail, and attributes the orders it recovers.
Maintainers
Readme
payload-abandoned-cart
Records when a Payload cart was abandoned, sends a recovery sequence through payload.sendEmail, restores the cart from a link that expires, and marks the orders that came back so a shop can measure whether any of it works.
- Extends
@payloadcms/plugin-ecommerce, adding fields and endpoints instead of replacing anything - No scheduler dependency: one endpoint you point your own cron at, and the same work as an exported function
- No mail library: every message goes through the email adapter the host already configured
- No runtime dependencies, no admin components, so it survives minor releases
Install
Requires Payload 3.88 or newer and @payloadcms/plugin-ecommerce 3.88 or newer. Verified against Payload 3.88.0 with the official plugin installed.
pnpm add payload-abandoned-cartimport { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { abandonedCartPlugin } from 'payload-abandoned-cart'
export default buildConfig({
plugins: [
ecommercePlugin({ ... }),
abandonedCartPlugin({
idleMinutes: 120,
steps: [{ delayMinutes: 60 }, { delayMinutes: 1440 }],
sweepSecret: process.env.ABANDONED_CART_SECRET,
}),
],
})abandonedCartPlugin must come after ecommercePlugin, because it adds fields to the collections that plugin defines. Then point a scheduler at the sweep:
curl -X POST https://example.com/api/abandoned-cart/sweep \
-H "Authorization: Bearer $ABANDONED_CART_SECRET"What was measured
Read in the published @payloadcms/[email protected], in the original TypeScript carried by its source maps.
The abandoned status exists and is never stored
The carts collection declares three statuses in src/collections/carts/createCartsCollection.ts, and the field carries virtual: true with an afterRead hook. src/collections/carts/statusBeforeRead.ts is the whole of it:
if (data?.purchasedAt) return 'purchased'
if (timeNow - createdAt < 7 * 24 * 60 * 60 * 1000) return 'active'
return 'abandoned'So the value is computed on every read and written nowhere. Four consequences, all of which this package exists to answer:
| What the plugin does | What follows |
| --- | --- |
| The field is virtual: true | It is not in the database. Payload's own query validation pushes an error for a query on a field with virtual: true, so where: { status: { equals: 'abandoned' } } cannot be run at all |
| The threshold is the literal 7 * 24 * 60 * 60 * 1000 | Seven days, not configurable |
| It is measured from createdAt | A cart created eight days ago and updated this morning already reads as abandoned |
| Nothing records when it changed | There is no moment to count a recovery sequence from |
This package therefore stores its own abandonedAt timestamp rather than trying to write a field that would be discarded. If your carts collection carries a stored status field of its own that offers an abandoned option, that field is written too; the virtual one never is.
A cart holds no address
The fields the plugin puts on a cart are items, secret, customer, purchasedAt, status, and, when currencies are configured, subtotal and currency. There is no email address anywhere, and no shippingAddress either; only orders have one of those.
A cart can therefore only be emailed if it has a customer relationship to look an address up through, which a guest cart does not. This package adds a customerEmail field to the cart so that a storefront can capture the address at the first step of checkout, and reads it before falling back to the customer record.
The order does not know which cart it came from
In src/payments/adapters/stripe/confirmOrder.ts the order is created first and the cart is marked second, inside one request:
const order = await payload.create({ collection: ordersSlug, data: { ... }, req })
await payload.update({ id: cartID, collection: cartsSlug, data: { purchasedAt: timestamp }, req })The order carries items, customer or customerEmail, transactions, status, amount and currency. Nothing points back at the cart. Attribution is therefore taken from the only moment the two are known together: the order id is kept in req.context as the order is created, and the cart's own hook, running later in the same request, writes it onto the order.
Options
| Option | Default | Meaning |
| --- | --- | --- |
| attributionWindowHours | 168 | How long after a recovery link is followed a purchase is still credited to it |
| cartsSlug | 'carts' | Slug of the carts collection |
| currencyDecimals | 2 | Decimal places used to format the subtotal, one number or a map from currency code to places |
| customersSlug | 'users' | Collection the cart's customer relationship points at |
| disabled | false | Stops marking, sending and attributing but keeps every field, so the database keeps its shape |
| emailFieldName | 'customerEmail' | Field on the cart holding the address a message is sent to |
| from | null | Address messages are sent from. Null leaves the choice to the email adapter |
| idleMinutes | 60 | Minutes of inactivity, measured on updatedAt, before a cart is marked abandoned |
| markLimit | 200 | Highest number of carts marked in one sweep |
| ordersSlug | 'orders' | Slug of the orders collection |
| recoverEndpointPath | '/abandoned-cart/recover' | Path a recovery link points at |
| redirectPath | '/cart' | Storefront path a followed link is redirected to. Empty means answer with JSON instead |
| redirectWithSecret | true | Whether the redirect carries the cart secret, which is what gives a guest the cart back |
| replyTo | null | Address replies go to |
| sendLimit | 100 | Highest number of messages sent in one sweep |
| serverURL | '' | Origin used to build recovery links. Empty falls back to serverURL of the Payload config |
| statusFieldName | 'status' | Cart status field. Written only when it is stored and offers an abandoned option |
| steps | two messages, at 60 and 1440 minutes | The sequence. An empty array turns sending off and leaves marking in place |
| sweepEndpointPath | '/abandoned-cart/sweep' | Path of the sweep endpoint |
| sweepSecret | '' | Secret accepted by that endpoint as Authorization: Bearer <secret>. Empty means only a signed in user may call it |
| tokenTtlHours | 336 | How long a recovery token stays valid, counted from the moment the cart was marked |
A value that cannot be used is replaced by its default rather than being applied. An idleMinutes of 0 becomes 60, 90.9 becomes 90, a slug given as an empty string becomes its default, and a path without a leading slash gains one. Steps are sorted by delay, so the sequence always goes out in the order the delays describe.
The sequence
Each step's delayMinutes is counted from abandonedAt, not from the message before it, so the schedule stays readable as steps are added or removed.
abandonedCartPlugin({
steps: [
{ delayMinutes: 60 },
{
delayMinutes: 1440,
template: {
subject: 'Still thinking it over?',
text: 'Your basket of {{itemCount}} items comes to {{subtotalFormatted}}.\n{{recoveryURL}}',
},
},
{ delayMinutes: 4320, template: (context) => ({ subject: '...', text: '...' }) },
],
})A template is either a pair of strings carrying {{placeholder}} tokens or a function receiving the context. The tokens are abandonedAt, cartID, currency, itemCount, lineCount, recoveryURL, step, subtotal, subtotalFormatted and to; the whole cart is on cart for a function template. A token with no matching value is left in place, so a mistyped name is visible instead of blank. The built in messages are plain text and compose nothing into markup.
What it adds to your database
| Collection | Field | Type | Notes |
| --- | --- | --- | --- |
| your carts collection | customerEmail | email | indexed. Added only when no field of that name exists |
| your carts collection | abandonedAt | date | indexed. Set by the sweep, cleared when the link is followed |
| your carts collection | recoveryToken | text | indexed, hidden, closed to create, read and update through the API |
| your carts collection | recoveryTokenExpiresAt | date | hidden |
| your carts collection | recoveryEmailsSent | number | how many steps have gone out, read only |
| your carts collection | lastRecoveryEmailAt | date | read only |
| your carts collection | recoveryVisitedAt | date | indexed. When the recovery link was first followed |
| your orders collection | recoveredFromCart | relationship | indexed, read only. The cart the sale came back from |
| your orders collection | recoveryEmailStep | number | read only. How many messages had gone out when the sale happened |
No collection is added. A field this package would add and your collection already declares under that name is left alone.
What it adds to your API
| Endpoint | Method | Purpose |
| --- | --- | --- |
| /api/abandoned-cart/recover?token=... | get | Public. Restores the cart and redirects to the storefront |
| /api/abandoned-cart/sweep | post | Marks idle carts and sends what is due. Returns { "marked": n, "sent": n, "skipped": n } |
Running the sweep
There are three ways, none of which needs a scheduler in your dependencies:
Point your existing scheduler at the endpoint, with the secret as a bearer token.
Call it from a route handler or a script.
import { runAbandonedCartSweep } from 'payload-abandoned-cart' const { marked, sent, skipped } = await runAbandonedCartSweep(payload, { idleMinutes: 120 })Call the two halves separately, when marking and sending belong on different schedules.
import { markAbandonedCarts, sendRecoveryEmails } from 'payload-abandoned-cart'
Pass the same options you passed to the plugin. Every exported function resolves them the same way the plugin does, so passing nothing gives you the documented defaults.
The recovery link
A token is 32 bytes from getRandomValues of node:crypto, written as 64 hexadecimal characters, minted when the cart is marked and reused by every message of the sequence so that an older message keeps working. Following the link stops the cart being abandoned, which also stops the sequence, records the visit, and redirects to:
/cart?cart=<id>&secret=<cart secret>The secret is what the ecommerce plugin's own guest cart access reads from req.query.secret, so a guest gets the cart back rather than an empty one. Set redirectWithSecret: false to leave it out, or redirectPath: '' to receive JSON and do the rest yourself:
{ "cart": "68f2...", "recovered": true, "secret": "3f9a..." }A refused link answers 404 for a token that belongs to nothing, 410 for one that expired and 409 for a cart already paid for. With a redirect configured, all three become ?recovery=unknown, ?recovery=expired and ?recovery=purchased on the storefront path instead.
Honest limits
One sweep at a time. The sweep is not locked. Two overlapping runs can read the same cart before either has written to it, and send one step twice. Space your schedule wider than a sweep takes, and keep markLimit and sendLimit at a size that finishes comfortably.
A message is recorded before it is sent. The step counter is written first and the transport called second, so a cart is never sent the same step twice. The cost is the opposite failure: if the transport throws, that message is lost rather than repeated. The failure is logged as an error and the sweep continues to the next cart. Losing one message is better than mailing a customer twice.
The sweep does not run inside your request transaction. Each cart is written on its own, so a failure late in a sweep does not roll back the messages already sent. On PostgreSQL that means the sweep uses a connection of its own while the endpoint holds another; the same mechanism was measured for payload-order-numbers, where the pg default pool of 10 was exhausted by fifteen simultaneous writers. One scheduled sweep is one extra connection, but do not run the sweep from inside a hot request path.
A guest cart with no address is never mailed. The ecommerce plugin gives a cart no address field, so unless your storefront writes one into customerEmail, only carts with a customer relationship can be reached. Those carts are still marked, still counted, and are passed over with a warning in the log rather than an error.
The recovery token is stored as it is sent. The same link has to appear in every message of the sequence, so the token cannot be replaced by a hash of itself. The field is closed to create, read and update through the API and hidden in the admin panel, but it is in the database in plain text. It grants access to one cart and it expires.
The secret travels in a URL. With redirectWithSecret on, the cart secret arrives on the storefront as a query parameter, which browsers keep in history and may pass on as a referrer. That is how the ecommerce plugin's own guest access works, and it is the only way to hand a guest their cart back. Turn it off if that trade is not one you want.
Attribution needs the order and the cart in one request. The order id is taken from req.context, which is how the two are connected when the official confirmOrder runs. A checkout that creates the order in a separate request, or writes it straight through the local API, leaves recoveredFromCart empty; the visit is still on the cart, and a note is written to the log.
Nothing is measured against a control group. recoveredFromCart counts sales that followed a recovery link inside the attribution window. It does not tell you whether those customers would have come back anyway.
Existing carts are not marked retroactively. The first sweep marks every cart already older than idleMinutes, up to markLimit, oldest first. On a shop with a long history that is several sweeps' worth of work, and the messages that follow are real. Set steps: [] for the first run if you want the marking without the mail.
Deleting a cart deletes its attribution. recoveredFromCart is a relationship; the order keeps recoveryEmailStep, but the link goes.
License
MIT. Copyright George Vasiliades, https://github.com/Poseidonas
