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 minutes and a change can happen at any moment, it lands on average halfway between two checks.
Here is the average delay before you notice a change, in minutes, and is the polling interval, in minutes. It is the mean of a delay that is equally likely to be anything from to , 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:
Here is clicks per hour, is the hours until Ciarán looks, and 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:
Here is 7.5 minutes of average blindness. In 7.5 minutes the ad group buys clicks, about 12 cent. A webhook gets that to a few seconds, which saves Ciarán another 12 cent.
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:
Here is the runs per function, and are the memory in GB, and and 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.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.
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
Here is the wait before retry number , is the base wait, doubles it each time, and is the cap. Doubling is growth at 100% per step, the arithmetic of Foundations: compounding. Adding up the first waits, before the cap bites, gives the total wait:
Here is the total wait across retries and 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:
Here is the base wait in seconds, is the number of retries , and 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.
Here is the expected extra margin per abandoned checkout from taking the action, compared with doing nothing, is the share who complete the order after the action, is the margin on the order in euro, is the cost of the action per completed order (the discount), and 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 and a 10% discount costs :
Here the plain reminder is worth euro per abandoned checkout. With the discount:
Here and are the return rates , is , is the margin and is the discount , 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 and the discount still costs :
Here is the margin and the discount. The plain reminder earns , so on this basket the plain reminder wins.
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
-
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
-
Amazon Web Services, Lambda quotas, AWS Lambda Developer Guide, 2026, https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html
-
Cloudflare, Limits, Cloudflare Workers documentation, 2026, https://developers.cloudflare.com/workers/platform/limits/
-
Amazon Web Services, AWS Lambda pricing, 2026, https://aws.amazon.com/lambda/pricing/
-
Shopify, Verify webhook deliveries, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/build/webhooks/verify-deliveries
-
Stripe, Receive Stripe events in your webhook endpoint, Stripe documentation, 2026, https://docs.stripe.com/webhooks
-
Amazon Web Services, Invoking Lambda function URLs, AWS Lambda Developer Guide, 2026, https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html
-
Cloudflare, Web Crypto, Cloudflare Workers documentation, 2026, https://developers.cloudflare.com/workers/runtime-apis/web-crypto/
-
Cloudflare, JavaScript APIs, Cloudflare Queues documentation, 2026, https://developers.cloudflare.com/queues/configuration/javascript-apis/
-
Shopify, Troubleshooting webhooks, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/build/webhooks/troubleshooting-webhooks
-
Shopify, Ignore duplicate webhooks, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/build/webhooks/ignore-duplicates
-
Shopify, About webhooks, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/build/webhooks
-
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
-
Amazon Web Services, DynamoDB condition expression CLI example, Amazon DynamoDB Developer Guide, 2026, https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html
-
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
-
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
-
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
-
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
-
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
-
Google, Manage products in your Merchant Center account, Google Merchant Center Help, 2026, https://support.google.com/merchants/answer/14996874?hl=en-GB
-
Google, How to fix: Mismatched product availability, Google Merchant Center Help, 2026, https://support.google.com/merchants/answer/12470049?hl=en-GB
-
Google, Deprecation and sunset, Content API for Shopping, Google for Developers, 2026, https://developers.google.com/shopping-content/guides/sunset
-
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
-
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
-
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
-
Shopify, Marketing automation types, Shopify Help Center, 2026, https://help.shopify.com/en/manual/promoting-marketing/create-marketing/marketing-automations/types
-
Shopify, Work with protected customer data, Shopify developer documentation, 2026, https://shopify.dev/docs/apps/launch/protected-customer-data
-
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
-
Cloudflare, Cron Triggers, Cloudflare Workers documentation, 2026, https://developers.cloudflare.com/workers/configuration/cron-triggers/
-
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