SureCart conversion tracking
SureCart is the odd one out among the commerce plugins Beaconry supports. WooCommerce and Easy Digital Downloads fire PHP actions for almost every funnel step, so the tracking is pure server-side. SureCart fires its cart funnel as browser CustomEvents on the document, with no native PHP submit to hook. This post walks through how Beaconry handles that split: a browser bridge for the JS-driven cart events, and authoritative server-side hooks for purchase and refund, all deduplicated on a per-order event_id.
Why SureCart needs a different approach
SureCart is built on the WordPress Interactivity API and a React-style block frontend. The cart, the product views, the checkout steps, all of that runs in the browser and talks to SureCart's own REST API. There is no classic form POST, no template_redirect page render you can latch onto for "the visitor just viewed this product". From WordPress PHP's point of view, most of the funnel is invisible: the only moments that surface server-side are the ones SureCart explicitly broadcasts as actions, and for the cart funnel it broadcasts none.
What SureCart does instead is dispatch CustomEvents on document. When a visitor views a product, adds to cart, opens the cart, or moves through checkout, SureCart fires events like scProductViewed, scAddedToCart, and scCheckoutInitiated. Each one carries a detail payload with the product or order data. That is the signal Beaconry has to work with for the cart funnel, so Beaconry listens for those events in the browser and forwards them through the exact same same-origin endpoint every other Beaconry event uses.
The result is a clean split. Cart-funnel events (the JS-driven ones) come in through a browser bridge. Purchase and refund (the money-moving, PII-carrying ones) come in through real SureCart PHP hooks. Both paths land in the same BCNR_Forwarder::dispatch() fan-out, so from the channel side (GA4, Meta, TikTok, the rest) there is no difference in how the event is treated.
The browser bridge: nine cart events
Beaconry's frontend engine (the vendored nl-data.js) registers a SureCart bridge that attaches nine document.addEventListener handlers. Each maps a SureCart CustomEvent to a Beaconry canonical (GA4-named) event:
scProductsViewed→view_item_list(shop and collection pages)scProductViewed→view_itemscSearched→searchscAddedToCart→add_to_cartscRemovedFromCart→remove_from_cartscViewedCart→view_cartscCheckoutInitiated→begin_checkoutscPaymentInfoAdded→add_payment_infoscShippingInfoAdded→add_shipping_info
The handler reads the CustomEvent detail, reshapes it into Beaconry's standard items[] array plus the Meta catalog block (content_ids, content_type, content_name, num_items), and hands it to sendEvent(). That is the same delivery path the page-view and click events use: a POST to /wp-json/beaconry/v1/event on your own domain, no third-party tracker script, nothing for an adblocker to recognise by content. From there the server normalises the event name and fans it out to every configured channel.
One detail that matters for accuracy: SureCart's window-event bus carries several different detail shapes. A list view sends { products: [...] }. A single product page sends a flat { id, name, price: {...} }. An add-to-cart sends a single line-item with a nested price.product. The checkout-step events send an order shape with line_items.data[] and a total_amount. The bridge has a small converter per shape so that item_id always resolves to the real SureCart product id (not a line-item id, which would break catalog match), and item_category is joined from the product's collections. Getting item_id wrong here is the classic way Dynamic Product Ads silently stop matching, so each shape is handled explicitly rather than guessed at.
Timing: why the bridge attaches early
Most of Beaconry's frontend listeners attach inside requestIdleCallback to stay out of the critical rendering path. The SureCart bridge is the deliberate exception. SureCart dispatches scProductViewed and scProductsViewed from its wp-interactivity init() callback, right at DOM-ready. If Beaconry only attached its listeners up to two seconds later in idle time, it would miss the initial product-view event on a product page entirely.
So the bridge registers synchronously at init, before the 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();Attaching an event listener is essentially free, it does no DOM work and has no measurable LCP cost, so doing it early buys correctness without a performance trade. The bridge config flag is also always on. Nine idle addEventListener calls on a non-SureCart site cost under a kilobyte minified and never fire, so there is no reason to gate them behind a server-side plugin-detection check that could get the load order wrong.
Purchase: server-side, not the bridge
You will notice scCheckoutCompleted is not in the bridge list. That is on purpose. Purchase is the one event where match quality matters most, and the browser CustomEvent only carries cart data, no customer PII. Firing purchase from the browser would ship a conversion with no hashed email, no phone, no billing address, which is exactly the data that drives Meta and Google Ads match-rate.
So purchase runs authoritatively in PHP, off SureCart's surecart/checkout_confirmed action:
add_action( 'surecart/checkout_confirmed', [ __CLASS__, 'handle_checkout_confirmed' ], 10, 2 );This fires the moment a checkout is confirmed, independent of the payment processor (Stripe, a manual bank transfer, cash on delivery). The conversion moment is the confirmation, the binding "I am buying this", not the later settlement of the charge. That means an order placed by email or a manual admin charge still counts, even though no browser JS pipeline ever ran for it.
One sharp edge here that is worth calling out, because it is the kind of thing that silently strips PII off every purchase if you miss it: the $checkout object handed to the hook does not have its relations eager-loaded. Read it as-is and customer and line_items come back empty, so the purchase would dispatch with no PII and no content_ids. Beaconry reloads the checkout explicitly with the relations it needs:
$full = \SureCart\Models\Checkout::with( [
'customer',
'customer.billing_address',
'line_items',
'line_items.price',
'price.product',
'product.product_collections',
] )->find( $checkout_id );The relation paths look oddly fragmented, and that too is deliberate. SureCart's ::with() caps expansion at two levels per entry. A single deep path like line_items.price.product (three levels) silently does not load. So the product has to be reached through separate two-level entries anchored on the last node (price.product, then product.product_collections), otherwise item_id falls back to the line-item id and the product name and category ship empty. This is the sort of vendor-specific behaviour you only get right by reading SureCart's own docs and source, not by assuming a deep dotted path resolves the way it would in most ORMs.
With the relations loaded, the handler builds a normalised snapshot: order id, amount, currency, the customer block (email, first and last name, phone, city, state, postal code, country), and the line items. From there it produces both the GA4 items[] shape and the Meta catalog block, hashes nothing itself (the forwarder does PII hashing per channel), and dispatches.
Cents, and the currencies that are not in cents
SureCart follows the Stripe convention: amounts are integers in the smallest currency unit. A 49.00 EUR order arrives as 4900. Beaconry divides by 100 to get the major-unit value it reports as the conversion value.
Except for zero-decimal currencies. JPY, KRW, and a handful of others have no minor unit, so a 500 yen order arrives as 500, already in major units. Dividing those by 100 would report the conversion at one percent of its real value. Beaconry keeps the Stripe zero-decimal currency list and skips the division for those 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;
}The same logic exists on the browser side of the bridge for the cart-funnel values, because those amounts come from SureCart in cents too. An earlier version used a conditional "divide only if the integer is large enough" heuristic, which reported small orders under one unit (a 99-cent order) as 99 of the currency. Reading the SureCart docs confirmed amounts are always in cents, so the heuristic was replaced with the unconditional, zero-decimal-aware conversion above.
Refund: the right hook, and two wrong ones
Refund is GA4 only. Beaconry nets the refund against the original purchase in GA4 revenue reporting and skips the ad channels, because none of Meta, TikTok, Google Ads, or the others has a real refund conversion event to send it to. That part is shared across all three commerce adapters.
What is SureCart-specific is which hook actually fires on a refund, and here the obvious candidates are both wrong:
surecart/refund_createddoes not exist. It is a phantom hook, an action you would reasonably expect by analogy with the other models, but SureCart never fires it.surecart/purchase_revokedis the wrong signal. It fires only when a refund revokes access (the revoke-purchase flag on the refund), not on a plain monetary refund. A partial refund that leaves access intact never triggers it.
The hook that does fire on every refund is the generic model-created event SureCart emits from its base Model::create() path:
add_action( 'surecart/models/refund/created', [ __CLASS__, 'handle_refund_created' ], 10, 1 );This fires for every refund, from any processor, whether triggered from the admin UI or the REST API, with no webhook and no access-revoke required. The $refund object carries its own amount in cents, its own currency, and a reference to the charge it refunds. Because Beaconry reads the refund's own amount (not the order total), partial refunds are reported at the correct partial value, run through the same zero-decimal-aware conversion as the purchase.
This is a textbook case for the "read the docs and the source, do not guess" rule. The first two hook names look right and would pass a casual code review. Only checking SureCart's actual model-event behaviour reveals that the generic surecart/models/refund/created is the one that reliably fires.
Per-order event_id dedup
Both server-side events carry a deterministic event_id derived from the SureCart object id:
- Purchase:
bcnr_sc_purchase_<checkout_id> - Refund:
bcnr_sc_refund_<refund_id>
Deterministic, not random, because the same value has to survive a retry. If the checkout_confirmed hook runs twice (a re-fire, a status flip in the admin), or a customer reloads the confirmation page, the event_id is identical. On the vendor side that means the platform deduplicates the pair and counts one conversion. Beaconry also writes a local marker option keyed on the id (bcnr_sc_purchase_fired_<hash>) and short-circuits if it is already set, so a duplicate hook fire does not even reach the dispatcher a second time. Two layers, one count.
This is the same event_id mechanism that powers hybrid mode. If you run a browser pixel alongside the server-side dispatch, Beaconry mirrors the purchase to the loaded pixels with the very same event_id the server CAPI used, so the browser-to-server pair deduplicates on each platform. The hybrid mode post covers that path in detail.
One honest limitation: a SureCart refund links to a charge, while the purchase keys on the checkout id. They do not share a transaction id, so GA4 will net the refunded revenue at the account level but will not auto-pair a specific refund to its specific original order row. That is documented as future work rather than papered over.
One thing the bridge does not do: fire a false abandon
Beaconry has a separate form-funnel feature that tracks where visitors abandon lead forms. Commerce checkouts are explicitly excluded from it. A SureCart checkout is not a lead form, it has its own view-to-begin_checkout-to-purchase funnel, and its submit is invisible to the form funnel anyway (it is REST and JS, no native submit). Tracking it as a form would mis-fire a form_abandon on every completed purchase. Beaconry enforces the exclusion from the server side (the render-agnostic exclude flag set when the page is a commerce checkout or cart) and as a CSS-class backstop in nl-data.js that recognises SureCart's checkout and cart component classes. Checkout-step abandonment is the commerce funnel's job, a begin_checkout with no following purchase, not the form funnel's.
Take-away
SureCart splits cleanly into two tracking paths and Beaconry follows the split. The JS-driven cart funnel (nine events from view_item_list through add_payment_info) comes in through a browser bridge that listens for SureCart's CustomEvents and routes them through the same same-origin endpoint as everything else, attaching early so it does not miss SureCart's at-init product views. Purchase and refund run server-side off real SureCart hooks: surecart/checkout_confirmed for purchase (reloaded with the right two-level relations so the PII and product ids are actually there), and the generic surecart/models/refund/created for refund (the one hook that fires on every refund, not the two that look right and are not). Amounts are converted from Stripe cents with a zero-decimal exception, and a deterministic per-order event_id keeps retries and hybrid-mode browser pixels from double-counting. The only SureCart-side configuration is having SureCart installed.