Easy Digital Downloads Conversion-Tracking
Easy Digital Downloads ist die schlanke Alternative zu WooCommerce für reine digitale Verkäufe: kein Versand, kein Lagerbestand, kein Overhead durch eine Steuer-Engine. Beaconry behandelt es als vollwertigen Commerce-Adapter und bildet fast denselben Funnel ab wie für WooCommerce, server-seitig, mit einem strukturellen Unterschied und einer Handvoll EDD-spezifischer Hooks. Hier ist, was wo feuert und warum.
Der Funnel und das eine, das fehlt
EDD bekommt dasselbe Event-Set wie WooCommerce, minus eines. WooCommerce liefert zehn Events. EDD liefert neun. Das fehlende Event ist search, und das ist eine bewusste Scope-Entscheidung, kein Versäumnis (mehr dazu weiter unten). Die neun EDD-Events sind:
view_item,view_item_list,view_cartam oberen Ende des Funnelsadd_to_cart,remove_from_cartim Warenkorb-Funnelbegin_checkoutam Eingang zum Checkoutpurchaseundrefundam unteren Ende
Jedes davon trägt dieselbe Payload-Form, die WooCommerce produziert: ein GA4-items[]-Array (item_id, item_name, item_category, item_brand, price, quantity) UND einen Meta-CAPI-Content-Block (content_ids[], content_type: 'product', content_name, content_category, num_items). Diese Parität ist der ganze Sinn: ein Dynamic-Product-Ads-Katalog bei Meta oder ein GA4-Item-Report kümmert sich nicht darum, ob der Shop auf WooCommerce oder EDD läuft. Die Wire-Payload ist identisch.
Warum kein Search-Event
WooCommerce hat ein eingebautes Produktsuche-Template und einen Query-Pfad (is_search() plus das post_type=product-Flag), der eine Produktsuche-Ergebnisseite sauber identifiziert. Beaconry hookt das und feuert search. EDD hat kein gleichwertiges erstklassiges Produktsuche-Feature. Downloads werden, wenn überhaupt, über die generische WordPress-Suche gesucht, die ein gemischtes Ergebnis-Set zurückgibt (Posts, Pages, Downloads) ohne verlässliches EDD-natives Signal, das sagt "das war eine Download-Suche". search aus der generischen WP-Suche zu feuern, würde Raten bedeuten, und gegen die URL oder den Ergebnis-Mix zu raten, ist genau die Art von Slug-Detection, die Beaconry verweigert. Also gibt EDD search schlicht nicht aus. Die anderen neun Events lösen alle über echte EDD-APIs auf, also bleiben sie.
view_item: render-pfad-agnostisch, nicht template-hook-gebunden
Der naheliegende Hook für einen Produkt-View ist edd_after_download_content, der feuert, nachdem der Single-Download-Content gerendert ist. Beaconry hat ihn einmal genutzt und sich davon verabschiedet, weil dieser Hook am the_content-Filter hängt. Page-Builder-Single-Templates (Elementor Theme Builder, Divi, Bricks) umgehen the_content komplett, also feuerte das Event auf einer builder-getriebenen Download-Seite nie.
Der Fix ist, template_redirect zu hooken und auf den Conditional Tag is_singular('download') zu gaten, der die Main Query (post-type-basiert) inspiziert statt den Render-Pfad. Das feuert identisch für klassische Single-Templates, Block-Templates (FSE) und Page-Builder-Templates. Die Download-ID kommt aus get_queried_object_id(), also gibt es keine Abhängigkeit von einem $post-Global, das ein Builder vielleicht nicht setzt.
add_action( 'template_redirect', [ __CLASS__, 'maybe_handle_view_item' ], 20 );
public static function maybe_handle_view_item(): void {
if ( self::is_prefetch() ) {
return; // a speculation-rules prefetch lands before the visitor navigates
}
if ( ! is_singular( 'download' ) ) {
return;
}
$id = (int) get_queried_object_id();
// ... build items[] from edd_get_download( $id ), dispatch view_item
}Dieselbe Logik schützt jedes render-getriebene View-Event: ein Chrome-Speculation-Rules-Prefetch trifft die Seite, bevor der Besucher tatsächlich navigiert, also würde ein ungeschützter Handler einen Phantom-view_item loggen. Der Prefetch-Check steigt bei diesem Header aus.
view_item_list: drei Render-Pfade, ein Event
Eine EDD-Shop-Liste kann auf drei verschiedene Arten in den Browser gelangen, und derselbe Produktlisten-View muss genau einen view_item_list produzieren, egal welcher Pfad ihn gerendert hat:
- Natives CPT-Archiv. Die Standard-
/downloads/-Shop-Seite oder eindownload_category- /download_tag-Taxonomie-Archiv. Abgefangen auftemplate_redirectviais_post_type_archive('download')undis_tax(...)auf der Main Query. - Der
[downloads]-Shortcode auf einer normalen WP-Seite. Eine Seite unter/products/mit dem Shortcode hat keine Post-Type-Archive-Body-Class, also matchen die Conditional Tags oben nicht. EDD feuertedd_downloads_list_topwährend des Shortcode-Renders mit der aufgelöstenWP_Queryder sichtbaren Downloads, die Beaconry direkt hookt. - Der
edd/downloads-Gutenberg-Block. Das Block-Render-Template hat überhaupt keine Action-Hooks. Beaconry lauscht auf WordPress' generischemrender_block-Filter, und wenn es diesen Block-Namen sieht, spielt es die Query über EDDs öffentliche\EDD\Downloads\Query-Klasse erneut ab, um dieselben sichtbaren IDs zu bekommen. Das eigene HTML des Blocks wird unverändert zurückgegeben.
Alle drei laufen in einen einzigen privaten Dispatcher mit einem statischen Guard pro Request, sodass selbst im pathologischen Fall, dass zwei Pfade auf einer Seite feuern, nur der erste view_item_list rausgeht. Die Liste ist standardmäßig auf 20 Items gedeckelt (filterbar via bcnr_edd_view_item_list_cap), weil die EDD-Pagination meist 12 bis 24 beträgt und es keinen Wert hat, ein 200-Item-Array an einen Katalog-Matcher zu schicken.
view_cart und begin_checkout: beide feuern auf der Checkout-Seite
EDD hat keine separate Warenkorb-Seite, wie WooCommerce sie standardmäßig hat; der Warenkorb wird auf der Checkout-Seite angezeigt und von dort abgeschickt. Also feuert Beaconry sowohl view_cart als auch begin_checkout, wenn der Checkout rendert. Dasselbe Render-Pfad-Problem, derselbe Fix: die alten Template-Hooks (edd_after_checkout_cart, edd_checkout_form_top) feuern nur innerhalb des klassischen [download_checkout]-Shortcode-Renders. Beim EDD-Checkout-Block und Page-Builder-Checkouts bleiben sie stumm, und beide Events fehlten dort.
Das render-agnostische Gate ist edd_is_checkout(), das an EDDs eigenes \EDD\Checkout\Validator::is_checkout() delegiert und über den Abgleich der konfigurierten Purchase-Page-ID auflöst (is_page(edd_get_option('purchase_page'))). Das ist ein Page-ID-Vergleich, kein URL-Match, also ist es korrekt, egal wie der Shop seinen Checkout-Slug umbenannt hat.
add_action( 'template_redirect', [ __CLASS__, 'maybe_handle_checkout_funnel' ], 20 );
public static function maybe_handle_checkout_funnel(): void {
if ( self::is_prefetch() ) {
return;
}
if ( ! edd_is_checkout() ) {
return;
}
self::handle_view_cart(); // GA4 view_cart, current edd_get_cart_contents()
self::handle_begin_checkout(); // GA4 begin_checkout, same cart snapshot
}Beide Handler tragen ihren eigenen statischen Guard pro Request und steigen bei einem leeren Warenkorb aus, sodass ein Reload der Checkout-Seite nicht doppelt feuert. Sie lesen beide den Live-Warenkorb über edd_get_cart_contents() und die Summe über edd_get_cart_total(), die öffentliche EDD-API, nie eine session-interne Struktur.
add_to_cart und remove_from_cart: die EDD-Hook-Signatur-Falle
EDDs Cart-Mutation-Hooks haben ihre Argument-Formen über Versionen hinweg verschoben, und die Docs legen sie nicht fest. Beaconry bindet sie defensiv und liest die echten Argumente mit func_get_args() statt einer festen Parameter-Liste zu vertrauen.
Ein konkreter Bug, den das aufgefangen hat: edd_post_remove_from_cart übergibt drei Argumente, nicht vier, und das zweite ist der download_id-Integer, nicht das Cart-Item-Array. Früherer Code las Argument-Index 1 als das Item-Array, also nullte ein is_array()-Check es bei jedem einzelnen Aufruf stillschweigend aus und das entfernte Item löste nie auf. Das Lesen aus der Quelle (includes/cart/actions.php: do_action( 'edd_post_remove_from_cart', int $cart_key, int $download_id, array $cart_item )) hat es behoben.
Beide Cart-Hooks wurden dabei beobachtet, wie sie mehrfach pro Page-Load feuern (Cart-Rebuilds bei einem Redirect-after-Action, UI-Refresh, Cart-State-Sync, Receipt-Rebuild triggern sie alle erneut). Jeder Handler hält ein statisches Dedup-Set pro Request, sodass drei interne Feuer zu einem Event kollabieren. Der Dedup-Key für add_to_cart ist download_id + md5(serialize(options)), sodass ein echtes erneutes Hinzufügen desselben Produkts in einer anderen Variante sich weiterhin als eigenständiges Event registriert, während ein doppeltes Feuern des identischen Add das nicht tut.
Eine Feinheit, die es wert ist, herausgestellt zu werden: das sind Action-Events, keine View-Events. Zwei echte "In den Warenkorb"-Klicks müssen zwei Events bleiben. Also bekommen Add und Remove anders als die View-Events nie eine deterministische, kollabierende event_id, sie bekommen eine frische UUID pro Dispatch. Der Trick mit der deterministischen ID ist render-getriebenen Views vorbehalten, wo ein Prefetch oder ein Reload tatsächlich einen logischen View repräsentiert.
purchase und refund: deterministische IDs und Dedup
Purchase feuert auf edd_complete_purchase, das läuft, wenn eine Zahlung in den Complete-(Publish-)Status wechselt. Die event_id ist deterministisch: bcnr_edd_purchase_<payment_id>. Ein Refresh der Danke-Seite oder eine erneute Webhook-Zustellung produziert dieselbe ID, sodass Plattformen, die deduplizieren, nie doppelt zählen, und ein Payment-Post-Meta-Marker (_bcnr_purchase_event_fired) gibt eine zweite, persistente Idempotenz-Schicht, die über Requests hinweg überlebt.
Die Line Items für den Kauf kommen aus edd_get_payment_meta_cart_details(), dem historischen Warenkorb-Snapshot, nicht dem Live-Download-Preis. Das ist wichtig: Preis und Menge werden mit dem überschrieben, was zum Kaufzeitpunkt tatsächlich berechnet wurde, sodass eine Erstattung sechs Wochen später den ursprünglichen Betrag meldet, selbst wenn der Download seither neu bepreist wurde.
Refund beobachtet edd_update_payment_status und filtert auf $new_status === 'refunded'. Es nutzt die deterministische transaction_id (edd_payment_<payment_id>) und die historischen Line Items wieder, sodass die Erstattung sauber gegen den ursprünglichen Kauf verrechnet wird.
Refund ist ein reines GA4-Event. Das ist das mit Abstand kontraintuitivste am Commerce-Tracking, also ist es wert, klar gesagt zu werden: von allen Kanälen, an die Beaconry weiterleitet, hat nur GA4 ein echtes Refund-Event, eines, das den erstatteten Umsatz wieder gegen den erfassten Kauf verrechnet. Jeder Ad-Kanal wird bei Refund übersprungen, weil keiner davon ein Refund-Event hat, das etwas Sinnvolles tut. Meta CAPI hat überhaupt keinen Refund-Event-Typ (seine Stornierungs- und Erstattungs-API ist die Commerce-Platform-Order-Management-API, ein anderes Produkt, nicht die Conversions API). TikTok listet kein Refund in seinen unterstützten Events. Pinterest, Snapchat und Reddit können nur einen Custom-"refund"-String nehmen, der ein toter Zähler ohne Smart-Bidding- oder Revenue-Netting-Wert ist, und bei Snapchat würde er sogar mit dem Lead-Slot kollidieren. Also schickt Beaconry Refund an GA4 und überspringt jeden Ad-Kanal, konsistent. Ein Custom-Refund-Event an eine Ad-Plattform zu feuern, würde den gemeldeten ROAS nicht korrigieren, es würde nur Rauschen hinzufügen; den Umsatz zu verrechnen ist etwas, das nur GA4 tatsächlich tut.
PII-Hashing: woher die Match-Keys kommen
Purchase und Refund haben es leicht: die EDD-Zahlung trägt E-Mail, Name, Telefon und Rechnungsadresse des Kunden, und Beaconry liest sie über den normalisierten get_payment()-Helper in eine user_data-Map (em, fn, ln, ph, ct, st, zp, country). Der Forwarder hasht jedes Feld pro Kanal mit SHA-256, bevor es den Server überhaupt verlässt.
Die Cart-Funnel-Events sind der schwierige Fall. Ein view_item oder add_to_cart passiert, bevor der Besucher irgendetwas in ein Checkout-Formular getippt hat, also tragen sie out of the box keine Identifier, und Pinterest, Meta und TikTok warnen dann vor schlechter Match-Qualität. EDD hat kein Äquivalent zu WooCommerces WC()->customer-Billing-Cache, also zieht Beaconry die Pre-Checkout-Identität aus zwei anderen Quellen, eingeschichtet über einen bcnr_pre_dispatch_event-Filter, der nur EDD-Funnel-Events anfasst:
- EDDs Customer-Session (
EDD()->session->get('customer')), die E-Mail / Name / Adresse füllt, sobald der Besucher mit dem Checkout-Formular interagiert hat, Gäste eingeschlossen. - Das eingeloggte WP-User-Konto, das Lücken füllt, wenn ein Kunde den Account-Bereich durchstöbert, bevor die EDD-Session überhaupt etwas darin hat.
Es gibt einen bewussten Guard, der vom WooCommerce-Adapter übernommen ist: EDDs Geolocation-Feature (für Steuer genutzt) kann country und state in der Session automatisch befüllen, bevor der Besucher das Checkout-Formular tatsächlich betreten hat. Also werden die Adressfelder (Stadt, Bundesland, PLZ, Land) nur übernommen, wenn mindestens ein echtes Identitätsfeld (E-Mail, Vorname oder Telefon) gesetzt ist, Felder, die Geolocation nie befüllt. Ohne diesen Check würdest du ein gehashtes "country" als Match-Key für einen Besucher schicken, der sich nie identifiziert hat, was sowohl falsch als auch nutzlos fürs Matching ist.
Eine weitere Schicht: nach einem abgeschlossenen Kauf persistiert Beaconry die rohen Match-Keys in seinen verschlüsselten First-Party-PII-Tresor. Die nächste Cart-Funnel-Session eines wiederkehrenden Kunden reichert dann ihre view_item- und add_to_cart-Events automatisch mit diesen Keys an, ohne erneute Eingabe, und genau daher kommt der Match-Rate-Gewinn bei wiederkehrenden Käufern.
Die Slug-Detection-Regel, auf EDD angewendet
Jede Seiten- und Objekt-Identifikation oben läuft über eine EDD- oder WordPress-API, nie einen URL-String. is_singular('download') für die Produktseite, edd_is_checkout() für den Checkout, is_post_type_archive('download') und is_tax(...) für den Shop, edd_get_download() und get_post_type() === 'download' für den Objekttyp. Nichts matcht /downloads/ oder /checkout/ in einer Regex. Genau das lässt den Adapter unverändert auf einem deutschen Shop funktionieren, der seine Checkout-Seite umbenannt hat, auf einer mehrsprachigen Site mit lokalisierten Taxonomie-Bases oder auf einem Shop, der seinen Shop nach /store/ verschoben hat. Wenn eine API eine Seite nicht identifizieren kann, werden die Daten als fehlend akzeptiert statt geraten.
Fazit
EDD bekommt neun von WooCommerces zehn Events, es fehlt nur search, weil EDD keine erstklassige Produktsuche hat, die sich ohne Raten hooken ließe. Alles andere ist auf voller Parität: identische items[]- und Meta-Content-Block-Payloads, deterministische event_ids auf Views und Käufen für sauberes Dedup, historische Line Items, sodass Erstattungen korrekt verrechnen, und dasselbe render-pfad-agnostische Hooking (template_redirect plus Conditional Tags), das klassische, Block- und Page-Builder-Themes übersteht. Refund geht nur an GA4, den einen Kanal, der Umsatz tatsächlich verrechnet, und überspringt jeden Ad-Kanal, weil keiner davon ein Refund-Event hat, das das Feuern wert wäre. Die einzige EDD-seitige Konfiguration ist "EDD installiert haben".