Guides & Tutorials13 min read

Build Shopify Abandoned Cart Recovery with Webhooks and AI Marketing Automation

By ButterGrow Team

TL;DR

This hands on tutorial shows how to capture Shopify cart events with a secure webhook, draft personalized emails and SMS with OpenClaw, and orchestrate timed follow ups that respect quiet hours. You will implement HMAC verification, generate copy with a prompt, and send through ESP and SMS providers while logging outcomes for feedback. The goal is a working abandoned cart recovery flow that proves out AI marketing automation end to end with clear checks and reliable delivery. You can deploy the pattern on a test store in hours and expand it as you validate results.

What you will build

You will ship a production ready abandoned cart recovery flow for Shopify that:

  • Receives carts/update or checkouts/create webhooks.
  • Verifies the signature before processing.
  • Generates personalized subject lines, email body, and a short SMS using an OpenClaw task.
  • Sends email via an ESP and SMS via a messaging API.
  • Applies quiet hours and per customer frequency capping.
  • Tracks opens, clicks, and conversions for simple bandit optimization.

Along the way you will see sample code, an OpenClaw playbook, and testing tips.

Prerequisites

  • A Shopify store with admin access to create webhooks and a private app secret.
  • API keys for your chosen email and SMS providers. The examples use SendGrid and Twilio.
  • An OpenClaw workspace.
  • Basic Node.js or Python familiarity for the webhook endpoint.
  • Your ButterGrow account.

Architecture overview

The flow is simple and reliable:

  1. Shopify fires a webhook when a cart is updated or a checkout starts.
  2. Your endpoint receives the event, verifies the signature, and writes a dedup record keyed by the webhook id and customer id.
  3. The endpoint enqueues a job for OpenClaw with the minimal fields needed to compose a message.
  4. The OpenClaw playbook generates copy, schedules sends, calls provider APIs, and records outcomes.
  5. A short feedback loop updates subject line win rates and suppresses users who completed orders.

The sequence is resilient and will tolerate retries from Shopify and your providers if you use idempotency at the call sites.

Events to subscribe to in Shopify

For abandoned cart flows, common choices are carts/update, checkouts/create, and checkouts/update. Some teams prefer orders/create to cancel or suppress sends when a purchase completes. Shopify provides a clear description of events in the Shopify Webhooks reference which is the starting point for registration and testing.

Sample webhook endpoint with HMAC verification

The handler must verify the X-Shopify-Hmac-Sha256 header against the raw body. The example below uses Node.js with Express. The same approach applies in other frameworks as long as you can access the unparsed request bytes.

// server.js
const express = require('express');
const crypto = require('crypto');

const app = express();

// Expose raw body for HMAC verification
app.use('/webhooks/shopify', express.raw({ type: '*/*' }));

function verifyShopifyHmac(req, secret) {
  const hmacHeader = req.get('X-Shopify-Hmac-Sha256') || '';
  const digest = crypto
    .createHmac('sha256', secret)
    .update(req.body)
    .digest('base64');
  const safe = crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmacHeader));
  return safe;
}

app.post('/webhooks/shopify', async (req, res) => {
  if (!verifyShopifyHmac(req, process.env.SHOPIFY_APP_SECRET)) {
    return res.status(401).send('invalid signature');
  }

  // Parse JSON after verification
  const event = JSON.parse(req.body.toString('utf8'));
  const idempotencyKey = `${event.id || event.token}:${event.customer?.id || 'anon'}`;

  // Write a short lived dedup record to your store here
  // await store.tryInsert(idempotencyKey, { ttlSeconds: 900 });

  // Minimal payload for composition
  const payload = {
    idempotencyKey,
    customer: {
      email: event.customer?.email,
      phone: event.customer?.phone,
      firstName: event.customer?.first_name,
    },
    items: (event.line_items || event.cart?.line_items || []).map(i => ({
      title: i.title,
      price: i.price,
      url: i.url || i.product_url || '',
      image: i.image || '',
    })),
    cartUrl: event.abandoned_checkout_url || event.cart?.url || '',
  };

  // Enqueue for OpenClaw. This can be an HTTP call to your workflow gateway.
  // await fetch(process.env.OPENCLAW_INGRESS_URL, { method: 'POST', body: JSON.stringify(payload) });

  res.status(200).send('ok');
});

const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`listening on ${port}`));

Step 1Model the copy you want to generate

Write down the inputs and outputs so the workflow is explicit.

  • Inputs: first name, up to three product titles, a cart recovery URL, and the store brand name.
  • Outputs: two subject variants, a short preview line, an HTML email body, and a 160 character SMS.

Keep prompts simple. The goal is consistent tone and length, not art.

Step 2Create the OpenClaw playbook

Below is a compact playbook that you can paste into your workspace and adapt. It assumes the gateway exposes a cart.abandoned topic you post to from the webhook handler.

