Webhooks and serverless functions for campaigns that react to your shop

TL;DR

Difficulty: Intermediate

A webhook is the shop telling you the moment something changes. A serverless function is a small piece of code that acts on it.

  • Verify every webhook's signature against the raw body before you believe it.
  • Answer in seconds and put the real work on a queue, because Shopify gives you five seconds.
  • Webhooks arrive twice, late and out of order, so make each action safe to repeat and ignore stale updates.
  • Retry with growing waits, send repeated failures to a dead letter queue, and run a nightly sweep for what was missed.
  • Check email permission before any reminder, and let expected margin, not a fixed threshold, decide the discount.

Adverts that keep running after the bestseller sold out, and sale banners that outlive the sale, are a timetable problem. In this article I show how webhooks and serverless functions let an invented Galway candle shop react the moment something changes, and how to build it so that duplicates, late messages and missed events never make it do the wrong thing.

When the shop changes, the marketing should hear about it

Most marketing runs on a timetable. Someone checks stock on Monday, pauses the advert for the candle that sold out on Saturday, and remembers on Wednesday that the sale ended last night. In between, the adverts keep promising things the shop can no longer deliver.

The fix is to let the shop tell the marketing when something changes, through a webhook, and to have a small piece of code ready to act on it, usually a serverless function. This article wires the two together so they do the right thing every time, including when the message arrives twice, late or not at all.

Throughout I use one invented business. Claddagh Candle Co is an online candle shop in Galway, run by Ciarán, on Shopify, at claddaghcandles.example. Its bestseller is the Winter Spice candle at €24, and it has its own ad group in Google Ads. Nobody real, and every number below is made up so I can show every step.

A webhook is the shop phoning you, instead of you phoning the shop

You can ask another system, again and again, "anything new?" That is polling. Or it can tell you the moment something happens. That is a webhook: the shop sends an HTTP request (a small message over the web, like a browser loading a page) to an address you gave it, with the details in the body.

Shopify calls its events topics. Each delivery carries headers that say what it is about: the topic, such as inventory_levels/update, the shop it came from, a delivery id, the time it was triggered, and a signature.1 The body is JSON. For a stock change it is short: which inventory item, at which location, how many are available, and when it was updated.1

Polling has an honest place. If you check every II minutes and a change can happen at any moment, it lands on average halfway between two checks.

dˉ=I2\bar{d} = \frac{I}{2}

Here dˉ\bar{d} is the average delay before you notice a change, in minutes, and II is the polling interval, in minutes. It is the mean of a delay that is equally likely to be anything from 00 to II, explained from scratch in Foundations: the mean.

Worked example: what the Winter Spice sell out costs

The Winter Spice ad group gets 40 clicks a day at €0.60 a click. On a Saturday at 2pm the last candle sells. Ciarán checks his adverts by hand on Monday at 9am, 43 hours later. The clicks bought in between, all landing on a sold out page:

4024×43×0.60=43\frac{40}{24} \times 43 \times 0.60 = 43

Here 4024\frac{40}{24} is clicks per hour, 4343 is the hours until Ciarán looks, and 0.600.60 is the cost per click in euro. That is €43 on clicks that could not turn into orders.

Now a script that polls the stock every 15 minutes:

dˉ=152=7.5\bar{d} = \frac{15}{2} = 7.5

Here dˉ\bar{d} is 7.5 minutes of average blindness. In 7.5 minutes the ad group buys 7.5×401,440=0.217.5 \times \frac{40}{1{,}440} = 0.21 clicks, about 12 cent. A webhook gets that to a few seconds, which saves Ciarán another 12 cent.

Spend on clicks to a sold out product, by how the stock is checkedChecking by hand on Monday morning costs €43 in clicks to a sold out page, polling every 15 minutes costs about 12 cent, and a webhook costs close to nothing.Claddagh's Winter Spice sell out, spend before the ad group is paused€0€12.5€25€37.5€50€43Ciarán checks onMonday€0.1Script pollsevery 15 minutes€0.0WebhookWasted spend (€)

Each bar is one way of noticing the sell out. The vertical axis is the money spent on clicks between the last candle selling and the ad group being paused. The takeaway: almost all the saving comes from automating the check at all, not from the webhook. For one product a 15 minute poll is grand. Webhooks earn their place when you watch thousands of things (polling 2,000 products every 15 minutes is 192,000 requests a day) or must react within a minute, such as a checkout someone has just left.

