Webhooks und Serverless Funktionen für Kampagnen, die auf deinen Shop reagieren
Kurz gesagt
Niveau: Fortgeschritten
Ein Webhook ist der Shop, der dir sofort sagt, dass sich etwas geändert hat. Eine Serverless Funktion ist ein kleines Stück Code, das darauf reagiert.
- Prüf die Signatur jedes Webhooks am rohen Body, bevor du ihm glaubst.
- Antworte in Sekunden und leg die eigentliche Arbeit in eine Warteschlange, denn Shopify gibt dir fünf Sekunden.
- Webhooks kommen doppelt, zu spät und in falscher Reihenfolge, also mach jede Aktion wiederholbar und ignorier veraltete Änderungen.
- Wiederhole mit wachsenden Wartezeiten, schick dauerhafte Fehler in eine Dead Letter Queue und gleich jede Nacht ab, was gefehlt hat.
- Prüf vor jeder Erinnerungsmail die Erlaubnis nach dem UWG und lass die erwartete Marge entscheiden, nicht eine feste Schwelle, ob es Rabatt gibt.
Anzeigen, die weiterlaufen, nachdem der Bestseller ausverkauft ist, und Rabattbanner, die die Aktion überleben, sind ein Problem des Stundenplans. In diesem Artikel zeige ich, wie Webhooks und Serverless Funktionen einen erfundenen Kerzenshop in Hamburg sofort reagieren lassen, wenn sich etwas ändert, und wie du das so baust, dass Duplikate, verspätete Nachrichten und verpasste Ereignisse ihn nie das Falsche tun lassen.
Wenn sich im Shop etwas ändert, sollte das Marketing es erfahren
Das meiste Marketing läuft nach Stundenplan. Jemand prüft am Montag den Bestand, pausiert die Anzeige für die Kerze, die am Samstag ausverkauft war, und merkt am Mittwoch, dass die Rabattaktion schon gestern Abend zu Ende ging. Dazwischen versprechen die Anzeigen Dinge, die der Shop nicht mehr liefern kann.
Besser ist es, wenn der Shop dem Marketing selbst Bescheid gibt, sobald sich etwas ändert, über einen Webhook, und wenn ein kleines Stück Code bereitsteht, das darauf reagiert, meistens eine Serverless Funktion. In diesem Artikel verbinde ich beides so, dass jedes Mal das Richtige passiert, auch wenn die Nachricht doppelt kommt, zu spät oder gar nicht.
Dabei begleitet mich ein erfundenes Unternehmen. Elbkerzen ist ein Onlineshop für Kerzen in Hamburg. Jonas führt ihn auf Shopify, unter elbkerzen.example. Der Bestseller ist die Kerze Winterzauber für 24 €, und sie hat in Google Ads eine eigene Anzeigengruppe. Niemand davon ist echt, und jede Zahl unten ist ausgedacht, damit ich jeden Schritt zeigen kann.
Ein Webhook ist der Shop, der dich anruft, statt dass du ihn anrufst
Du kannst ein anderes System immer wieder fragen: "Gibt es etwas Neues?" Das nennt man Polling. Oder das System meldet sich, sobald etwas passiert. Das ist ein Webhook: Der Shop schickt eine HTTP Anfrage (eine kleine Nachricht über das Web, so wie ein Browser eine Seite lädt) an eine Adresse, die du ihm gegeben hast, und die Einzelheiten stehen im Body.
Shopify nennt seine Ereignisse Topics. Jede Zustellung trägt Header, die sagen, worum es geht: das Topic, etwa inventory_levels/update, den Shop, von dem sie kommt, eine Zustellungs ID, den Zeitpunkt, an dem sie ausgelöst wurde, und eine Signatur.1 Der Body ist JSON. Bei einer Bestandsänderung ist er kurz: welcher Lagerartikel, an welchem Standort, wie viele verfügbar sind und wann das aktualisiert wurde.1
Polling hat seinen ehrlichen Platz. Wenn du alle Minuten nachsiehst und eine Änderung jederzeit passieren kann, fällt sie im Schnitt genau zwischen zwei Prüfungen.
Dabei ist die durchschnittliche Verzögerung, bis du eine Änderung bemerkst, in Minuten, und der Abstand zwischen zwei Abfragen, ebenfalls in Minuten. Es ist der Mittelwert einer Verzögerung, die mit gleicher Wahrscheinlichkeit irgendwo zwischen und liegt, von Grund auf erklärt in Grundlagen: der Mittelwert.
Rechenbeispiel: was der ausverkaufte Winterzauber kostet
Die Anzeigengruppe für Winterzauber bekommt 40 Klicks am Tag zu 0,60 € pro Klick. An einem Samstag um 14 Uhr geht die letzte Kerze weg. Jonas sieht sich seine Anzeigen am Montag um 9 Uhr an, 43 Stunden später. Die Klicks, die er in der Zwischenzeit kauft, landen alle auf einer ausverkauften Seite:
Dabei ist die Zahl der Klicks pro Stunde, die Zahl der Stunden, bis Jonas nachsieht, und der Preis pro Klick in Euro. Das sind 43 € für Klicks, aus denen keine Bestellung werden konnte.
Jetzt ein Skript, das den Bestand alle 15 Minuten abfragt:
Dabei ist eine durchschnittliche Blindzeit von 7,5 Minuten. In 7,5 Minuten kauft die Anzeigengruppe Klicks, etwa 12 Cent. Ein Webhook drückt das auf wenige Sekunden und spart Jonas noch einmal 12 Cent.
Jeder Balken ist eine Art, den Ausverkauf zu bemerken. Die senkrechte Achse zeigt das Geld, das für Klicks ausgegeben wurde, zwischen dem Verkauf der letzten Kerze und dem Pausieren der Anzeigengruppe. Die Erkenntnis: Fast die ganze Ersparnis kommt daher, dass die Prüfung überhaupt automatisch läuft, nicht vom Webhook. Für ein einzelnes Produkt reicht eine Abfrage alle 15 Minuten. Webhooks lohnen sich, wenn du Tausende Dinge beobachtest (2.000 Produkte alle 15 Minuten abzufragen sind 192.000 Anfragen am Tag) oder innerhalb einer Minute reagieren musst, etwa auf einen Checkout, den jemand gerade verlassen hat.
Serverless heißt, jemand anderes hält den Server am Laufen, mit Grenzen
Ein Webhook braucht eine Adresse, die immer zuhört. Eine Serverless Funktion liefert sie ohne eigenen Server: Du lädst eine Funktion hoch, der Anbieter führt sie aus, sobald eine Anfrage eintrifft, und du zahlst pro Ausführung. Die zwei, die ich am häufigsten nutze, sind AWS Lambda und Cloudflare Workers.
Bei den Grenzen wird die Werbung der Anbieter leise. Eine Lambda Funktion läuft höchstens 15 Minuten, mit 128 MB bis 10.240 MB Arbeitsspeicher, und eine synchrone Anfrage oder Antwort darf höchstens 6 MB groß sein. Ein Konto startet standardmäßig mit 1.000 gleichzeitigen Ausführungen, und AWS weist darauf hin, dass neue Konten niedriger anfangen und mit der Nutzung angehoben werden.2 Ein Cloudflare Worker bekommt im kostenlosen Tarif 10 ms CPU Zeit pro Anfrage, im bezahlten Tarif standardmäßig 30 Sekunden, bis zu fünf Minuten, jeweils mit 128 MB Arbeitsspeicher. Der kostenlose Tarif endet bei 100.000 Anfragen am Tag.3 "Skaliert sofort", wie die alte Fassung dieses Artikels versprach, stimmt also bis zu einem Kontingent, und das solltest du vor dem Weihnachtsgeschäft wissen, nicht mittendrin.
Rechenbeispiel: die Monatsrechnung von Elbkerzen
Elbkerzen bekommt etwa 30.000 Webhooks im Monat, weil jede Bestellung den Bestand ändert. Jeder löst zwei Funktionen aus: einen Empfänger mit 128 MB für 0,1 Sekunden und einen Worker mit 256 MB für 0,5 Sekunden. Lambda rechnet die Rechenzeit in Gigabyte Sekunden ab, also Arbeitsspeicher in GB mal Laufzeit in Sekunden:
Dabei ist die Zahl der Ausführungen pro Funktion, und sind der Arbeitsspeicher in GB, und die Sekunden pro Ausführung. Das ergibt 4.125 GB Sekunden und 60.000 Anfragen im Monat. Das kostenlose Kontingent von Lambda deckt eine Million Anfragen und 400.000 GB Sekunden im Monat.4 Jonas' Funktionen brauchen also etwa 1 % der Rechenzeit und 6 % der Anfragen davon. Selbst ohne dieses Kontingent kämen beim Listenpreis von AWS, 0,20 US Dollar pro Million Anfragen und 0,0000166667 US Dollar pro GB Sekunde in der Region US East, etwa 8 US Cent zusammen.4 Die Warteschlange und die Datenbank, die ich weiter unten ergänze, werden getrennt abgerechnet, aber in dieser Größe ist die Rechnung nicht die Kosten. Die Kosten liegen darin, es ordentlich zu bauen.
Prüf, ob die Nachricht echt ist, bevor du ihr glaubst
Eine Webhook Adresse ist öffentlich. Jeder, der sie findet, kann eine Anfrage schicken, die aussieht wie Shopify mit der Meldung "Winterzauber ist ausverkauft". Die alte Fassung dieses Artikels hätte ihr geglaubt, weil ihr Code auf den Body reagierte, ohne irgendetwas zu prüfen.
Shopify signiert jede Zustellung. Es berechnet einen HMAC (Hash based Message Authentication Code: einen Fingerabdruck der Nachricht, erzeugt mit einem Geheimnis, das nur du und Shopify kennen) über den unveränderten Request Body, mit SHA256 und dem Client Secret deiner App, und schickt ihn Base64 kodiert im Header X-Shopify-Hmac-Sha256. Du berechnest auf deiner Seite denselben Fingerabdruck und vergleichst. Shopify betont, dass du den rohen Body nehmen musst, bevor irgendein Framework ihn verarbeitet, sonst passen die Fingerabdrücke nie zusammen.5
Stripe nimmt zusätzlich einen Zeitstempel dazu. Sein Header Stripe-Signature signiert den Zeitstempel, einen Punkt und den rohen Body gemeinsam, und die Bibliotheken von Stripe lehnen standardmäßig alles ab, was älter als fünf Minuten ist. Eine abgefangene Nachricht lässt sich so nächste Woche nicht noch einmal abspielen.6
Hier ist der Empfänger von Elbkerzen als Lambda Funktion hinter einer Funktions URL. Lambda übergibt den Body als Zeichenkette, Base64 kodiert, wenn der Inhalt binär ist. Die erste Aufgabe ist also, genau die ursprünglichen Bytes zurückzubekommen.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 vergleicht in konstanter Zeit, damit niemand die Signatur herausfinden kann, indem er misst, wie lange falsche Versuche dauern. Und die Funktion tut nichts weiter, als die Nachricht in eine Warteschlange zu legen, um die es im nächsten Abschnitt geht.
Rechenbeispiel: eine geänderte Ziffer
Damit es lesbar bleibt, nehme ich ein ausgedachtes Geheimnis, demo_secret, und einen gekürzten Body: {"inventory_item_id":808950810,"available":0}. Der HMAC SHA256 dieses Bodys mit diesem Schlüssel, Base64 kodiert, ist WsbEAO23WBTv/jRCvwSm+JKbk0mqDEnVoHet6eHzjW8=. Genau das würde Shopify in den Header schreiben.
Jetzt schickt jemand dieselbe Anfrage mit "available":9, in der Hoffnung, eine Anzeigengruppe wieder einzuschalten. Der Fingerabdruck dieses Bodys ist 8VaGxE/VorQ4ejSaiw7t2BCxAFr1PMnPTAxhDkce51w=, keinerlei Ähnlichkeit mit dem ersten. Der Vergleich schlägt fehl, der Empfänger antwortet mit 401, nichts passiert. Ohne das Geheimnis kann niemand den passenden Fingerabdruck für einen selbst gewählten Body erzeugen.
Auf Cloudflare sieht der Empfänger in JavaScript fast genauso aus. Workers unterstützen HMAC über die Web Crypto API, und Cloudflare Queues nimmt die Nachricht entgegen.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");
},
};
Ich würde die ganze Kette auf einer Plattform halten, statt sie wie die alte Fassung auf zwei zu verteilen: Zwei Clouds bedeuten zwei Stellen, an denen du Logs durchsuchen musst. Ab hier bleibe ich bei AWS.
Antworte in Sekunden, arbeite danach
Shopify wartet fünf Sekunden auf deine Antwort. Kommt in dieser Zeit keine Erfolgsmeldung, gilt die Zustellung als fehlgeschlagen. Shopify wiederholt fehlgeschlagene Zustellungen bis zu achtmal innerhalb von vier Stunden, und wenn die Fehler anhalten, löscht es das Abonnement ganz.10 Stripe rät, eine Erfolgsmeldung zurückzugeben, bevor irgendeine aufwendige Logik läuft, Ereignisse aus einer Warteschlange zu verarbeiten, und wiederholt im Livemodus bis zu drei Tage lang.6
Der Empfänger prüft also nur die Signatur, schreibt die Nachricht in eine Warteschlange und antwortet. Eine zweite Funktion, der Worker, holt die Nachrichten aus der Warteschlange und erledigt die langsame Arbeit in seinem eigenen Tempo. Auf AWS ist diese Warteschlange SQS (Simple Queue Service), die Lambda direkt auslesen kann.
Lies es von oben nach unten. Jedes Kästchen ist ein Schritt, jede Raute eine Frage. Die Erkenntnis ist die Teilung: Oberhalb der Warteschlange geht es schnell und einfach zu, unterhalb langsam und sorgfältig, und Shopify wartet nur auf die schnelle Hälfte.
Rechenbeispiel: ein langsamer Montag bei Google
An einem Montagmorgen braucht die Google Ads API acht Sekunden für eine Antwort. Hätte der Empfänger sie direkt aufgerufen, hätte Shopify nach fünf Sekunden aufgegeben und die Zustellung erneut geschickt, während der erste Versuch die Anzeigengruppe noch pausierte. Mit der Warteschlange dazwischen hat der Empfänger nach einer Zehntelsekunde geantwortet, und der Worker hat sich seine acht Sekunden genommen, ohne dass jemand warten musste.
Jede Nachricht kann doppelt kommen, zu spät oder in falscher Reihenfolge
Die alte Fassung dieses Artikels hat diesen Teil komplett ausgelassen, und genau er entscheidet, ob man dem Ganzen trauen kann.
Webhooks werden mindestens einmal zugestellt, nicht genau einmal. Shopify schreibt, dass dieselbe Zustellung mehr als einmal ankommen kann, und rät, Webhooks idempotent zu verarbeiten oder Wiederholungen anhand des Headers X-Shopify-Webhook-Id zu überspringen.11 Stripe sagt dasselbe, und dazu, dass Ereignisse nicht in der Reihenfolge ankommen, in der sie passiert sind.6 Auch Shopify garantiert keine Reihenfolge, verweist auf den Zeitstempel X-Shopify-Triggered-At und empfiehlt Abgleichsjobs, die ab und zu den tatsächlichen Stand abrufen, statt sich nur auf Webhooks zu verlassen.12 Die Warteschlange in der Mitte liefert ebenfalls mindestens einmal: SQS kann dir in seltenen Fällen dieselbe Nachricht zweimal geben.13
Der Worker muss also mit drei Dingen fertig werden: einem Duplikat, einer veralteten Nachricht, die eine frische überholt, und einer Nachricht, die nie kommt.
Eine Aktion ist idempotent, wenn sie zweimal ausgeführt dasselbe Ergebnis hinterlässt wie einmal. "Setz diese Anzeigengruppe auf pausiert" ist idempotent: Eine pausierte Anzeigengruppe zu pausieren ändert nichts. "Schick diesem Kunden eine Erinnerungsmail" ist es nicht: Zweimal heißt zwei Mails. Die erste Art braucht keine Deduplizierung. Die zweite braucht sie.
Die Reihenfolge ist ein eigenes Problem. Kommt "available: 0" nach "available: 12" an, pausiert ein Worker, der einfach der letzten empfangenen Nachricht folgt, die Anzeigengruppe für ein Produkt, das auf Lager ist. Der Schutz dagegen: Merk dir pro Artikel den neuesten Zeitstempel, den du gesehen hast, und ignorier alles, was älter ist. DynamoDB, die Key Value Datenbank von AWS, kann diese Prüfung und das Schreiben in einem Schritt erledigen, mit einem Bedingungsausdruck, der den Schreibvorgang scheitern lässt, wenn die Bedingung nicht erfüllt ist.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: # Bestand wird für diesen Artikel nicht geführt
continue
set_ad_group_paused(watched["ad_group_id"], level["available"] <= watched["pause_at"])
except Exception:
failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": failures}
Der Code zum Pausieren folgt Googles eigenem Beispiel für das Pausieren einer Anzeige, angewendet auf eine Anzeigengruppe.15 Die Liste batchItemFailures ist die teilweise Batch Antwort von Lambda: Ist ReportBatchItemFailures eingeschaltet, gehen nur die gescheiterten Nachrichten zurück in die Warteschlange.16 Der Schutz lässt einen gleichen Zeitstempel absichtlich durch, damit die Wiederholung einer Nachricht, deren Aufruf bei Google gescheitert ist, nicht für eine veraltete gehalten wird. Ein schlichtes Duplikat kommt dadurch auch durch, und das schadet nicht, weil zweimal denselben Status zu setzen nichts ändert.
Rechenbeispiel: die Nachlieferung, die zuerst ankam
Am Donnerstag verkauft Jonas um 16:10 Uhr die letzten zwei Winterzauber Kerzen, und um 16:12 Uhr wird eine Lieferung von 12 Stück eingebucht. Shopify schickt zwei Webhooks: "available: 0, aktualisiert 16:10 Uhr" und "available: 12, aktualisiert 16:12 Uhr". Der von 16:12 Uhr kommt zuerst an.
Der Worker sieht 16:12 Uhr, findet nichts gespeichert, schreibt 16:12 Uhr und lässt die Anzeigengruppe an. Dann trifft die Nachricht von 16:10 Uhr ein. Die Bedingung "zuletzt gesehen ist nicht später als 16:10 Uhr" ist falsch, weil 16:12 Uhr gespeichert ist, also verweigert DynamoDB das Schreiben, und der Worker überspringt die Nachricht. Ohne den Schutz wäre die Anzeigengruppe pausiert worden, während 12 Kerzen im Regal stehen. Kommt die Nachricht von 16:12 Uhr ein zweites Mal, geht sie durch und setzt erneut "an", was nichts ändert.
Wenn ein Aufruf scheitert, warte jedes Mal länger und gib dann laut auf
Manchmal ist die API von Google überlastet. Sofort neu zu versuchen scheitert meistens wieder, und wenn alle Worker gleichzeitig wiederholen, machen sie das Gedränge schlimmer. Die übliche Antwort, wie Marc Brooker sie in der Amazon Builders' Library beschreibt, ist ein gedeckeltes exponentielles Backoff: nach jedem Versuch länger warten, bis zu einer Obergrenze, plus ein bisschen Zufall, Jitter genannt, damit sich die Wiederholungen verteilen. Er warnt auch, dass Wiederholen nur bei idempotenten Aufrufen sicher ist.17
Dabei ist die Wartezeit vor Wiederholung Nummer , die Grundwartezeit, verdoppelt sie jedes Mal, und ist die Obergrenze. Verdoppeln ist Wachstum um 100 % pro Schritt, die Rechnung aus Grundlagen: Zinseszins. Addierst du die ersten Wartezeiten, bevor die Obergrenze greift, ergibt sich die gesamte Wartezeit:
Dabei ist die gesamte Wartezeit über Wiederholungen und die Grundwartezeit, eine geometrische Summe, erklärt in Grundlagen: Zinseszins.
Rechenbeispiel: vier Wiederholungen im Worker
Der Worker von Jonas wiederholt einen gescheiterten Aufruf bei Google Ads viermal, beginnend bei zwei Sekunden, mit einer Obergrenze von 30 Sekunden, die nie erreicht wird. Die Wartezeiten sind 2, 4, 8 und 16 Sekunden:
Dabei ist die Grundwartezeit in Sekunden, die Zahl der Wiederholungen und die gesamte Wartezeit in Sekunden. Rechnet man die Aufrufe selbst dazu, kann der Worker im schlechten Fall etwa eine Minute brauchen, also bekommt er ein Timeout von 90 Sekunden.
Diese Zahl bestimmt dann die Warteschlange. AWS verlangt ein Sichtbarkeits Timeout der Warteschlange (wie lange eine Nachricht verborgen bleibt, während ein Worker sie bearbeitet) von mindestens dem Sechsfachen des Funktions Timeouts, hier also neun Minuten. Und es empfiehlt, jede Nachricht mindestens fünfmal empfangen zu lassen, bevor sie in eine Dead Letter Queue wandert.18 Eine Dead Letter Queue hält Nachrichten für einen Menschen bereit, wenn jeder Versuch gescheitert ist.19 Jonas bekommt eine Mail, sobald dort etwas landet, denn eine Nachricht, die fünfmal scheitert, ist ein Fehler im Code, und Fehler sollen laut sein.
Nimm für Produkte den Feed und die Anzeigen API nur für das, was der Feed nicht kann
In der alten Fassung dieses Artikels legte ein Webhook für jedes neue Produkt eine Suchanzeige bei Google, eine Anzeigengruppe bei Meta und eine Keyword Gruppe bei Microsoft Ads an. Das würde ich nicht bauen.
Produkte und Bestand gelangen über den Produktfeed im Merchant Center zu Google. Markierst du dort ein Produkt als nicht auf Lager, wird es in den meisten Einkaufsumgebungen nicht mehr angezeigt, und wenn du ein Produkt pausierst, verschwindet es bis zu 14 Tage lang aus Shopping Anzeigen und kostenlosen Produkteinträgen.20 Zu viele Produkte mit falscher Verfügbarkeit können zur Sperrung des Kontos führen.21 Falls dein Shop den Feed per API aktualisiert: Die Content API for Shopping wurde am 18. August 2026 abgeschaltet, Anfragen ohne Verlängerung scheitern seit dem 1. September 2026 zeitweise, und Google rät zum Umstieg auf die Merchant API.22
Was ein Feed nicht kann, ist eine Suchanzeigengruppe zu pausieren, die um ein einzelnes Produkt herum geschrieben ist, wie die Winterzauber Gruppe von Jonas. Genau da verdient der Webhook sein Geld.
Rechenbeispiel: drei neue Kerzen im November
Jonas nimmt im November drei neue Düfte ins Sortiment. Der alte Ansatz hätte drei Suchanzeigen aus einer Vorlage erzeugt, live, bevor sie jemand gelesen hat. Stattdessen kommen die Produkte über den Feed ins Merchant Center, und der Worker ignoriert sie, weil keins davon auf der beobachteten Liste steht. Läuft eins richtig gut, schreibt Jonas ihm eine ordentliche Anzeigengruppe und ergänzt die Liste um eine Zeile. Ein Mensch schreibt die Worte, die Automation übernimmt das Schalten.
Abgebrochene Checkouts: erst die Erlaubnis prüfen, dann die Marge entscheiden lassen
Eine Kundin tippt ihre E Mail Adresse in den Checkout und geht dann. Eine Erinnerung, eine Erinnerung mit Rabatt oder gar nichts?
Die Erlaubnis kommt zuerst. Eine Erinnerung an einen Warenkorb ist Werbung per E Mail, und in Deutschland entscheidet darüber das Gesetz gegen den unlauteren Wettbewerb (UWG). Nach § 7 Abs. 2 Nr. 2 ist Werbung per elektronischer Post ohne vorherige ausdrückliche Einwilligung eine unzumutbare Belästigung. Die einzige Ausnahme ist die für Bestandskunden in § 7 Abs. 3, und sie verlangt unter anderem, dass du die Adresse im Zusammenhang mit dem Verkauf einer Ware oder Dienstleistung bekommen hast und dass du schon bei der Erhebung der Adresse klar und deutlich darauf hinweist, dass die Kundin jederzeit widersprechen kann, nicht erst später in einer Bestellbestätigung.23 Ausführlich habe ich das im Artikel über Einwilligung und E Mail Capture beschrieben. Der Europäische Gerichtshof hat 2025 im Urteil Inteligo Media festgehalten, dass ein Verkauf eine Vereinbarung ist, die notwendig die Zahlung eines Entgelts mit sich bringt, und dass die Ausnahme an eine bestehende Kundenbeziehung anknüpft.24 Zu abgebrochenen Checkouts sagt weder das UWG noch der Gerichtshof etwas. Wer vor dem Bezahlen aufgehört hat, hat aber keine solche Vereinbarung geschlossen, und deshalb würde ich mich bei einer neuen Kundin nicht auf die Ausnahme verlassen. Der sichere Test ist das Häkchen, das die Kundin gesetzt hat: Der Checkout Webhook von Shopify liefert buyer_accepts_marketing neben der E Mail Adresse mit.1 Shopifys eigene Automation für abgebrochene Checkouts schickt standardmäßig nur an Kunden, die Marketing abonniert haben.25 Und wenn du deine eigene App baust, schwärzt Shopify Kundenfelder wie die E Mail Adresse, solange deine App nicht für deren Empfang freigegeben ist.26 Ich bin Ingenieur, kein Anwalt, also würde ich für alles darüber hinaus einen fragen.
Dann die Entscheidung. Die alte Fassung hat einen Rabatt gemailt, sobald die Marge über 40 % lag. Eine feste Schwelle übersieht, wie viele Leute ohnehin zurückgekommen wären und wie viele ein Rabatt zusätzlich bringt. Der Erwartungswert berücksichtigt beides.
Dabei ist die erwartete zusätzliche Marge pro abgebrochenem Checkout, wenn du die Aktion ausführst, verglichen mit Nichtstun, der Anteil, der die Bestellung nach der Aktion abschließt, die Marge auf die Bestellung in Euro, die Kosten der Aktion pro abgeschlossener Bestellung (der Rabatt) und der Anteil, der ganz ohne Mail zurückgekommen wäre. Der Erwartungswert ist erklärt in Grundlagen: Erwartungswert, und mehr zum Entscheiden auf diese Weise steht in Entscheidungstheorie für Gründer.
Rechenbeispiel: zwei Warenkörbe, zwei Antworten
Aus seinen bisherigen Mails schätzt Jonas, dass 4 % der abgebrochenen Checkouts ohne Mail zurückkommen, 8 % nach einer einfachen Erinnerung und 11 % nach einer Erinnerung mit 10 % Rabatt. Nimm einen Warenkorb über 40 € mit seinen eigenen Kerzen, mit 55 % Marge, also , und ein Rabatt von 10 % kostet :
Dabei ist die einfache Erinnerung Euro pro abgebrochenem Checkout wert. Mit Rabatt:
Dabei sind und die Rückkehrquoten , ist , die Marge und der Rabatt , alles in Euro. Der Rabatt gewinnt mit 22 Cent pro Warenkorb.
Jetzt ein Warenkorb über 40 € mit Geschenksets, die Jonas zukauft und weiterverkauft, mit 25 % Marge, also , und der Rabatt kostet weiter :
Dabei ist die Marge und der Rabatt. Die einfache Erinnerung bringt , bei diesem Warenkorb gewinnt also die einfache Erinnerung.
Jeder Balken ist eine Aktion bei einer Art von Warenkorb. Die senkrechte Achse zeigt die erwartete zusätzliche Marge pro abgebrochenem Checkout, verglichen damit, gar nichts zu schicken. Die Erkenntnis: Derselbe Rabatt ist beim einen Warenkorb richtig und beim anderen falsch, wegen der Marge, die er auffrisst, nicht wegen einer magischen Grenze. Die Rückkehrquoten von Jonas sind ausgedacht. Deine kommen aus Tests, und nach meiner Erfahrung verschieben sie sich, weil Kunden, die lernen, dass Abbrechen einen Rabatt bringt, irgendwann mit Absicht abbrechen.
Hier zählt die Deduplizierung, denn eine Mail ist nicht idempotent. Der Code setzt das Worker Modul fort. Die Tabelle claims enthält eine Zeile pro Checkout Token, mit einer Lebensdauer, damit alte Zeilen sich selbst löschen.27 send_reminder steht für den Aufruf, den die API deines E Mail Tools braucht.
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
Der Eintrag wird geschrieben, bevor die Mail rausgeht, und gelöscht, wenn das Senden scheitert, damit eine Wiederholung es noch einmal versuchen kann. Stirbt die Funktion mitten im Senden, bleibt der Eintrag stehen, und niemand bekommt eine zweite Mail. Mir ist eine fehlende Erinnerung lieber als zwei, und das ist eine Entscheidung, die du bewusst treffen solltest. Ich lasse das eine Stunde nach dem letzten checkouts/update für einen Token laufen und prüfe completed_at dann noch einmal, weil viele Leute von selbst fertig kaufen. Die alte Fassung hat Warenkörbe mit niedriger Marge stattdessen in eine Remarketing Zielgruppe geschoben. Dafür brauchst du eine eigene Erlaubnis, dazu mehr im Artikel über Data Warehousing.
Manche Ereignisse passieren nie: Zeit und Abgleich brauchen einen Zeitplan
Eine Rabattaktion, die um Mitternacht endet, schickt keinen Webhook. Im Shop hat sich nichts geändert, nur die Uhr ist weitergelaufen. In der alten Fassung "hörte" ein Cloudflare Worker auf Rabattkennzeichen, aber ein Worker hört auf gar nichts. Er läuft, wenn eine Anfrage eintrifft oder wenn ein Cron Trigger ihn auslöst.28
Auf AWS übernimmt das der EventBridge Scheduler. Er führt einen einmaligen Zeitplan zu einem festen Datum und einer festen Uhrzeit aus oder einen Cron Zeitplan, jeweils in einer Zeitzone, die du angibst, und stellt sich von selbst auf die Sommerzeit um.29 Die Cron Trigger von Cloudflare laufen nur in UTC.28 Für einen deutschen Shop macht das im Winter eine Stunde aus, zur Mitteleuropäischen Zeit (MEZ), und im Sommer zwei, zur Mitteleuropäischen Sommerzeit (MESZ).
Derselbe Scheduler kümmert sich um die Nachricht, die nie ankommt, über den Abgleichsjob, den Shopify empfiehlt.12 Der von Jonas läuft um 3:00 Uhr, liest den Bestand jedes beobachteten Artikels mit Shopifys eigenem Änderungszeitpunkt und schickt jeden durch denselben Worker. Weil der Worker ältere Nachrichten ignoriert und das Setzen eines Status idempotent ist, kann dieser Durchlauf nie etwas verschlimmern.
Rechenbeispiel: die Frühlingsaktion, die zwei Stunden zu spät endete
Die Frühlingsaktion von Jonas endet um Mitternacht am Ende von Sonntag, dem 31. Mai 2026, deutscher Zeit. Dann gilt die Sommerzeit, und Deutschland ist UTC zwei Stunden voraus. Ein Cloudflare Cron Trigger mit 0 0 1 6 * feuert um Mitternacht UTC, in Hamburg ist es dann 2:00 Uhr. Die Anzeigen mit "20 % Rabatt" laufen also zwei Stunden über das Ende der Aktion hinaus, und wer in dieser Zeit klickt, findet volle Preise.
Mit dem EventBridge Scheduler schreibt Jonas at(2026-06-01T00:00:00) mit der Zeitzone Europe/Berlin, und der Zeitplan feuert um Mitternacht in Hamburg. Auf Cloudflare bräuchte er 0 22 31 5 *, und für eine Aktion im Dezember, wenn die Winterzeit gilt, eine andere Regel mit 23 statt 22 Uhr.
Was du zuerst baust, und wann du das alles gar nicht brauchst
Wenn die eingebauten Automationen deiner Plattform die Aufgabe schon erledigen, nimm sie. Eine Webhook Kette lohnt sich, wenn du eine Regel brauchst, die sie nicht abbilden können: diese Anzeigengruppe unter fünf Stück Bestand pausieren, den Rabatt nach der Marge entscheiden, die Anzeigen mit der Aktion beenden.
Wenn du deinen eigenen Shop mit Ruby on Rails oder Solidus betreibst, brauchst du vielleicht gar keine Serverless Funktionen. Der Shop hat schon einen Server und Hintergrundjobs, und die liefern dasselbe Muster: schnell annehmen, später arbeiten. Die Regeln zu Duplikaten und Wiederholungen aus dem Artikel über Background Workers gelten Wort für Wort, und wie du das testest, steht in TDD und BDD für APIs.
Ein Beispiel: die Reihenfolge, in der Jonas baut. Zuerst der signierte Empfänger und die Warteschlange. Zweitens das Pausieren von Winterzauber mit dem Schutz gegen falsche Reihenfolge. Drittens die Dead Letter Queue und ihre Benachrichtigung. Viertens der Abgleich um 3:00 Uhr. Erst danach die Entscheidung bei abgebrochenen Checkouts. Jeder Schritt funktioniert für sich, und hört er nach Schritt zwei auf, hat er die 43 € vom Wochenende trotzdem zurück. Für das größere Bild gibt es einen einheitlichen Stack für Campaign Automation, und dafür, wie E Mail und Anzeigen sich gegenseitig auslösen, Ads von E Mail Aktivität triggern und umgekehrt.
Der Webhook ist die Klingel, der Worker macht die Tür auf
Die alte Fassung dieses Artikels hat null Verzögerung und volle Kontrolle versprochen. Webhooks sind schnell, aber sie kommen doppelt, in falscher Reihenfolge oder gar nicht, und Serverless skaliert bis zu einem Kontingent. Was du haben kannst, ist eine Kette, die jedes Mal das Richtige tut: Signatur prüfen, in Sekunden antworten, die Arbeit in eine Warteschlange legen, Aktionen wiederholbar machen, veraltete Nachrichten ignorieren, geduldig wiederholen und jede Nacht nachsehen, was durchgerutscht ist. Nichts davon ist raffiniert. Es ist der Unterschied zwischen einer Automation, die du vergessen kannst, und einer, die deinen Bestseller genau an dem Tag pausiert, an dem die Nachlieferung ankommt.
Footnotes
-
Shopify, Webhooks topics reference (Header und Beispieldaten für inventory_levels/update und checkouts/update), Shopify Entwicklerdokumentation, 2026, https://shopify.dev/docs/api/webhooks/latest
-
Amazon Web Services, Lambda Kontingente, AWS Lambda Entwicklerhandbuch, 2026, https://docs.aws.amazon.com/de_de/lambda/latest/dg/gettingstarted-limits.html
-
Cloudflare, Limits, Cloudflare Workers Dokumentation, 2026, https://developers.cloudflare.com/workers/platform/limits/
-
Amazon Web Services, AWS Lambda Preise, 2026, https://aws.amazon.com/de/lambda/pricing/
-
Shopify, Verify webhook deliveries, Shopify Entwicklerdokumentation, 2026, https://shopify.dev/docs/apps/build/webhooks/verify-deliveries
-
Stripe, Registrieren Sie Stripe Ereignisse in Ihrem Webhook Endpoint, Stripe Dokumentation, 2026, https://docs.stripe.com/webhooks?locale=de-DE
-
Amazon Web Services, Aufrufen von Lambda Funktions URLs, AWS Lambda Entwicklerhandbuch, 2026, https://docs.aws.amazon.com/de_de/lambda/latest/dg/urls-invocation.html
-
Cloudflare, Web Crypto, Cloudflare Workers Dokumentation, 2026, https://developers.cloudflare.com/workers/runtime-apis/web-crypto/
-
Cloudflare, JavaScript APIs, Cloudflare Queues Dokumentation, 2026, https://developers.cloudflare.com/queues/configuration/javascript-apis/
-
Shopify, Troubleshooting webhooks, Shopify Entwicklerdokumentation, 2026, https://shopify.dev/docs/apps/build/webhooks/troubleshooting-webhooks
-
Shopify, Ignore duplicate webhooks, Shopify Entwicklerdokumentation, 2026, https://shopify.dev/docs/apps/build/webhooks/ignore-duplicates
-
Shopify, About webhooks, Shopify Entwicklerdokumentation, 2026, https://shopify.dev/docs/apps/build/webhooks
-
Amazon Web Services, Amazon SQS Zustellung mindestens einmal (at least once), Amazon SQS Entwicklerhandbuch, 2026, https://docs.aws.amazon.com/de_de/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues-at-least-once-delivery.html
-
Amazon Web Services, CLI Beispiel für DynamoDB Bedingungsausdrücke, Amazon DynamoDB Entwicklerhandbuch, 2026, https://docs.aws.amazon.com/de_de/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html
-
Google, pause_ad.py, Google Ads API Clientbibliothek für Python, Beispiele, GitHub, 2026, https://github.com/googleads/google-ads-python/blob/main/examples/basic_operations/pause_ad.py
-
Amazon Web Services, Behandlung von Fehlern für eine SQS Ereignisquelle in Lambda, AWS Lambda Entwicklerhandbuch, 2026, https://docs.aws.amazon.com/de_de/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, Erstellen und Konfigurieren einer Amazon SQS Zuordnung von Ereignisquellen, AWS Lambda Entwicklerhandbuch, 2026, https://docs.aws.amazon.com/de_de/lambda/latest/dg/services-sqs-configure.html
-
Amazon Web Services, Verwenden von Warteschlangen für unzustellbare Nachrichten in Amazon SQS, Amazon SQS Entwicklerhandbuch, 2026, https://docs.aws.amazon.com/de_de/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
-
Google, Produkte in Ihrem Merchant Center Konto verwalten, Google Merchant Center Hilfe, 2026, https://support.google.com/merchants/answer/14996874?hl=de
-
Google, Problemlösung: Nicht übereinstimmende Produktverfügbarkeit, Google Merchant Center Hilfe, 2026, https://support.google.com/merchants/answer/12470049?hl=de
-
Google, Einstellung und Außerbetriebnahme, Content API for Shopping, Google for Developers, 2026, https://developers.google.com/shopping-content/guides/sunset?hl=de
-
Bundesministerium der Justiz, Gesetz gegen den unlauteren Wettbewerb (UWG), § 7 Unzumutbare Belästigungen, Abs. 2 Nr. 2 und Abs. 3, Gesetze im Internet, 2026, https://www.gesetze-im-internet.de/uwg_2004/__7.html
-
Gerichtshof der Europäischen Union, Urteil vom 13. November 2025, C-654/23, Inteligo Media, Rn. 48, 53 und 61, EUR Lex, 2025, https://eur-lex.europa.eu/legal-content/DE/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 Entwicklerdokumentation, 2026, https://shopify.dev/docs/apps/launch/protected-customer-data
-
Amazon Web Services, Verwenden von Time to Live (TTL) in DynamoDB, Amazon DynamoDB Entwicklerhandbuch, 2026, https://docs.aws.amazon.com/de_de/amazondynamodb/latest/developerguide/TTL.html
-
Cloudflare, Cron Triggers, Cloudflare Workers Dokumentation, 2026, https://developers.cloudflare.com/workers/configuration/cron-triggers/
-
Amazon Web Services, Zeitplantypen im EventBridge Scheduler, Amazon EventBridge Scheduler Benutzerhandbuch, 2026, https://docs.aws.amazon.com/de_de/scheduler/latest/UserGuide/schedule-types.html