Skip to content
On this page

Recover abandoned carts

Learn how to recover abandoned Checkout pages and boost revenue.

Customers may leave Checkout before completing their purchase. In e-commerce, this is known as cart abandonment. Creating a recovery flow where you follow up with customers over email and help bring them back to Checkout to complete the purchase can boost your revenue and conversion.

Cart abandonment emails fall into the broader category of promotional emails, which includes emails that you send to inform customers of new products (for example, newsletters) and to share coupons and discounts. Customers must agree to receive promotional emails before you can contact them.

Checkout helps you:

  1. Collect consent from customers to send them promotional emails.

  2. Get notified when customers abandon Checkout so you can send cart abandonment emails.

Configure recovery

A Checkout Session becomes abandoned when it reaches its expires_at timestamp and the buyer hasn’t completed checking out. When this occurs, the session is no longer accessible and Payske fires the checkout.session.expired webhook, which you can listen to and try to bring the customer back to a new Checkout Session to complete their purchase.

To use this feature, enable after_expiration.recovery when you create the session.

console
curl https://api.payske.com/v1/checkout/sessions \
  -u sk_test_4eC39HqLyjWDarjtT1zdp7dc: \
  -d "line_items[0][price]"='{{PRICE_ID}}' \
  -d "line_items[0][quantity]"=2 \
  -d customer='{{CUSTOMER_ID}}' \
  -d mode=payment \
  -d success_url="https://example.com/success" \
  -d cancel_url="https://example.com/cancel" \
  -d "consent_collection[promotions]"="auto" \
  -d "after_expiration[recovery][enabled]"=true
ruby
# Set your secret key. Remember to switch to your live secret key in production.
# See your keys here: https://account.payske.com/api/key
Payske.api_key = 'sk_test_4eC39HqLyjWDarjtT1zdp7dc'

session = Payske::Checkout::Session.create({
  line_items: [{
    price: '{{PRICE_ID}}',
    quantity: 1,
  }],
  mode: 'payment',
  success_url: 'https://example.com/success',
  cancel_url: 'https://example.com/cancel',
  customer: '{{CUSTOMER_ID}}',
  consent_collection: {
    promotions: 'auto',
  },
  after_expiration: {
    recovery: {
      enabled: true,
    }
  },
})
python
# Set your secret key. Remember to switch to your live secret key in production.
# See your keys here: https://account.payske.com/api/key
payske.api_key = 'sk_test_4eC39HqLyjWDarjtT1zdp7dc'

session = payske.checkout.Session.create(
  line_items=[{
    'price': '{{PRICE_ID}}',
    'quantity': 1,
  }],
  mode='payment',
  success_url='https://example.com/success',
  cancel_url='https://example.com/cancel',
  customer='{{CUSTOMER_ID}}',
  consent_collection={
    'promotions': 'auto',
  },
  after_expiration={
    'recovery': {
      'enabled': True,
    },
  },
)
php
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://account.payske.com/api/key
\Payske\Payske::setApiKey('sk_test_4eC39HqLyjWDarjtT1zdp7dc');

$session = \Payske\Checkout\Session::create([
  'line_items' => [[
    'price' => '{{PRICE_ID}}',
    'quantity' => 1,
  ]],
  'mode' => 'payment',
  'success_url' => 'https://example.com/success',
  'cancel_url' => 'https://example.com/cancel',
  'customer' => '{{CUSTOMER_ID}}',
  'consent_collection' => [
    'promotions' => 'auto',
  ],
  'after_expiration' => [
    'recovery' => [
      'enabled' => True,
    ],
  ],
]);
typescript
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://account.payske.com/api/key
const payske = require('payske')('sk_test_4eC39HqLyjWDarjtT1zdp7dc');

const session = await payske.checkout.sessions.create({
  line_items: [
    {
      price: '{{PRICE_ID}}',
      quantity: 1,
    },
  ],
  mode: 'payment',
  success_url: 'https://example.com/success',
  cancel_url: 'https://example.com/cancel',
  customer: '{{CUSTOMER_ID}}',
  consent_collection: {
    promotions: 'auto',
  },
  after_expiration: {
    recovery: {
      enabled: true,
    },
  },
});
go
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://account.payske.com/api/key
payske.Key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc"