Serverless means someone else keeps the server on, within limits

A webhook needs an address that is always listening. A serverless function provides one without a server of your own: you upload a function, the provider runs it when a request arrives, and you pay per run. The two I use most are AWS Lambda and Cloudflare Workers.

The limits are where the marketing copy goes quiet. A Lambda function can run for at most 15 minutes, with 128 MB to 10,240 MB of memory, and a synchronous request or response of at most 6 MB. An account starts with 1,000 concurrent executions by default, and AWS notes that new accounts start lower and are raised with use.2 A Cloudflare Worker gets 10 ms of CPU time per request on the free plan and 30 seconds by default on the paid plan, up to five minutes, with 128 MB of memory. The free plan stops at 100,000 requests a day.3 So "scales instantly", as the old version of this article promised, is true up to a quota, worth knowing before the Christmas rush rather than during it.

Worked example: Claddagh's monthly bill

Claddagh receives about 30,000 webhooks a month, because every order changes stock. Each one runs two functions: a receiver at 128 MB for 0.1 seconds, and a worker at 256 MB for 0.5 seconds. Lambda bills compute in gigabyte seconds, memory in GB times run time in seconds:

30,000×0.125×0.1+30,000×0.25×0.5=4,12530{,}000 \times 0.125 \times 0.1 + 30{,}000 \times 0.25 \times 0.5 = 4{,}125

Here 30,00030{,}000 is the runs per function, 0.1250.125 and 0.250.25 are the memory in GB, and 0.10.1 and 0.50.5 are the seconds per run. So 4,125 GB seconds and 60,000 requests a month. The Lambda free tier covers one million requests and 400,000 GB seconds a month.4 Ciarán's functions use about 1% of that compute allowance and 6% of the requests. Even without it, AWS's list price of 0.20permillionrequestsand0.20 per million requests and 0.0000166667 per GB second in its US East region comes to about 8 US cents.4 The queue and database I add below bill separately, but at this size the bill is not the cost. Building it properly is.

Prove the message is genuine before you believe it

A webhook address is public. Anyone who finds it can send a request that looks like Shopify saying "Winter Spice is out of stock". The old version of this article would have believed it, because its code acted on the body without a single check.

Shopify signs every delivery. It computes an HMAC (a hash based message authentication code: a fingerprint of the message made with a secret only you and Shopify know) over the raw request body, using SHA256 and your app's client secret, and sends it base64 encoded in the X-Shopify-Hmac-Sha256 header. You compute the same fingerprint on your side and compare. Shopify stresses that you must use the raw body, before any framework parses it, or the fingerprints never match.5

Stripe adds a timestamp. Its Stripe-Signature header signs the timestamp, a full stop and the raw body together, and its libraries reject anything older than five minutes by default, so a captured message cannot be replayed next week.6

Here is Claddagh's receiver as a Lambda function behind a function URL. Lambda hands the body over as a string, base64 encoded when the content is binary, so the first job is to get the exact bytes back.7

import base64
import hashlib
import hmac
import os

import boto3

sqs = boto3.client("sqs")
QUEUE_URL = os.environ["QUEUE_URL"]
SECRET = os.environ["SHOPIFY_CLIENT_SECRET"].encode()


def lambda_handler(event, context):
    headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
    body = event.get("body") or ""
    raw = base64.b64decode(body) if event.get("isBase64Encoded") else body.encode("utf-8")

    expected = base64.b64encode(hmac.new(SECRET, raw, hashlib.sha256).digest()).decode()
    if not hmac.compare_digest(expected, headers.get("x-shopify-hmac-sha256", "")):
        return {"statusCode": 401, "body": "invalid signature"}

    sqs.send_message(
        QueueUrl=QUEUE_URL,
        MessageBody=raw.decode("utf-8"),
        MessageAttributes={
            "topic": {"DataType": "String", "StringValue": headers["x-shopify-topic"]},
            "webhook_id": {"DataType": "String", "StringValue": headers["x-shopify-webhook-id"]},
        },
    )
    return {"statusCode": 200, "body": "ok"}