# openclaw.abandoned-cart.yaml
apiVersion: v1
kind: Playbook
metadata:
  name: abandoned-cart-recovery
spec:
  triggers:
    - type: topic
      topic: cart.abandoned
  variables:
    - name: sendgrid_api_key
      from: secret:sendgrid_api_key
    - name: twilio_account_sid
      from: secret:twilio_account_sid
    - name: twilio_auth_token
      from: secret:twilio_auth_token
    - name: twilio_from
      from: secret:twilio_from
  steps:
    - id: compose
      type: llm.generate
      params:
        system: |
          You are a concise ecommerce copywriter. Keep email body under 180 words. Match brand tone: friendly, helpful, clear.
        prompt: |
          Customer: {{ customer.firstName }}
          Items: {{ items | map: 'title' | join: ', ' }}
          Cart URL: {{ cartUrl }}
          Task: Generate two subject lines, one preview line, an HTML body, and a 160 character SMS with a single call to action link.
          Constraints: Avoid discounts unless present in data. Use first name if available.
        outputs:
          - key: subject_a
          - key: subject_b
          - key: preview
          - key: email_html
          - key: sms_text

    - id: choose_subject
      type: decision.bandit
      params:
        arms:
          - {{ steps.compose.output.subject_a }}
          - {{ steps.compose.output.subject_b }}
        epsilon: 0.2
      outputs:
        - key: subject

    - id: schedule
      type: schedule.window
      params:
        timezone: "America/New_York"
        quietHours:
          start: "20:00"
          end: "08:00"
        firstSendDelayMinutes: 30

    - id: send_email
      type: http.request
      when: {{ customer.email }}
      params:
        url: https://api.sendgrid.com/v3/mail/send
        method: POST
        headers:
          Authorization: "Bearer {{ var.sendgrid_api_key }}"
          Content-Type: application/json
        body: |
          {
            "personalizations": [{ "to": [{ "email": "{{ customer.email }}" }] }],
            "from": { "email": "noreply@yourbrand.com", "name": "Your Brand" },
            "subject": "{{ steps.choose_subject.output.subject }}",
            "content": [{ "type": "text/html", "value": "{{ steps.compose.output.email_html }}" }]
          }

    - id: send_sms
      type: http.request
      when: {{ customer.phone }}
      params:
        url: https://api.twilio.com/2010-04-01/Accounts/{{ var.twilio_account_sid }}/Messages.json
        method: POST
        auth:
          type: basic
          username: {{ var.twilio_account_sid }}
          password: {{ var.twilio_auth_token }}
        form:
          From: {{ var.twilio_from }}
          To: {{ customer.phone }}
          Body: {{ steps.compose.output.sms_text }}

    - id: log_outcome
      type: metric.emit
      params:
        event: cart_recovery_sent
        properties:
          email: {{ !!customer.email }}
          sms: {{ !!customer.phone }}
          subject: {{ steps.choose_subject.output.subject }}

This playbook covers copy generation, a simple epsilon greedy chooser, quiet hours, and downstream API calls. You can extend it with a webhook receiver for opens and clicks to update win rates, or add suppression when an order completes.

Step 3Map inputs from the webhook to the playbook

The webhook handler should post only what the playbook expects. Here is a small example payload.

{
  "customer": { "firstName": "Ava", "email": "ava@example.com", "phone": "+14445550123" },
  "items": [
    { "title": "Linen Shirt", "price": "48.00", "url": "https://store.example/p/linen-shirt" },
    { "title": "Chino Pants", "price": "69.00", "url": "https://store.example/p/chino-pants" }
  ],
  "cartUrl": "https://store.example/cart/recover/abc123",
  "idempotencyKey": "12345:67890"
}

Step 4Send an email through SendGrid

If you prefer a raw request instead of the playbook HTTP step, the following cURL shows the minimal POST with an API key. The structure matches the playbook body so you can swap either approach later.

curl -X POST https://api.sendgrid.com/v3/mail/send \
  -H "Authorization: Bearer $SENDGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "personalizations": [{ "to": [{ "email": "ava@example.com" }] }],
    "from": { "email": "noreply@yourbrand.com", "name": "Your Brand" },
    "subject": "Ava, your picks are still in your cart",
    "content": [{ "type": "text/html", "value": "<p>Finish your order here: <a href=\"https://store.example/cart/recover/abc123\">resume checkout</a></p>" }]
  }'

You can find provider specifics in the SendGrid Mail Send API which documents required fields and recommended headers.

Step 5Send an SMS through Twilio

Keep SMS under 160 characters and include only one link. The cURL below pairs with the playbook step and shows basic auth using your account credentials.