params := &payske.CheckoutSessionParams{
  Mode: payske.String(string(payske.CheckoutSessionModePayment)),
  Customer: payske.String('{{CUSTOMER_ID}}'),
  LineItems: []*payske.CheckoutSessionLineItemParams{
    &payske.CheckoutSessionLineItemParams{
      Price: payske.String('{{PRICE_ID}}'),
      Quantity: payske.Int64(1),
    },
  },
  SuccessURL: payske.String("https://example.com/success"),
  CancelURL:  payske.String("https://example.com/cancel"),
  ConsentCollection: &payske.CheckoutSessionConsentCollectionParams{
    Promotions: payske.String("auto"),
  },
  AfterExpiration: &payske.CheckoutSessionAfterExpirationParams{
    Recovery: &payske.CheckoutSessionAfterExpirationRecoveryParams{
      Enabled: payske.Bool(true),
    },
  },
}

session, _ := session.New(params)
csharp
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://account.payske.com/api/key
PayskeConfiguration.ApiKey = "sk_test_4eC39HqLyjWDarjtT1zdp7dc";

var options = new SessionCreateOptions
{
  LineItems = new List<SessionLineItemOptions>
  {
    new SessionLineItemOptions
    {
      Price = '{{PRICE_ID}}',
      Quantity = 1,
    },
  },
  Mode = "payment",
  Customer = '{{CUSTOMER_ID}}',
  SuccessUrl = "https://example.com/success",
  CancelUrl = "https://example.com/cancel",
  ConsentCollection = new SessionConsentCollectionOptions { Promotions = "auto" },
  AfterExpiration = new SessionAfterExpirationOptions
  {
    Recovery = new SessionAfterExpirationRecoveryOptions
    {
      Enabled = true,
    }
  }
};
java
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://account.payske.com/api/key
Payske.apiKey = "sk_test_4eC39HqLyjWDarjtT1zdp7dc";

SessionCreateParams params =
  SessionCreateParams
    .builder()
    .setSuccessUrl("https://example.com/success")
    .setCancelUrl("https://example.com/cancel")
    .addLineItem(
      SessionCreateParams.LineItem
        .builder()
        .setPrice('{{PRICE_ID}}')
        .setQuantity(1L)
        .build()
    )
    .setConsentCollection(
      SessionCreateParams.ConsentCollection
        .builder()
        .setPromotions(SessionCreateParams.ConsentCollection.Promotions.AUTO)
        .build()
    )
    .setAfterExpiration(
      SessionCreateParams.AfterExpiration
        .builder()
        .setRecovery(
          SessionCreateParams.AfterExpiration.Recovery
            .builder()
            .setEnabled(true)
            .build()
        )
        .build()
    )
    .setMode(SessionCreateParams.Mode.PAYMENT)
    .setCustomer('{{CUSTOMER_ID}}')
    .build();

Session session = Session.create(params);

Get notified of abandonment

Listen to the checkout.session.expired webhook to be notified when customers abandon Checkout and sessions expire.

When the session expires with recovery enabled, the webhook payload contains after_expiration, which includes a URL denoted by after_expiration.recovery.url that you can embed in cart abandonment emails. When the customer opens this URL, it creates a new Checkout Session that’s a copy of the original expired session and they can complete their purchase.

For security purposes, the recovery URL for a session is usable for 30 days, denoted by the after_expiration.recovery.expires_at timestamp.