hmac.compare_digest compares in constant time, so nobody can learn the signature by timing wrong guesses. And the function does nothing except put the message on a queue, the subject of the next section.

Worked example: one changed digit

To keep it readable, take a made up secret, demo_secret, and a shortened body: {"inventory_item_id":808950810,"available":0}. The HMAC SHA256 of that body with that key, base64 encoded, is WsbEAO23WBTv/jRCvwSm+JKbk0mqDEnVoHet6eHzjW8=. That is what Shopify would put in the header.

Now someone sends the same request with "available":9, hoping to switch an ad group back on. The fingerprint of that body is 8VaGxE/VorQ4ejSaiw7t2BCxAFr1PMnPTAxhDkce51w=, nothing like the first. The comparison fails, the receiver answers 401, nothing happens. Without the secret, nobody can produce the right fingerprint for a body of their choosing.

On Cloudflare the receiver looks almost the same in JavaScript. Workers support HMAC through the Web Crypto API, and Cloudflare Queues takes the message.89

export default {
  async fetch(request, env) {
    if (request.method !== "POST") return new Response("method not allowed", { status: 405 });
    const raw = await request.arrayBuffer();

    let signature;
    try {
      const header = request.headers.get("X-Shopify-Hmac-Sha256") || "";
      signature = Uint8Array.from(atob(header), (c) => c.charCodeAt(0));
    } catch {
      return new Response("invalid signature", { status: 401 });
    }

    const key = await crypto.subtle.importKey(
      "raw",
      new TextEncoder().encode(env.SHOPIFY_CLIENT_SECRET),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["verify"],
    );
    if (!(await crypto.subtle.verify("HMAC", key, signature, raw))) {
      return new Response("invalid signature", { status: 401 });
    }

    await env.SHOP_EVENTS.send({
      topic: request.headers.get("X-Shopify-Topic"),
      webhookId: request.headers.get("X-Shopify-Webhook-Id"),
      body: new TextDecoder().decode(raw),
    });
    return new Response("ok");
  },
};

I would keep the whole chain on one platform rather than split it across two, as the old version did: two clouds means two sets of logs to search. From here on I stay on AWS.

Answer in seconds, do the work afterwards

Shopify waits five seconds for your answer. If it does not get a success in time, the delivery counts as failed. Shopify retries failed deliveries up to eight times over four hours, and if the failures persist, it removes the subscription altogether.10 Stripe tells you to return a success "before any complex logic", to process events from a queue, and retries for up to three days in live mode.6

So the receiver only checks the signature, writes the message to a queue and answers. A second function, the worker, picks messages off the queue and does the slow work at its own pace. On AWS that queue is SQS (Simple Queue Service), which Lambda reads directly.

From a Shopify event to a paused ad groupShopify sends a webhook to a receiver function, which verifies the signature and either rejects it or puts it on a queue and answers within seconds. A worker function takes it from the queue, skips it if it is older than the last update it saw, acts if not, and sends messages that keep failing to a dead letter queue.Stock changes in Shopify, which sends awebhookReceiver function checks the HMACsignatureDoes the signature match?noAnswer 401 and do nothingyesPut the message on the queue and answer200 within secondsWorker function takes the message fromthe queueIs it newer than the last updateseen for this item?noSkip it, a later update already wonyesPause or switch on the ad group,retrying with backoffAfter five failed attempts the messagegoes to the dead letter queue

Read it from the top. Each box is a step, each diamond a question. The takeaway is the split: above the queue is fast and simple, below it is slow and careful, and Shopify only waits for the fast half.

Worked example: a slow Monday at Google

On a Monday morning the Google Ads API takes eight seconds to answer. Had the receiver called it directly, Shopify would have given up after five and sent the delivery again while the first attempt was still pausing the ad group. With the queue in between, the receiver answered in a tenth of a second, and the worker took its eight seconds with nobody waiting.

Every message can arrive twice, late, or out of order

The old version of this article skipped this part entirely, and it decides whether the whole thing can be trusted.

Webhooks are delivered at least once, not exactly once. Shopify says the same delivery can arrive more than once and tells you to process webhooks idempotently, or to skip repeats by the X-Shopify-Webhook-Id header.11 Stripe says the same, and that events do not arrive in the order they happened.6 Shopify guarantees no order either, points to the X-Shopify-Triggered-At timestamp, and tells you to run reconciliation jobs that fetch the real state now and then rather than rely on webhooks alone.12 The queue in the middle is at least once too: SQS can hand you the same message twice on rare occasions.13

