Easy Digital Downloads conversion tracking
Easy Digital Downloads is the lean alternative to WooCommerce for pure digital sales: no shipping, no inventory, no tax-engine overhead. Beaconry treats it as a first-class commerce adapter and maps almost the same funnel it maps for WooCommerce, server-side, with one structural difference and a handful of EDD-specific hooks. Here is what fires where, and why.
The funnel, and the one thing that is missing
EDD gets the same event set as WooCommerce minus one. WooCommerce ships ten events. EDD ships nine. The missing event is search, and that is a deliberate scope decision, not an oversight (more on that below). The nine EDD events are:
view_item,view_item_list,view_cartat the top of the funneladd_to_cart,remove_from_cartin the cart funnelbegin_checkoutat the entry to checkoutpurchaseandrefundat the bottom
Every one of these carries the same payload shape WooCommerce produces: a GA4 items[] array (item_id, item_name, item_category, item_brand, price, quantity) AND a Meta CAPI content block (content_ids[], content_type: 'product', content_name, content_category, num_items). That parity is the whole point: a Dynamic Product Ads catalogue on Meta, or a GA4 item report, does not care whether the store runs WooCommerce or EDD. The wire payload is identical.
Why no search event
WooCommerce has a built-in product-search template and a query path (is_search() plus the post_type=product flag) that cleanly identifies a product-search result page. Beaconry hooks that and fires search. EDD has no equivalent first-class product search. Downloads are searched, if at all, through the generic WordPress search, which returns a mixed result set (posts, pages, downloads) with no reliable EDD-native signal that says "this was a download search". Firing search off generic WP search would mean guessing, and guessing against the URL or the result mix is exactly the kind of slug-detection Beaconry refuses to do. So EDD simply does not emit search. The other nine events all resolve through real EDD APIs, so they stay.
view_item: render-path-agnostic, not template-hook-bound
The obvious hook for a product view is edd_after_download_content, which fires after the single-download content renders. Beaconry used it once and moved off it, because that hook hangs on the the_content filter. Page-builder single templates (Elementor Theme Builder, Divi, Bricks) bypass the_content entirely, so on a builder-driven download page the event never fired.
The fix is to hook template_redirect and gate on the conditional tag is_singular('download'), which inspects the main query (post-type based) rather than the render path. That fires identically for classic single templates, block (FSE) templates, and page-builder templates. The download ID comes from get_queried_object_id(), so there is no dependency on a $post global that a builder might not set.
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
}The same logic protects every render-driven view event: a Chrome speculation-rules prefetch hits the page before the visitor actually navigates, so an unguarded handler would log a phantom view_item. The prefetch check bails on that header.
view_item_list: three render paths, one event
An EDD shop list can reach the browser three different ways, and the same product-list view has to produce exactly one view_item_list regardless of which path rendered it:
- Native CPT archive. The default
/downloads/shop page, or adownload_category/download_tagtaxonomy archive. Caught ontemplate_redirectviais_post_type_archive('download')andis_tax(...)on the main query. - The
[downloads]shortcode on a normal WP page. A page at/products/with the shortcode has no post-type-archive body class, so the conditional tags above do not match. EDD firesedd_downloads_list_topduring the shortcode render with the resolvedWP_Queryof visible downloads, which Beaconry hooks directly. - The
edd/downloadsGutenberg block. The block render template has no action hooks at all. Beaconry listens on WordPress's genericrender_blockfilter, and when it sees that block name it replays the query through EDD's public\EDD\Downloads\Queryclass to get the same visible IDs. The block's own HTML is returned untouched.
All three funnel into one private dispatcher with a per-request static guard, so even in the pathological case where two paths fire on one page, only the first view_item_list goes out. The list is capped at 20 items by default (filterable via bcnr_edd_view_item_list_cap) because EDD pagination is usually 12 to 24 and there is no value in shipping a 200-item array to a catalogue matcher.
view_cart and begin_checkout: both fire on the checkout page
EDD does not have a separate cart page the way WooCommerce does by default; the cart is shown on, and submitted from, the checkout page. So Beaconry fires both view_cart and begin_checkout when the checkout renders. Same render-path problem, same fix: the old template hooks (edd_after_checkout_cart, edd_checkout_form_top) only fire inside the classic [download_checkout] shortcode render. On the EDD Checkout block and page-builder checkouts they stay silent, and both events went missing there.
The render-agnostic gate is edd_is_checkout(), which delegates to EDD's own \EDD\Checkout\Validator::is_checkout() and resolves by matching the configured purchase-page ID (is_page(edd_get_option('purchase_page'))). That is a page-ID comparison, not a URL match, so it is correct no matter what the store renamed its checkout slug to.
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
}Both handlers carry their own per-request static guard and bail on an empty cart, so a checkout-page reload does not double-fire. They both read the live cart through edd_get_cart_contents() and the total through edd_get_cart_total(), the public EDD API, never a session-internal structure.
add_to_cart and remove_from_cart: the EDD hook-signature trap
EDD's cart-mutation hooks have shifted their argument shapes across versions, and the docs do not pin them down. Beaconry binds them defensively and reads the real arguments with func_get_args() rather than trusting a fixed parameter list.
One concrete bug this caught: edd_post_remove_from_cart passes three arguments, not four, and the second one is the download_id integer, not the cart-item array. Earlier code read argument index 1 as the item array, so an is_array() check silently null-ed it out on every single call and the removed item never resolved. Reading from the source (includes/cart/actions.php: do_action( 'edd_post_remove_from_cart', int $cart_key, int $download_id, array $cart_item )) fixed it.
Both cart hooks have been observed firing multiple times per page load (cart rebuilds on a redirect-after-action, UI refresh, cart-state sync, receipt rebuild all re-trigger them). Each handler holds a per-request static dedup set so three internal fires collapse to one event. The dedup key for add_to_cart is download_id + md5(serialize(options)), so a genuine repeat-add of the same product in a different variant still registers as a distinct event, while a duplicate fire of the identical add does not.
One subtlety worth calling out: these are action events, not view events. Two real "add to cart" clicks must stay two events. So unlike the view events, add and remove never get a deterministic, collapsing event_id, they get a fresh UUID per dispatch. The deterministic-id trick is reserved for render-driven views where a prefetch or a reload genuinely represents one logical view.
purchase and refund: deterministic IDs and dedup
Purchase fires on edd_complete_purchase, which runs when a payment moves to the complete (publish) status. The event_id is deterministic: bcnr_edd_purchase_<payment_id>. A thank-you-page refresh or a repeat webhook delivery produces the same ID, so platforms that dedup never double-count, and a payment-post meta marker (_bcnr_purchase_event_fired) gives a second, persistent layer of idempotency that survives across requests.
Line items for the purchase come from edd_get_payment_meta_cart_details(), the historical cart snapshot, not the live download price. That matters: the price and quantity are overwritten with what was actually charged at purchase time, so a refund six weeks later reports the original amount even if the download has been re-priced since.
Refund watches edd_update_payment_status and filters to $new_status === 'refunded'. It reuses the deterministic transaction_id (edd_payment_<payment_id>) and the historical line items so the refund nets cleanly against the original purchase.
Refund is a GA4-only event. This is the single most counter-intuitive thing about commerce tracking, so it is worth stating plainly: of all the channels Beaconry forwards to, only GA4 has a real refund event, one that nets the refunded revenue back out against the recorded purchase. Every ad channel is skipped on refund, because none of them has a refund event that does anything useful. Meta CAPI has no refund event type at all (its cancellation-and-refund API is the Commerce-Platform order-management API, a different product, not the Conversions API). TikTok lists no refund in its supported events. Pinterest, Snapchat and Reddit can only take a custom "refund" string, which is a dead counter with no Smart-Bidding or revenue-netting value, and on Snapchat it would even collide with the lead slot. So Beaconry sends refund to GA4 and skips every ad channel, consistently. Firing a custom refund event to an ad platform would not correct the reported ROAS, it would just add noise; netting the revenue is something only GA4 actually does.
PII hashing: where the match keys come from
Purchase and refund have it easy: the EDD payment carries the customer's email, name, phone and billing address, and Beaconry reads them through the normalised get_payment() helper into a user_data map (em, fn, ln, ph, ct, st, zp, country). The forwarder SHA-256-hashes each field per channel before it ever leaves the server.
The cart-funnel events are the hard case. A view_item or add_to_cart happens before the visitor has typed anything into a checkout form, so out of the box they carry no identifiers, and Pinterest, Meta and TikTok then warn about poor match quality. EDD has no equivalent of WooCommerce's WC()->customer billing cache, so Beaconry pulls pre-checkout identity from two other sources, layered through a bcnr_pre_dispatch_event filter that only touches EDD funnel events:
- EDD's customer session (
EDD()->session->get('customer')), which populates email / name / address once the visitor has interacted with the checkout form, guests included. - The logged-in WP user account, which fills gaps when a customer is browsing the account area before the EDD session has anything in it.
There is a deliberate guard borrowed from the WooCommerce adapter: EDD's geolocation feature (used for tax) can auto-populate country and state in the session before the visitor has actually entered the checkout form. So the address fields (city, state, zip, country) are only adopted when at least one true identity field (email, first name, or phone) is set, fields geolocation never fills. Without that check, you would ship a hashed "country" as a match key for a visitor who never identified themselves, which is both wrong and useless for matching.
One more layer: after a completed purchase, Beaconry persists the raw match keys into its encrypted first-party PII vault. A returning customer's next cart-funnel session then enriches its view_item and add_to_cart events with those keys automatically, no re-entry required, which is where the match-rate gain on repeat buyers comes from.
The slug-detection rule, applied to EDD
Every page and object identification above goes through an EDD or WordPress API, never a URL string. is_singular('download') for the product page, edd_is_checkout() for checkout, is_post_type_archive('download') and is_tax(...) for the shop, edd_get_download() and get_post_type() === 'download' for the object type. Nothing matches /downloads/ or /checkout/ in a regex. That is what lets the adapter work unchanged on a German store that renamed its checkout page, a multilingual site with localised taxonomy bases, or a store that moved its shop to /store/. If an API cannot identify a page, the data is accepted as missing rather than guessed.
Take-away
EDD gets nine of WooCommerce's ten events, missing only search, because EDD has no first-class product search to hook without guessing. Everything else is at full parity: identical items[] and Meta content-block payloads, deterministic event_ids on views and purchases for clean dedup, historical line items so refunds net correctly, and the same render-path-agnostic hooking (template_redirect plus conditional tags) that survives classic, block, and page-builder themes. Refund goes to GA4 only, the one channel that actually nets revenue, and skips every ad channel because none of them has a refund event worth firing. The only EDD-side configuration is "have EDD installed".