json
{
  "id": "evt_123456789",
  "object": "event",
  "type": "checkout.session.expired",
  // ...other webhook attributes
  "data": {
    "object": {
      "id": "cs_12356789",
      "object": "checkout.session",
      // ...other Checkout Session attributes
      "consent_collection": {
        "promotions": "auto",
      },
      "consent": {
        "promotions": "opt_in"
      },
      "after_expiration": {
        "recovery": {
          "enabled": true,
          "url": ["https://buy.payske.com/r/live_asAb1724"](https://buy.payske.com/r/live_asAb1724),
          "allow_promotion_code": true,
          "expires_at": 1622908282,
        }
      }
    }
  }
}

Send recovery emails

To send recovery emails, create a webhook handler for expired sessions and send an email that embeds the session’s recovery URL. One customer may abandon multiple Checkout Sessions, each triggering its own checkout.session.expired webhook so make sure to record when you send recovery emails to customers and avoid spamming them.

Node

typescript

// Find your endpoint's secret in your Dashboard's webhook settings
const endpointSecret = 'whsec_...';

// Using Express
const app = require('express')();

// Use body-parser to retrieve the raw body as a buffer
const bodyParser = require('body-parser');

const sendRecoveryEmail = (email, recoveryUrl) => {
  // TODO: fill me in
  console.log("Sending recovery email", email, recoveryUrl);
}

app.post('/webhook', bodyParser.raw({type: 'application/json'}), (request, response) => {
  const payload = request.body;
  const sig = request.headers['payske-signature'];

  let event;

  try {
    event = payske.webhooks.constructEvent(payload, sig, endpointSecret);
  } catch (err) {
    return response.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the checkout.session.expired event
  if (event.type === 'checkout.session.expired') {
    const session = event.data.object;

    // When a Checkout Session expires, the buyer's email is not returned in
    // the webhook payload unless they give consent for promotional content
    const email = session.customer_details?.email
    const recoveryUrl = session.after_expiration?.recovery?.url

    // Do nothing if the Checkout Session has no email or recovery URL
    if (!email || !recoveryUrl) {
      return response.status(200).end();
    }

    // Check if the buyer has consented to promotional emails and
    // avoid spamming people who abandon Checkout multiple times
    if (
      session.consent?.promotions === 'opt_in'
      && !hasSentRecoveryEmailToCustomer(email)
    ) {
      sendRecoveryEmail(email, recoveryUrl)
    }
  }
  response.status(200).end();
});

Optional Adjust session expiration

By default, Checkout Sessions expire 24 hours after they’re created, but you can shorten the expiration time by setting expires_at to get notified of abandonment sooner. The minimum expires_at allowed is 30 minutes from when the session is created.

console
curl https://api.payske.com/v1/checkout/sessions \
  -u sk_test_4eC39HqLyjWDarjtT1zdp7dc: \
  -d "line_items[0][price]"='{{PRICE_ID}}' \
  -d "line_items[0][quantity]"=2 \
  -d customer='{{CUSTOMER_ID}}' \
  -d mode=payment \
  -d success_url="https://example.com/success" \
  -d cancel_url="https://example.com/cancel" \
  -d "expires_at"="{{NOW_PLUS_TWO_HOURS}}"
ruby
# Set your secret key. Remember to switch to your live secret key in production!
# See your keys here: https://account.payske.com/api/key
Payske.api_key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc"

session = Payske::Checkout::Session.create({
  line_items: [{
    price: '{{PRICE_ID}}',
    quantity: 1,
  }],
  mode: 'payment',
  success_url: 'https://example.com/success',
  cancel_url: 'https://example.com/cancel',
  customer: '{{CUSTOMER_ID}}',
  expires_at: Time.now.to_i + (3600 * 2), # Configured to expire after 2 hours
})
python
# Set your secret key. Remember to switch to your live secret key in production!
# See your keys here: https://account.payske.com/api/key
payske.api_key = 'sk_test_4eC39HqLyjWDarjtT1zdp7dc'

session = payske.checkout.Session.create(
  line_items=[{
    'price': '{{PRICE_ID}}',
    'quantity': 1,
  }],
  mode='payment',
  success_url='https://example.com/success',
  cancel_url='https://example.com/cancel',
  customer='{{CUSTOMER_ID}}',
  expires_at=int(time.time() + (3600 * 2)), # Configured to expire after 2 hours
)
php
// Set your secret key. Remember to switch to your live secret key in production!
// See your keys here: https://account.payske.com/api/key
\Payske\Payske::setApiKey('sk_test_4eC39HqLyjWDarjtT1zdp7dc');

$session = \Payske\Checkout\Session::create([
  'line_items' => [[
    'price' => '{{PRICE_ID}}',
    'quantity' => 1,
  ]],
  'mode' => 'payment',
  'success_url' => 'https://example.com/success',
  'cancel_url' => 'https://example.com/cancel',
  'customer' => '{{CUSTOMER_ID}}',
  'expires_at' => time() + (3600 * 2), // Configured to expire after 2 hours
]);
typescript
// Set your secret key. Remember to switch to your live secret key in production!
// See your keys here: https://account.payske.com/api/key
const payske = require('payske')('sk_test_4eC39HqLyjWDarjtT1zdp7dc');

const session = await payske.checkout.sessions.create({
  line_items: [
    {
      price: '{{PRICE_ID}}',
      quantity: 1,
    },
  ],
  mode: 'payment',
  success_url: 'https://example.com/success',
  cancel_url: 'https://example.com/cancel',
  customer: '{{CUSTOMER_ID}}',
  expires_at: Math.floor(Date.now() / 1000) + (3600 * 2), // Configured to expire after 2 hours
});
go
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://account.payske.com/api/key
payske.Key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc"

params := &payske.CheckoutSessionParams{
  Mode: payske.String(string(payske.CheckoutSessionModePayment)),
  Customer: payske.String('{{CUSTOMER_ID}}'),
  LineItems: []*payske.CheckoutSessionLineItemParams{
    &payske.CheckoutSessionLineItemParams{
      Price: payske.String('{{PRICE_ID}}'),
      Quantity: payske.Int64(1),
    },
  },
  SuccessURL: payske.String("https://example.com/success"),
  CancelURL:  payske.String("https://example.com/cancel"),
  ExpiresAt: payske.Int64(time.Now().Add(time.Hour * 2).Unix()), // Configured to expire after 2 hours
}

session, _ := session.New(params)
csharp
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://account.payske.com/api/key
PayskeConfiguration.ApiKey = "sk_test_4eC39HqLyjWDarjtT1zdp7dc";

var options = new SessionCreateOptions
{
  LineItems = new List<SessionLineItemOptions>
  {
    new SessionLineItemOptions
    {
      Price = '{{PRICE_ID}}',
      Quantity = 1,
    },
  },
  Mode = "payment",
  Customer = '{{CUSTOMER_ID}}',
  SuccessUrl = "https://example.com/success",
  CancelUrl = "https://example.com/cancel",
  ExpiresAt = DateTime.UtcNow + new TimeSpan(2, 0, 0) // Configured to expire after 2 hours
};
java
// Set your secret key. Remember to switch to your live secret key in production.
// See your keys here: https://account.payske.com/api/key
Payske.apiKey = "sk_test_4eC39HqLyjWDarjtT1zdp7dc";

SessionCreateParams params =
  SessionCreateParams
    .builder()
    .setSuccessUrl("https://example.com/success")
    .setCancelUrl("https://example.com/cancel")
    .addLineItem(
      SessionCreateParams.LineItem
        .builder()
        .setPrice('{{PRICE_ID}}')
        .setQuantity(1L)
        .build()
    )
    .setExpiresAt(Instant.now().plus(2, ChronoUnit.HOURS).getEpochSecond()) // Configured to expire after 2 hours
    .setMode(SessionCreateParams.Mode.PAYMENT)
    .setCustomer('{{CUSTOMER_ID}}')
    .build();

Session session = Session.create(params);

Optional Track conversion

When the customer opens the recovery URL for an expired Checkout Session, it creates a new Checkout Session that’s a copy of the abandoned session.

To verify whether a recovery email resulted in a successful conversion, check the recovered_from attribute in the checkout.session.completed webhook for the new Checkout Session. This attribute references the original session that expired.

Node

typescript

// Find your endpoint's secret in your Dashboard's webhook settings
const endpointSecret = 'whsec_...';

// Using Express
const app = require('express')();

// Use body-parser to retrieve the raw body as a buffer
const bodyParser = require('body-parser');

const logRecoveredCart = (sessionId, recoveredFromSessionId) => {
  // TODO: fill me in
  console.log("Recording recovered session", sessionId, recoveredFromSessionId);
}

app.post('/webhook', bodyParser.raw({type: 'application/json'}), (request, response) => {
  const payload = request.body;
  const sig = request.headers['payske-signature'];

  let event;

  try {
    event = payske.webhooks.constructEvent(payload, sig, endpointSecret);
  } catch (err) {
    return response.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the checkout.session.completed event
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;

    const recoveryFromSessionId = session.recovered_from
    if (recoveryFromSessionId) {
      // Log relationship between successfully completed session and abandoned session
      logRecoveredCart(session.id, recoveryFromSessionId)
    }

    // Handle order fulfillment
  }

  response.status(200).end();
});