So the worker has to cope with three things: a duplicate, a stale message overtaking a fresh one, and a message that never comes.

An action is idempotent if doing it twice leaves the world as doing it once would. "Set this ad group to paused" is idempotent: pausing a paused ad group changes nothing. "Send this customer a reminder email" is not: twice is two emails. The first kind needs no deduplication. The second kind does.

Order is a separate problem. If "available: 0" arrives after "available: 12", a worker that follows the last message it received pauses an ad group for a product in stock. The guard is to remember the newest timestamp seen per item and ignore anything older. DynamoDB, AWS's key value database, can do that check and the write in one step with a condition expression, which makes the write fail if the condition is false.14

import json
import os
from datetime import datetime

import boto3
from botocore.exceptions import ClientError
from google.ads.googleads.client import GoogleAdsClient
from google.api_core import protobuf_helpers

dynamodb = boto3.client("dynamodb")
STATE_TABLE = os.environ["STATE_TABLE"]
CUSTOMER_ID = os.environ["GOOGLE_ADS_CUSTOMER_ID"]
WATCHED = json.loads(os.environ["WATCHED_ITEMS"])  # {"808950810": {"ad_group_id": "...", "pause_at": 0}}
ads = GoogleAdsClient.load_from_env()


def is_newest(item_id, updated_epoch):
    try:
        dynamodb.update_item(
            TableName=STATE_TABLE,
            Key={"item_id": {"S": item_id}},
            UpdateExpression="SET last_seen = :t",
            ConditionExpression="attribute_not_exists(last_seen) OR last_seen <= :t",
            ExpressionAttributeValues={":t": {"N": str(updated_epoch)}},
        )
        return True
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return False
        raise


def set_ad_group_paused(ad_group_id, paused):
    service = ads.get_service("AdGroupService")
    operation = ads.get_type("AdGroupOperation")
    ad_group = operation.update
    ad_group.resource_name = service.ad_group_path(CUSTOMER_ID, ad_group_id)
    status = ads.enums.AdGroupStatusEnum
    ad_group.status = status.PAUSED if paused else status.ENABLED
    ads.copy_from(operation.update_mask, protobuf_helpers.field_mask(None, ad_group._pb))
    service.mutate_ad_groups(customer_id=CUSTOMER_ID, operations=[operation])


def lambda_handler(event, context):
    failures = []
    for record in event["Records"]:
        try:
            if record["messageAttributes"]["topic"]["stringValue"] != "inventory_levels/update":
                continue
            level = json.loads(record["body"])
            item_id = str(level["inventory_item_id"])
            watched = WATCHED.get(item_id)
            if watched is None:
                continue
            updated = int(datetime.fromisoformat(level["updated_at"]).timestamp())
            if not is_newest(item_id, updated):
                continue
            if level["available"] is None:  # stock not tracked for this item
                continue
            set_ad_group_paused(watched["ad_group_id"], level["available"] <= watched["pause_at"])
        except Exception:
            failures.append({"itemIdentifier": record["messageId"]})
    return {"batchItemFailures": failures}

The pausing code follows Google's own example for pausing an ad, applied to an ad group.15 The batchItemFailures list is Lambda's partial batch response: with ReportBatchItemFailures switched on, only the failed messages go back on the queue.16 The guard lets an equal timestamp through on purpose, so a retry of a message whose Google call failed is not mistaken for a stale one. A plain duplicate passes too, which is harmless, because setting a status twice changes nothing.

Worked example: the restock that arrived first

On Thursday Ciarán sells the last two Winter Spice candles at 4.10pm, and at 4.12pm a delivery of 12 is booked into stock. Shopify sends two webhooks: "available: 0, updated 4.10pm" and "available: 12, updated 4.12pm". The 4.12pm one arrives first.

The worker sees 4.12pm, finds nothing stored, writes 4.12pm and keeps the ad group on. Then the 4.10pm message turns up. The condition "last seen is no later than 4.10pm" is false, because 4.12pm is stored, so DynamoDB refuses the write and the worker skips the message. Without the guard, the ad group would have been paused with 12 candles on the shelf. If the 4.12pm message arrives a second time, it passes and sets "on" again, which changes nothing.