curl -X POST https://api.twilio.com/2010-04-01/Accounts/$TWILIO_SID/Messages.json \
  -u "$TWILIO_SID:$TWILIO_TOKEN" \
  --data-urlencode From="$TWILIO_FROM" \
  --data-urlencode To="+14445550123" \
  --data-urlencode Body="Ava, your cart is waiting. Finish here: https://store.example/cart/recover/abc123"

Provider details are available in the Twilio Programmable Messaging API which includes rate limits and delivery status webhooks.

Step 6Respect quiet hours and apply frequency caps

Most teams do not want to send at night. The table below shows one sensible default. Adjust times to your customer base and enable per user suppression if a purchase occurs.

Window Local time Notes
First reminder 30 minutes after event Use the preview line and subject A or subject B from the bandit chooser
Second reminder Next day between 10:00 and 14:00 Skip if complete order is recorded
SMS optional Same day between 16:00 and 18:00 Only if no email open is detected and an opt in phone number exists

The schedule window step in the playbook implements quiet hours for you. If you prefer code, add a simple time window check around the send steps and reschedule when outside the window.

Step 7Track opens, clicks, and conversions

At minimum, pass the email subject and a stable cart id into your tracking. Then emit an outcome metric with fields for the chosen subject, email open, click, and whether an order completed in the next seven days.

Step 8Add idempotency and retry safety

Webhooks and provider APIs retry on failure. Avoid accidental duplicate sends with a short lived dedup record keyed by event id and customer id. For deeper guidance, see our tutorial on retries and dead letter queues in workflows.

Testing the flow end to end

Use the Shopify admin or the CLI to trigger test events while your webhook endpoint runs locally with a public tunnel. Validate signature failures, quiet hour reschedules, subject selection variability, and idempotency. Keep logs for each step with the idempotency key so a single cart can be traced without guesswork.

When you are ready to ship, promote the playbook to your production workspace and rotate API keys.

Troubleshooting checklist

  • Signature verification fails even with the correct secret. Confirm that the request body is unparsed raw bytes at the time of hashing and that no whitespace or JSON pretty printing is introduced.
  • Emails or SMS send twice for the same user. Ensure the dedup record is written before any provider call and that the record uses a stable id. Shopify sends retries if your endpoint returns anything except HTTP 200.
  • Messages arrive during quiet hours. Double check the timezone and that your schedule step uses local time, not UTC. If workers run in another region, pass the intended timezone in the job payload.
  • Open and click tracking looks low. Verify DNS for your sending domain and use a recognizable from name. Also review the call to action location in the email body.

What you learned and next steps

You now have a working abandoned cart recovery flow that handles signature checks, deduplication, copy generation, timed sends, and basic optimization. You can grow this into a full lifecycle program by adding browse abandonment, post purchase upsells, and reactivation. To see how the broader product supports that roadmap, review the AI marketing automation features, and see how it stacks up in the comparison. If you are ready to build your own flow, head over to get started in minutes.

Your next iteration could add on brand voice controls, deep personalization using product metadata, and a catalog aware template. For related reading, browse more from the ButterGrow blog.

To try this pattern in another channel, consider converting the email body to a social retargeting script with a different trigger. That change is just a new provider call and a different quiet hour window.

ButterGrow has the pieces you need for scalable workflows that respect your brand and your customer.

References

Frequently Asked Questions

How do I verify the X-Shopify-Hmac-Sha256 signature for webhook security?+

Use your app secret to compute an HMAC SHA256 over the raw request body and compare it to the base64 value in the X-Shopify-Hmac-Sha256 header using a constant time comparison. Many frameworks require access to the unparsed body, so add middleware to expose the raw bytes.

What is the simplest way to schedule a follow up message without spamming customers?+

Store a per-customer send log and apply a quiet period policy in your workflow. A practical default is to send the first reminder at T+30 minutes, a second at T+20 hours, and to skip all sends if a completed order is detected.

Can I run bandit testing on subject lines without a full experimentation platform?+

Yes. Generate two subject variants, assign incoming events to a variant with a small exploration rate, and update win rates from opens or conversions. Our bandit testing playbook link in this tutorial shows the mechanics for lightweight optimization.

How do I prevent duplicate emails when Shopify retries a webhook?+

Use an idempotency key derived from the webhook id and customer id. Write a short lived dedup record to your store before sending. If the key exists, drop the send and log the duplicate. This works well with a DLQ for eventual retries.

Which providers work well for email and SMS in this setup?+

SendGrid or Mailgun are popular for email, and Twilio for SMS. The examples here show SendGrid and Twilio. You can swap providers by changing the HTTP request task and headers while keeping the same payload shape.

Where should I start if I am new to OpenClaw workflows?+

Begin with the ButterGrow getting started flow to create a workspace and your first playbook. Then adapt the sample abandoned cart playbook in this guide by adding your API keys and Shopify secret.

Ready to try ButterGrow?

See how ButterGrow can supercharge your growth with a quick demo.

Book a Demo