SureCart-Conversion-Tracking
SureCart ist der Sonderfall unter den Commerce-Plugins, die Beaconry unterstützt. WooCommerce und Easy Digital Downloads feuern für fast jeden Funnel-Schritt PHP-Actions, das Tracking ist also rein server-seitig. SureCart feuert seinen Cart-Funnel als Browser-CustomEvents auf dem document, ohne nativen PHP-Submit zum Hooken. Dieser Beitrag zeigt, wie Beaconry diesen Split handhabt: eine Browser-Bridge für die JS-getriebenen Cart-Events und autoritative server-seitige Hooks für Purchase und Refund, alle dedupliziert über eine per-Order-event_id.
Warum SureCart einen anderen Ansatz braucht
SureCart ist auf der WordPress Interactivity API und einem React-artigen Block-Frontend aufgebaut. Der Warenkorb, die Produktansichten, die Checkout-Schritte, all das läuft im Browser und spricht mit SureCarts eigener REST-API. Es gibt keinen klassischen Form-POST, kein template_redirect-Page-Rendering, an dem du dich für "der Besucher hat dieses Produkt gerade angesehen" festhalten könntest. Aus Sicht von WordPress-PHP ist der Großteil des Funnels unsichtbar: die einzigen Momente, die server-seitig auftauchen, sind die, die SureCart explizit als Actions broadcastet, und für den Cart-Funnel broadcastet es keine.
Was SureCart stattdessen tut, ist CustomEvents auf document zu dispatchen. Wenn ein Besucher ein Produkt ansieht, in den Warenkorb legt, den Warenkorb öffnet oder durch den Checkout läuft, feuert SureCart Events wie scProductViewed, scAddedToCart und scCheckoutInitiated. Jedes trägt ein detail-Payload mit den Produkt- oder Bestelldaten. Das ist das Signal, mit dem Beaconry für den Cart-Funnel arbeiten muss, also lauscht Beaconry im Browser auf diese Events und leitet sie durch genau denselben Same-Origin-Endpoint weiter, den jedes andere Beaconry-Event nutzt.
Das Ergebnis ist ein sauberer Split. Cart-Funnel-Events (die JS-getriebenen) kommen über eine Browser-Bridge herein. Purchase und Refund (die geldbewegenden, PII-tragenden) kommen über echte SureCart-PHP-Hooks herein. Beide Pfade landen im selben BCNR_Forwarder::dispatch()-Fan-out, von der Kanal-Seite aus (GA4, Meta, TikTok, der Rest) gibt es also keinen Unterschied darin, wie das Event behandelt wird.
Die Browser-Bridge: neun Cart-Events
Beaconrys Frontend-Engine (das vendored nl-data.js) registriert eine SureCart-Bridge, die neun document.addEventListener-Handler anhängt. Jeder mappt ein SureCart-CustomEvent auf ein kanonisches (GA4-benanntes) Beaconry-Event:
scProductsViewed→view_item_list(Shop- und Collection-Seiten)scProductViewed→view_itemscSearched→searchscAddedToCart→add_to_cartscRemovedFromCart→remove_from_cartscViewedCart→view_cartscCheckoutInitiated→begin_checkoutscPaymentInfoAdded→add_payment_infoscShippingInfoAdded→add_shipping_info
Der Handler liest das CustomEvent-detail, formt es in Beaconrys Standard-items[]-Array plus den Meta-Catalog-Block (content_ids, content_type, content_name, num_items) um und übergibt es an sendEvent(). Das ist derselbe Auslieferungspfad, den die Page-View- und Click-Events nutzen: ein POST an /wp-json/beaconry/v1/event auf deiner eigenen Domain, kein Drittanbieter-Tracker-Script, nichts, was ein Adblocker am Inhalt erkennen könnte. Von dort normalisiert der Server den Event-Namen und fächert ihn an jeden konfigurierten Kanal aus.
Ein Detail, das für die Genauigkeit zählt: SureCarts window-event-Bus trägt mehrere verschiedene detail-Formen. Eine List-View sendet { products: [...] }. Eine einzelne Produktseite sendet ein flaches { id, name, price: {...} }. Ein Add-to-Cart sendet ein einzelnes Line-Item mit einem verschachtelten price.product. Die Checkout-Step-Events senden eine Order-Form mit line_items.data[] und einem total_amount. Die Bridge hat pro Form einen kleinen Converter, sodass item_id immer auf die echte SureCart-Produkt-ID auflöst (nicht auf eine Line-Item-ID, die den Catalog-Match brechen würde), und item_category aus den Collections des Produkts zusammengesetzt wird. item_id hier falsch zu setzen, ist der klassische Weg, wie Dynamic Product Ads stillschweigend aufhören zu matchen, also wird jede Form explizit behandelt statt geraten.
Timing: warum die Bridge früh anhängt
Die meisten von Beaconrys Frontend-Listenern hängen sich innerhalb von requestIdleCallback an, um aus dem kritischen Rendering-Pfad herauszubleiben. Die SureCart-Bridge ist die bewusste Ausnahme. SureCart dispatcht scProductViewed und scProductsViewed aus seinem wp-interactivity-init()-Callback, direkt bei DOM-Ready. Wenn Beaconry seine Listener erst bis zu zwei Sekunden später in der Idle-Zeit anhängen würde, würde es das initiale Product-View-Event auf einer Produktseite komplett verpassen.
Also registriert sich die Bridge synchron beim init, vor dem Idle-Time-Block:
// Pageview and engagement timer are time-sensitive - fire immediately.
if ( a.pageview ) setupPageview();
if ( a.engagement ) setupEngagement();
// SureCart V4 dispatches scProductViewed / scProductsViewed in the
// wp-interactivity init() callback right at DOM-ready. If our listeners
// only attach in requestIdleCallback (up to 2s later), we miss the
// initial events. addEventListener is free; no LCP impact.
if ( _config.surecartBridge ) setupSureCartBridge();Einen Event-Listener anzuhängen ist im Grunde kostenlos, es macht keine DOM-Arbeit und hat keine messbaren LCP-Kosten, das früh zu tun erkauft also Korrektheit ohne Performance-Trade-off. Das Bridge-Config-Flag ist außerdem immer an. Neun idle addEventListener-Aufrufe auf einer Nicht-SureCart-Site kosten minifiziert unter einem Kilobyte und feuern nie, es gibt also keinen Grund, sie hinter einen server-seitigen Plugin-Detection-Check zu sperren, der die Ladereihenfolge falsch erwischen könnte.
Purchase: server-seitig, nicht die Bridge
Dir fällt auf, dass scCheckoutCompleted nicht in der Bridge-Liste steht. Das ist Absicht. Purchase ist das eine Event, bei dem die Match-Qualität am meisten zählt, und das Browser-CustomEvent trägt nur Cart-Daten, keine Kunden-PII. Purchase aus dem Browser zu feuern würde eine Conversion verschicken ohne gehashte E-Mail, ohne Telefon, ohne Rechnungsadresse, also genau die Daten, die die Match-Rate bei Meta und Google Ads treiben.
Also läuft Purchase autoritativ in PHP, über SureCarts surecart/checkout_confirmed-Action:
add_action( 'surecart/checkout_confirmed', [ __CLASS__, 'handle_checkout_confirmed' ], 10, 2 );Das feuert in dem Moment, in dem ein Checkout bestätigt wird, unabhängig vom Payment-Processor (Stripe, eine manuelle Überweisung, Nachnahme). Der Conversion-Moment ist die Bestätigung, das verbindliche "ich kaufe das", nicht die spätere Abwicklung der Belastung. Das heißt, eine per E-Mail aufgegebene Bestellung oder eine manuelle Admin-Belastung zählt trotzdem, auch wenn nie eine Browser-JS-Pipeline dafür gelaufen ist.
Eine scharfe Kante hier ist es wert, sie zu benennen, weil es die Art von Sache ist, die stillschweigend jeder Purchase die PII abstreift, wenn du sie verpasst: das an den Hook übergebene $checkout-Objekt hat seine Relations nicht eager-geladen. Lies es so, wie es ist, und customer sowie line_items kommen leer zurück, die Purchase würde also ohne PII und ohne content_ids dispatchen. Beaconry lädt den Checkout explizit mit den Relations neu, die es braucht:
$full = \SureCart\Models\Checkout::with( [
'customer',
'customer.billing_address',
'line_items',
'line_items.price',
'price.product',
'product.product_collections',
] )->find( $checkout_id );Die Relation-Pfade sehen seltsam fragmentiert aus, und auch das ist Absicht. SureCarts ::with() deckelt die Expansion bei zwei Ebenen pro Eintrag. Ein einzelner tiefer Pfad wie line_items.price.product (drei Ebenen) lädt stillschweigend nicht. Das Produkt muss also über separate Zwei-Ebenen-Einträge erreicht werden, verankert am letzten Knoten (price.product, dann product.product_collections), sonst fällt item_id auf die Line-Item-ID zurück und Produktname und -Kategorie kommen leer. Das ist die Art von vendor-spezifischem Verhalten, das du nur richtig hinbekommst, indem du SureCarts eigene Docs und Source liest, nicht indem du annimmst, ein tiefer gepunkteter Pfad löst so auf, wie er es in den meisten ORMs täte.
Mit geladenen Relations baut der Handler einen normalisierten Snapshot: Order-ID, Betrag, Währung, den Customer-Block (E-Mail, Vor- und Nachname, Telefon, Stadt, Bundesland, Postleitzahl, Land) und die Line Items. Von dort erzeugt er sowohl die GA4-items[]-Form als auch den Meta-Catalog-Block, hasht selbst nichts (der Forwarder macht das PII-Hashing pro Kanal) und dispatcht.
Cents, und die Währungen, die nicht in Cents sind
SureCart folgt der Stripe-Konvention: Beträge sind Integer in der kleinsten Währungseinheit. Eine 49,00-EUR-Bestellung kommt als 4900 an. Beaconry teilt durch 100, um den Major-Unit-Wert zu erhalten, den es als Conversion-Wert meldet.
Außer bei Zero-Decimal-Währungen. JPY, KRW und eine Handvoll anderer haben keine Untereinheit, eine 500-Yen-Bestellung kommt also als 500 an, bereits in Major Units. Diese durch 100 zu teilen würde die Conversion mit einem Prozent ihres echten Werts melden. Beaconry führt die Stripe-Zero-Decimal-Währungsliste und überspringt die Division für diese Codes:
private static function amount_to_major( float $amount, string $currency ): float {
$code = strtoupper( trim( $currency ) );
if ( $code !== '' && in_array( $code, self::ZERO_DECIMAL_CURRENCIES, true ) ) {
return $amount;
}
return $amount / 100.0;
}Dieselbe Logik existiert auf der Browser-Seite der Bridge für die Cart-Funnel-Werte, weil diese Beträge ebenfalls in Cents von SureCart kommen. Eine frühere Version nutzte eine bedingte "nur teilen, wenn der Integer groß genug ist"-Heuristik, die kleine Bestellungen unter einer Einheit (eine 99-Cent-Bestellung) als 99 der Währung meldete. Das Lesen der SureCart-Docs bestätigte, dass Beträge immer in Cents sind, also wurde die Heuristik durch die obige bedingungslose, Zero-Decimal-bewusste Umrechnung ersetzt.
Refund: der richtige Hook, und zwei falsche
Refund ist GA4-only. Beaconry verrechnet den Refund gegen den ursprünglichen Purchase im GA4-Umsatz-Reporting und überspringt die Ad-Kanäle, weil keiner von Meta, TikTok, Google Ads oder den anderen ein echtes Refund-Conversion-Event hat, an das es geschickt werden könnte. Dieser Teil ist über alle drei Commerce-Adapter geteilt.
Was SureCart-spezifisch ist, ist welcher Hook bei einem Refund tatsächlich feuert, und hier sind die naheliegenden Kandidaten beide falsch:
surecart/refund_createdexistiert nicht. Es ist ein Phantom-Hook, eine Action, die du in Analogie zu den anderen Models vernünftigerweise erwarten würdest, aber SureCart feuert sie nie.surecart/purchase_revokedist das falsche Signal. Es feuert nur, wenn ein Refund den Zugang entzieht (das revoke-purchase-Flag auf dem Refund), nicht bei einer schlichten Geld-Erstattung. Eine Teilerstattung, die den Zugang intakt lässt, löst es nie aus.
Der Hook, der bei jedem Refund feuert, ist das generische Model-Created-Event, das SureCart aus seinem Basis-Model::create()-Pfad emittiert:
add_action( 'surecart/models/refund/created', [ __CLASS__, 'handle_refund_created' ], 10, 1 );Das feuert bei jedem Refund, von jedem Processor, ob aus der Admin-UI oder der REST-API ausgelöst, ohne Webhook und ohne Zugangs-Entzug. Das $refund-Objekt trägt seinen eigenen Betrag in Cents, seine eigene Währung und eine Referenz auf den Charge, den es erstattet. Weil Beaconry den eigenen Betrag des Refunds liest (nicht den Order-Total), werden Teilerstattungen mit dem korrekten Teilwert gemeldet, durch dieselbe Zero-Decimal-bewusste Umrechnung wie der Purchase geführt.
Das ist ein Lehrbuchfall für die "lies die Docs und die Source, rate nicht"-Regel. Die ersten beiden Hook-Namen sehen richtig aus und würden ein flüchtiges Code-Review bestehen. Erst das Prüfen von SureCarts tatsächlichem Model-Event-Verhalten enthüllt, dass das generische surecart/models/refund/created dasjenige ist, das zuverlässig feuert.
Per-Order-event_id-Dedup
Beide server-seitigen Events tragen eine deterministische event_id, abgeleitet aus der SureCart-Objekt-ID:
- Purchase:
bcnr_sc_purchase_<checkout_id> - Refund:
bcnr_sc_refund_<refund_id>
Deterministisch, nicht zufällig, weil derselbe Wert einen Retry überleben muss. Wenn der checkout_confirmed-Hook zweimal läuft (ein Re-Fire, ein Status-Flip im Admin) oder ein Kunde die Bestätigungsseite neu lädt, ist die event_id identisch. Auf der Vendor-Seite heißt das, dass die Plattform das Paar dedupliziert und eine Conversion zählt. Beaconry schreibt außerdem eine lokale Marker-Option, die auf die ID gekeyt ist (bcnr_sc_purchase_fired_<hash>), und kurzschließt, wenn sie bereits gesetzt ist, sodass ein doppeltes Hook-Feuern den Dispatcher kein zweites Mal überhaupt erreicht. Zwei Ebenen, ein Count.
Das ist derselbe event_id-Mechanismus, der den Hybrid-Modus antreibt. Wenn du einen Browser-Pixel neben dem server-seitigen Dispatch laufen lässt, spiegelt Beaconry den Purchase mit genau derselben event_id, die die Server-CAPI genutzt hat, zu den geladenen Pixeln, sodass das Browser-zu-Server-Paar auf jeder Plattform dedupliziert. Der Hybrid-Modus-Beitrag behandelt diesen Pfad im Detail.
Eine ehrliche Einschränkung: ein SureCart-Refund verlinkt auf einen Charge, während der Purchase auf die Checkout-ID keyt. Sie teilen sich keine Transaktions-ID, GA4 wird den erstatteten Umsatz also auf Account-Ebene verrechnen, aber nicht automatisch einen spezifischen Refund mit seiner spezifischen ursprünglichen Order-Zeile paaren. Das ist als zukünftige Arbeit dokumentiert statt übertüncht.
Eine Sache, die die Bridge nicht tut: einen falschen Abandon feuern
Beaconry hat ein separates Form-Funnel-Feature, das verfolgt, wo Besucher Lead-Formulare abbrechen. Commerce-Checkouts sind davon explizit ausgeschlossen. Ein SureCart-Checkout ist kein Lead-Formular, es hat seinen eigenen View-zu-begin_checkout-zu-Purchase-Funnel, und sein Submit ist für den Form-Funnel ohnehin unsichtbar (es ist REST und JS, kein nativer Submit). Es als Formular zu tracken würde bei jedem abgeschlossenen Purchase ein form_abandon fehlfeuern. Beaconry erzwingt den Ausschluss von der Server-Seite (das render-agnostische Exclude-Flag, gesetzt wenn die Seite ein Commerce-Checkout oder -Cart ist) und als CSS-Klassen-Backstop in nl-data.js, das SureCarts Checkout- und Cart-Component-Klassen erkennt. Checkout-Step-Abbruch ist die Aufgabe des Commerce-Funnels, ein begin_checkout ohne folgenden purchase, nicht die des Form-Funnels.
Fazit
SureCart teilt sich sauber in zwei Tracking-Pfade und Beaconry folgt dem Split. Der JS-getriebene Cart-Funnel (neun Events von view_item_list bis add_payment_info) kommt über eine Browser-Bridge herein, die auf SureCarts CustomEvents lauscht und sie durch denselben Same-Origin-Endpoint wie alles andere routet, früh anhängend, damit sie SureCarts At-Init-Produktansichten nicht verpasst. Purchase und Refund laufen server-seitig über echte SureCart-Hooks: surecart/checkout_confirmed für Purchase (neu geladen mit den richtigen Zwei-Ebenen-Relations, damit die PII und Produkt-IDs tatsächlich da sind) und das generische surecart/models/refund/created für Refund (der eine Hook, der bei jedem Refund feuert, nicht die zwei, die richtig aussehen und es nicht sind). Beträge werden aus Stripe-Cents umgerechnet, mit einer Zero-Decimal-Ausnahme, und eine deterministische per-Order-event_id hält Retries und Hybrid-Modus-Browser-Pixel vom Doppelzählen ab. Die einzige SureCart-seitige Konfiguration ist, SureCart installiert zu haben.