When a call fails, wait longer each time, then give up loudly

Sometimes Google's API is busy. Retrying straight away usually fails again, and if every worker retries at once they make the crowd worse. The standard answer, as Marc Brooker describes it in Amazon's Builders' Library, is capped exponential backoff: wait longer after each attempt, up to a maximum, plus a little randomness, called jitter, so retries spread out. He also warns that retrying is only safe for calls that are idempotent.17

wk=min⁡(wmax⁡,  b⋅2k−1)w_k = \min\left(w_{\max},\; b \cdot 2^{k-1}\right)

Here wkw_k is the wait before retry number kk, bb is the base wait, 2k−12^{k-1} doubles it each time, and wmax⁡w_{\max} is the cap. Doubling is growth at 100% per step, the arithmetic of Foundations: compounding. Adding up the first nn waits, before the cap bites, gives the total wait:

W=b (2n−1)W = b\,(2^{n} - 1)

Here WW is the total wait across nn retries and bb is the base wait, a geometric sum covered in Foundations: compounding.

Worked example: four retries inside the worker

Ciarán's worker retries a failed Google Ads call four times, starting at two seconds, with a cap of 30 seconds that is never reached. The waits are 2, 4, 8 and 16 seconds:

W=2×(24−1)=30W = 2 \times (2^4 - 1) = 30

Here 22 is the base wait bb in seconds, 44 is the number of retries nn, and 3030 is the total wait in seconds. Add the calls themselves and the worker can need about a minute in a bad case, so its timeout is set to 90 seconds.

That number then sets the queue. AWS asks for a queue visibility timeout (how long a message stays hidden while a worker has it) of at least six times the function timeout, so nine minutes. And it recommends letting each message be received at least five times before it moves to a dead letter queue.18 A dead letter queue holds messages for a human once every attempt has failed.19 Ciarán gets an email whenever anything lands there, because a message that fails five times is a bug, and bugs should be loud.

Use the feed for products, and the ad API only for what the feed cannot do

The old version of this article had a webhook create a Google search ad, a Meta ad set and a Microsoft Ads keyword group for every new product. I would not build that.

Products and stock reach Google through the product feed in Merchant Center. Marking a product out of stock there "will prevent the product from showing in most shopping experiences", and pausing a product hides it from Shopping ads and free listings for up to 14 days.20 Too many products with the wrong availability can get the account suspended.21 If your shop updates the feed by API, note that the Content API for Shopping reached its sunset on 18 August 2026, requests without an extension have been failing intermittently since 1 September 2026, and Google's advice is to move to the Merchant API.22

What a feed cannot do is pause a search ad group written around one product, like Ciarán's Winter Spice ad group. That is where the webhook earns its keep.

Worked example: three new candles in November

Ciarán adds three new scents in November. The old approach would have created three template written search ads, live before anyone read them. Instead the products reach Merchant Center through the feed and the worker ignores them, because none is on the watched list. If one takes off, Ciarán writes it a proper ad group and adds one line to the list. A human writes the words, the automation does the switching.

Abandoned checkouts: check permission first, then let the margin decide

A customer types their email into the checkout, then leaves. A reminder, a reminder with a discount, or nothing?

Permission comes first. A reminder about a basket is marketing email, and in Ireland the ePrivacy Regulations 2011 (S.I. No. 336 of 2011) decide when you may send it. Regulation 13 asks for the person's consent, with one narrow exception for existing customers in regulation 13(11): you got the address from them in the context of a sale, you are marketing your own products of a similar kind, you gave them a clear, easy and free way to object when you collected it and in every message since, and the sale was no more than 12 months before the email.23 I have covered it in detail in the article on consent and email capture. Someone who abandoned a checkout has not completed a sale, so the existing customer rule does not cover them. The Data Protection Commission (DPC) says so in as many words: no electronic marketing to a prospective customer who does not complete a purchase, the example being someone who never finishes the checkout.24 That is stricter than the UK rule, which also covers negotiations for a sale. In the Inteligo Media case in November 2025 the Court of Justice of the EU held that a sale needs payment,25 and a basket left behind has none. A customer who bought from Ciarán in the last 12 months may still be covered by that earlier sale, but the abandoned checkout never counts on its own. So the safe test is consent, and the place to find it is the box the customer ticked: Shopify's checkout webhook carries buyer_accepts_marketing alongside the email.1 Shopify's own abandoned checkout automation sends only to subscribed customers by default.26 And if you build your own app, Shopify redacts customer fields such as the email unless your app has been approved to receive them.27

