# Abandoned Basket Email Integration

This guide explains how to build an automated abandoned basket email campaign using the Limio API and a third-party email service provider. We use [Resend](https://resend.com) as an example, but the same principles apply to any transactional email provider such as SendGrid, Mailgun, or Amazon SES.

{% hint style="info" %}
**Prerequisites.** This guide assumes you are familiar with the [Abandoned Basket API](https://docs.limio.com/developers/api-documentation/abandoned-basket-api) and have already set up [OAuth authentication](https://docs.limio.com/developers/api-documentation/authentication-overview/oauth-bearer-token). If you are looking to manually inspect abandoned baskets, see [Guide: Inspect and Follow Up on Abandoned Baskets](https://docs.limio.com/guides/feature-implementation-guides/guide-inspect-abandoned-baskets).
{% endhint %}

## Overview

The high-level flow for an abandoned basket email integration is:

1. **Authenticate** with the Limio API using OAuth client credentials
2. **Fetch** abandoned baskets using the `GET /api/checkout/abandoned` endpoint
3. **Filter** baskets to identify those eligible for email outreach
4. **Build** a recovery email with a link back to the customer's checkout
5. **Send** the email via your provider
6. **Track** which baskets have already been emailed to avoid duplicate sends

## Fetching Abandoned Baskets

Use the `createdAfter` parameter to control the lookback window. For example, to fetch baskets abandoned in the last 24 hours:

```
GET https://{tenant}/api/checkout/abandoned?limit=50&createdAfter=2025-01-01T00:00:00Z
```

Each basket in the response includes `customerDetails`, `orderItems`, a `completed` flag, and a `recoveryLink`. Only baskets where `completed` is `false` are considered abandoned.

If more results exist than the limit allows, the response includes a `queryMore` cursor. Pass it as a query parameter on the next request to page through results.

## Key Considerations

### Integration strategy: polling vs event-driven

There are two main approaches to detecting incomplete baskets and triggering emails. You can use one or combine both.

#### Approach 1: Polling the Abandoned Basket API (recommended)

A scheduled job — a cron task, a Lambda on a CloudWatch timer, or a simple script on a schedule — periodically calls `GET /api/checkout/abandoned` with a `createdAfter` filter and processes any new baskets.

**Why this approach works well:**

* **Full control over timing.** You decide when to send: after 1 hour for urgency, or after 24 hours for a traditional campaign.
* **Simple to operate.** No always-on infrastructure is required beyond a scheduler. A cron job or a serverless function on a timer is sufficient.
* **Batch-friendly.** You can process multiple baskets per run, apply deduplication, and rate-limit sends to stay within your email provider's limits.

#### Approach 2: Event-driven (webhook + delayed check)

Limio fires a [`checkout.initiated`](https://docs.limio.com/developers/webhooks/overview) webhook when a customer starts a checkout. You can listen for this event, wait a defined period, and then check whether the basket was completed.

**Why you might consider this approach:**

* **Near real-time awareness.** You know about the basket the moment it is created, which can reduce the time between checkout inactivity and email delivery.
* **Targeted checks.** Instead of scanning all baskets on each poll, you only check specific baskets that were flagged by a webhook.

**Limitations to be aware of:**

* **The webhook fires at checkout start, not when the basket becomes inactive.** You still need to wait and re-check the Abandoned Basket API to confirm the basket was not completed.
* **The webhook payload does not include `customerDetails.email`.** The email is only captured later when the customer fills in the checkout form (or logs in via SSO). You would use the basket ID from the webhook as your reference, then call the API after a delay to retrieve the email and other details.
* **More complex infrastructure.** You need an always-on endpoint to receive webhooks, plus a queue or delay mechanism (e.g. SQS with a delay, a scheduled retry) to check back later.

{% hint style="info" %}
**Which approach should I use?** For most use cases, polling is the simplest and most reliable approach. The event-driven approach adds complexity without eliminating the need to call the API — since the webhook fires at checkout initiation and does not include the customer's email. Consider webhooks as an optimisation if you need to reduce the time between checkout inactivity and email delivery.
{% endhint %}

### Only baskets with an email can be contacted

A basket is created as soon as a customer clicks a call-to-action on your shop — but `customerDetails.email` is only populated once the customer enters their email on the checkout form (or logs in via SSO). Filter out any baskets where `customerDetails.email` is missing before attempting to send.

### Wait before sending

A basket is created the moment a customer clicks "Subscribe" or "Buy Now" — they may still be filling in the checkout form. If you send an email immediately, you risk contacting a customer who is still mid-checkout.

Introduce a minimum age threshold before considering a basket eligible. For example, only email baskets created at least **1–2 hours ago** for a standard campaign, or 30–60 minutes for a more aggressive approach. Compare the basket's `created` timestamp to the current time and skip any baskets that are too recent.

{% hint style="info" %}
**There is no definitive moment when a basket becomes "abandoned".** It is simply incomplete. You choose the threshold that makes sense for your business — there is no right answer. A shorter delay means faster outreach but risks emailing customers who are still deciding; a longer delay gives more confidence but reduces urgency.
{% endhint %}

### Avoid duplicate emails and suppress completed orders

If you run your integration on a schedule (e.g. every 15 minutes via a cron job), you must track which baskets have already been emailed. Without deduplication, the same customer could receive multiple recovery emails for the same basket.

A simple approach is to maintain a persistent record of processed basket IDs — a JSON file, a database table, or a key-value store. Before sending, check whether the basket ID exists in your record. After a successful send, add it. Prune old entries periodically (e.g. remove records older than 72 hours) to keep the store manageable.

You should also check the basket's `completed` flag before sending. If there is a delay between fetching the basket and sending the email (e.g. you batch-fetch then process sequentially), consider calling the API again for individual baskets to confirm they are still incomplete. If you are using the event-driven approach, you can also listen for the order completion event and clear the basket from your queue to suppress the email before it is sent.

### Validate the email address

Always validate the format of `customerDetails.email` before passing it to your email provider. Malformed addresses will cause API errors and may affect your sender reputation.

### Filter by basket type

The Abandoned Basket API returns **all** incomplete baskets — including new subscriptions, upgrades, referral flows, reseller flows, and update subscription baskets. You will likely want to target only specific types.

Use the `tracking.tag` field to filter baskets by sales channel. The tag value corresponds to the Limio page or entry point where the checkout was initiated (e.g. direct purchase, partner referral, self-service upgrade). Work with your team to identify which tags correspond to which channels in your shop configuration, then filter accordingly.

Typically, you will want to target baskets from **direct** and **referral** flows — these represent end customers purchasing for themselves. **Reseller** baskets (where a partner is standing up a subscription on behalf of a client) should usually be excluded, as the partner manages that relationship.

{% hint style="warning" %}
**Exclude update subscription baskets.** These appear in the API alongside new subscription baskets, but the recovery link may point to the wrong checkout path and the `orderItems` do not clearly indicate which product is being added or removed. To filter them out, identify the `tracking.tag` or `tracking.campaign` values associated with your update subscription pages and exclude baskets matching those values.
{% endhint %}

## Recovery Links

Each abandoned basket includes a `recoveryLink` field. This is a tokenised URL that takes the customer back to the exact checkout session they abandoned, with their basket pre-populated.

{% hint style="warning" %}
**Use the shop domain, not the API tenant domain.** The `recoveryLink` returned by the API uses the shop domain. Make sure your email links resolve against your **public-facing shop domain** (e.g. `https://shop.example.com`), not the API tenant domain. The shop domain is the URL your customers use to access your checkout.
{% endhint %}

The full recovery link format is:

```
https://<shopDomain>/api/checkout/recover?basketId=<basket-id>&recover=<token>
```

Include this link as the primary call-to-action in your email so the customer can return to their checkout in a single click.

### Preserving UTM parameters

The basket's `entry` field contains the original URL the customer used to reach the checkout, including any UTM parameters. You have two options:

* **Preserve original UTMs** — reappend the UTMs from `entry` to the recovery link, so conversions are attributed to the original campaign that brought the customer to checkout.
* **Use new UTMs** — generate abandoned-cart-specific UTMs (e.g. `utm_campaign=abandon_cart_recovery`) so you can measure the recovery campaign separately.

Check with your marketing team which approach they prefer. You can also combine both by preserving the original `utm_source` while overriding `utm_campaign`.

To extract the original UTM parameters from the `entry` field:

```javascript
function extractUtmParams(entry) {
  const url = new URL(entry, "https://placeholder.com");
  const utmParams = new URLSearchParams();
  for (const [key, value] of url.searchParams) {
    if (key.startsWith("utm_")) utmParams.set(key, value);
  }
  return utmParams.toString();
}
```

The full recovery link is then constructed as:

```
{shopDomain} + {recoveryLink} + &{utmParams} + &pc={promoCode}
```

## Applying Promo Codes

To incentivise customers to complete their purchase, you can automatically apply a promo code by appending `&pc=<code>` to the recovery link:

```
https://shop.example.com/api/checkout/recover?basketId=abc-123&recover=eyJ...&pc=COMEBACK10
```

When the customer clicks this link, the promo code `COMEBACK10` is applied to their basket automatically. This is a powerful way to offer a time-limited discount in your recovery email without requiring the customer to enter a code manually.

{% hint style="info" %}
**Promo code requirements.** The promo code must already be configured in Limio. If the code is expired, invalid, or not compatible with the items in the basket, the offer will still appear but the discount will not be applied. See [How to Configure Promo Codes](https://docs.limio.com/product/pricing/how-to-configure-and-implement-promo-codes) for setup instructions.
{% endhint %}

## Enriching Emails with Offer Details

You can enhance your recovery emails by fetching additional offer details from the Limio API. Each basket's `orderItems` includes a `path` field (e.g. `/offers2/Monthly Standard`) that you can use to query the Offers API:

```
GET https://{tenant}/api/offers?path=/offers2/Monthly%20Standard
```

The response includes offer attributes such as `display_price__limio` and `checkout_description__limio`, which you can use to display the price and a short description in the email body. This gives the customer a clear reminder of what they left behind.

## Example: Sending with Resend

[Resend](https://resend.com) is a developer-friendly transactional email API. After creating an account and verifying your sending domain, you can send emails using their SDK:

```javascript
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

const { data, error } = await resend.emails.send({
  from: "noreply@yourdomain.com",
  to: basket.customerDetails.email,
  subject: "You left something behind",
  html: buildEmailHtml(basket, recoveryLink),
});

if (error) {
  console.error("Failed to send:", error.message);
}
```

{% hint style="warning" %}
**Always check the response.** The Resend SDK returns `{ data, error }`. If you do not check the `error` field, failed sends will appear to succeed silently. This applies to most email provider SDKs — always confirm the send was successful before marking a basket as processed.
{% endhint %}

The `buildEmailHtml` function is where you construct your email template. Include the offer name, price (if enriched), and a prominent call-to-action button pointing to the recovery link with an optional promo code appended.

## Putting It All Together

A complete abandoned basket email integration follows this sequence:

1. **Authenticate** — obtain a Bearer token using OAuth `client_credentials` grant
2. **Fetch baskets** — call `GET /api/checkout/abandoned` with a `createdAfter` filter
3. **Filter** — remove baskets that are too recent, missing an email address, or already emailed
4. **Enrich** — optionally fetch offer details to include pricing and descriptions in the email
5. **Build the recovery link** — use the `recoveryLink` from the basket, and append `&pc=<code>` if you want to include a promo code
6. **Send** — deliver the email via your provider and confirm success
7. **Record** — mark the basket ID as processed in your deduplication store

This flow can be triggered manually, run on a cron schedule, or integrated into a larger marketing automation pipeline.