Then the decision. The old version emailed a discount whenever the margin was above 40%. A fixed threshold ignores how many people would have come back anyway, and how many more a discount brings. Expected value covers both.

ΔE=pa (m−c)−p0 m\Delta E = p_a\,(m - c) - p_0\, m

Here ΔE\Delta E is the expected extra margin per abandoned checkout from taking the action, compared with doing nothing, pap_a is the share who complete the order after the action, mm is the margin on the order in euro, cc is the cost of the action per completed order (the discount), and p0p_0 is the share who would have come back with no email at all. Expected value is explained in Foundations: expected value, and there is more on deciding this way in decision theory for founders.

Worked example: two baskets, two answers

From his own past sends, Ciarán estimates that 4% of abandoned checkouts come back with no email, 8% after a plain reminder, and 11% after a reminder with 10% off. Take a €40 basket of his own candles, with a 55% margin, so m=22m = 22 and a 10% discount costs c=4c = 4:

ΔEplain=0.08×22−0.04×22=1.76−0.88=0.88\Delta E_{\text{plain}} = 0.08 \times 22 - 0.04 \times 22 = 1.76 - 0.88 = 0.88

Here the plain reminder is worth 0.880.88 euro per abandoned checkout. With the discount:

ΔEdiscount=0.11×(22−4)−0.04×22=1.98−0.88=1.10\Delta E_{\text{discount}} = 0.11 \times (22 - 4) - 0.04 \times 22 = 1.98 - 0.88 = 1.10

Here 0.080.08 and 0.110.11 are the return rates pap_a, 0.040.04 is p0p_0, 2222 is the margin mm and 44 is the discount cc, all in euro. The discount wins by 22 cent a basket.

Now a €40 basket of gift sets Ciarán buys in and resells, at 25% margin, so m=10m = 10 and the discount still costs c=4c = 4:

ΔEdiscount=0.11×(10−4)−0.04×10=0.66−0.40=0.26\Delta E_{\text{discount}} = 0.11 \times (10 - 4) - 0.04 \times 10 = 0.66 - 0.40 = 0.26

Here 1010 is the margin and 44 the discount. The plain reminder earns 0.08×10−0.40=0.400.08 \times 10 - 0.40 = 0.40, so on this basket the plain reminder wins.

Expected extra margin per abandoned checkoutFor a €40 basket at 55% margin a discounted reminder earns €1.10 against €0.88 for a plain one. At 25% margin the plain reminder earns €0.40 against €0.26 for the discounted one.Claddagh, €40 basket, expected extra margin by action€0€0.5€1€1.5€2€0.955% margin,plain€1.155% margin,10% off€0.425% margin,plain€0.325% margin,10% offExpected extra margin per checkout (€)

Each bar is one action on one kind of basket. The vertical axis is the expected extra margin per abandoned checkout, compared with sending nothing. The takeaway: the same discount is right on one basket and wrong on the other, because of the margin it eats, not because of some magic line. Ciarán's return rates are invented. Yours come from testing, and in my experience they drift, because customers who learn that leaving pays a discount start leaving on purpose.

Here deduplication matters, because an email is not idempotent. The code continues the worker module. The table claims holds one row per checkout token, with a time to live so old rows delete themselves.28 send_reminder stands for whatever call your email tool's API needs.

import time

P_BASE, P_PLAIN, P_DISCOUNT, DISCOUNT_RATE = 0.04, 0.08, 0.11, 0.10


def claim(key):
    try:
        dynamodb.put_item(
            TableName="claims",
            Item={"key": {"S": key}, "expires_at": {"N": str(int(time.time()) + 30 * 86400)}},
            ConditionExpression="attribute_not_exists(#k)",
            ExpressionAttributeNames={"#k": "key"},
        )
        return True
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return False
        raise


def handle_abandoned(checkout, margin_rate):
    if checkout.get("completed_at") or not checkout.get("buyer_accepts_marketing"):
        return
    basket = float(checkout["total_price"])
    m, c = basket * margin_rate, basket * DISCOUNT_RATE
    plain = P_PLAIN * m - P_BASE * m
    discount = P_DISCOUNT * (m - c) - P_BASE * m
    key = f"reminder:{checkout['token']}"
    if not claim(key):
        return
    try:
        send_reminder(checkout["email"], checkout["abandoned_checkout_url"],
                      with_discount=discount > plain)
    except Exception:
        dynamodb.delete_item(TableName="claims", Key={"key": {"S": key}})
        raise

The claim is written before the email goes and deleted if sending fails, so a retry can try again. If the function dies mid send, the claim stays and nobody gets a second email. I prefer one missing reminder to two, and that is a choice to make on purpose. I run this an hour after the last checkouts/update for a token, checking completed_at again then, because many people finish on their own. The old version pushed low margin baskets into a remarketing audience instead. That needs permission of its own, covered in the data warehouse article.

Some events never happen: time and reconciliation need a schedule

A sale that ends at midnight sends no webhook. Nothing in the shop changed, the clock moved. The old version had a Cloudflare Worker "listen to" sale flags, but a Worker listens to nothing. It runs when a request arrives or when a Cron Trigger fires it.29

On AWS that is EventBridge Scheduler. It runs a one off schedule at a set date and time, or a cron schedule, in a time zone you name, and adjusts for daylight saving time on its own.30 Cloudflare's Cron Triggers run in UTC only.29 For an Irish shop that difference is exactly an hour for half the year.

The same scheduler handles the message that never arrives, through the reconciliation job Shopify recommends.12 Ciarán's runs at 3am, reads the stock of every watched item with Shopify's own update time, and feeds each through the same worker. Because the worker ignores older news and setting a status is idempotent, the sweep can never make anything worse.

Worked example: the spring sale that ended an hour late

Ciarán's spring sale ends at midnight at the end of Sunday 31 May 2026, Irish time, when Ireland is on Irish Standard Time, one hour ahead of UTC. A Cloudflare Cron Trigger written as 0 0 1 6 * fires at midnight UTC, 1am in Galway, so the "20% off" adverts run an hour past the sale and those clicks meet full prices.

With EventBridge Scheduler, Ciarán writes at(2026-06-01T00:00:00) with the time zone Europe/Dublin, and it fires at midnight in Galway. On Cloudflare he would need 0 23 31 5 *, and a different rule for a December sale.

What to build first, and when not to build this at all

If your platform's built in automations already do the job, use them. A webhook pipeline earns its place when you need a rule they cannot express: pause this ad group below five in stock, decide the discount by margin, end the adverts with the sale.

If you run your own Rails or Solidus shop, you may not need serverless functions at all. The shop already has a server and background jobs, which give you the same receive fast, work later shape. The rules about duplicates and retries in background workers apply word for word, and testing it is covered in TDD and BDD for APIs.

An example: Ciarán's build order. First the signed receiver and the queue. Second, the Winter Spice pause with the ordering guard. Third, the dead letter queue and its alert. Fourth, the 3am reconciliation. Only then the abandoned checkout decision. Each step works on its own, so if he stops after step two he still has the €43 weekend back. For the bigger picture there is a unified campaign automation stack, and for email and ads triggering each other, triggering ads from email activity.

The webhook is the doorbell, the worker is who answers

The old version of this article promised zero delay and full control. Webhooks are fast, but they arrive twice, out of order or not at all, and serverless scales up to a quota. What you can have is a chain that does the right thing every time: check the signature, answer in seconds, queue the work, make actions safe to repeat, ignore stale news, retry with patience, and sweep nightly for what slipped through. None of it is clever. It is the difference between automation you can forget about and automation that pauses your bestseller on the day the restock arrives.

Footnotes

  1. Shopify, Webhooks topics reference (headers and sample payloads for inventory_levels/update and checkouts/update), Shopify developer documentation, 2026, https://shopify.dev/docs/api/webhooks/latest

  2. Amazon Web Services, Lambda quotas, AWS Lambda Developer Guide, 2026, https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html

  3. Cloudflare, Limits, Cloudflare Workers documentation, 2026, https://developers.cloudflare.com/workers/platform/limits/

  4. Amazon Web Services, AWS Lambda pricing, 2026, https://aws.amazon.com/lambda/pricing/

  5. Shopify, Verify webhook deliveries, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/build/webhooks/verify-deliveries

  6. Stripe, Receive Stripe events in your webhook endpoint, Stripe documentation, 2026, https://docs.stripe.com/webhooks

  7. Amazon Web Services, Invoking Lambda function URLs, AWS Lambda Developer Guide, 2026, https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html

  8. Cloudflare, Web Crypto, Cloudflare Workers documentation, 2026, https://developers.cloudflare.com/workers/runtime-apis/web-crypto/

  9. Cloudflare, JavaScript APIs, Cloudflare Queues documentation, 2026, https://developers.cloudflare.com/queues/configuration/javascript-apis/

  10. Shopify, Troubleshooting webhooks, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/build/webhooks/troubleshooting-webhooks

  11. Shopify, Ignore duplicate webhooks, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/build/webhooks/ignore-duplicates

  12. Shopify, About webhooks, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/build/webhooks

  13. Amazon Web Services, Amazon SQS at least once delivery, Amazon SQS Developer Guide, 2026, https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues-at-least-once-delivery.html

  14. Amazon Web Services, DynamoDB condition expression CLI example, Amazon DynamoDB Developer Guide, 2026, https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html

  15. Google, pause_ad.py, Google Ads API client library for Python, examples, GitHub, 2026, https://github.com/googleads/google-ads-python/blob/main/examples/basic_operations/pause_ad.py

  16. Amazon Web Services, Handling errors for an SQS event source in Lambda, AWS Lambda Developer Guide, 2026, https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-errorhandling.html

  17. Marc Brooker, Timeouts, retries, and backoff with jitter, Amazon Builders' Library, AWS, 2019, https://builder.aws.com/content/3EumjoZascWd1oZiEgL8ORlv3qE/timeouts-retries-and-backoff-with-jitter

  18. Amazon Web Services, Creating and configuring an Amazon SQS event source mapping, AWS Lambda Developer Guide, 2026, https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html

  19. Amazon Web Services, Using dead letter queues in Amazon SQS, Amazon SQS Developer Guide, 2026, https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html

  20. Google, Manage products in your Merchant Center account, Google Merchant Center Help, 2026, https://support.google.com/merchants/answer/14996874?hl=en-GB

  21. Google, How to fix: Mismatched product availability, Google Merchant Center Help, 2026, https://support.google.com/merchants/answer/12470049?hl=en-GB

  22. Google, Deprecation and sunset, Content API for Shopping, Google for Developers, 2026, https://developers.google.com/shopping-content/guides/sunset

  23. Government of Ireland, European Communities (Electronic Communications Networks and Services) (Privacy and Electronic Communications) Regulations 2011, S.I. No. 336 of 2011, regulation 13, Irish Statute Book, 2011, https://www.irishstatutebook.ie/eli/2011/si/336/made/en/print

  24. Data Protection Commission, FAQ on Consent for Electronic Direct Marketing, dataprotection.ie, April 2020, https://www.dataprotection.ie/sites/default/files/uploads/2026-05/FAQ_Consent_Electronic_Direct_Marketing_April2020.pdf

  25. Court of Justice of the European Union, Judgment of 13 November 2025, C-654/23, Inteligo Media, paragraphs 48, 53 to 62 and 64 to 69 and operative part, EUR-Lex, 2025, https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:62023CJ0654

  26. Shopify, Marketing automation types, Shopify Help Center, 2026, https://help.shopify.com/en/manual/promoting-marketing/create-marketing/marketing-automations/types

  27. Shopify, Work with protected customer data, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/launch/protected-customer-data

  28. Amazon Web Services, Using time to live (TTL) in DynamoDB, Amazon DynamoDB Developer Guide, 2026, https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html

  29. Cloudflare, Cron Triggers, Cloudflare Workers documentation, 2026, https://developers.cloudflare.com/workers/configuration/cron-triggers/

  30. Amazon Web Services, Schedule types in EventBridge Scheduler, Amazon EventBridge Scheduler User Guide, 2026, https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html

If your adverts still find out about stock and sales on Monday morning, I run a free workshop to map which events in your shop should move which campaigns, and what is worth automating at all. The first two weeks of work are on me.