Architecture

Why refunds go to GA4 only

A refund is a conversion that ran backwards. GA4 has a native event for exactly that: it nets the revenue back against the original purchase. No ad platform has a real refund event, so sending one a custom "Refund" buys nothing and risks polluting the very counts your bidding runs on. Beaconry routes refunds to GA4 alone. Here is the reasoning, the wire-level detail, and why this pre-empts the "my refund vanished from Meta" support ticket.

Reading time: ~7 minPublished: 2026-06-08

The principle in one sentence

A conversion event goes to a channel only if that channel has a real concept of it. For refunds, exactly one destination qualifies: GA4. Every ad platform either has no refund event at all, or has a slot that would do damage if you fired into it. So the rule is not "send the refund everywhere it is technically accepted" but "send it only where it means something". That single principle decides the whole behaviour, and it is worth understanding why, because the tempting alternative (spray a custom refund event at every channel that will not reject it) is exactly the kind of well-meant choice that quietly corrupts a campaign report.

What GA4 actually does with a refund

GA4 treats refund as a first-class e-commerce event, the same status as purchase, add_to_cart, or view_item. It is not a custom event you have to define. When GA4 receives a refund with a transaction_id that matches an earlier purchase, it nets the refunded revenue back against that purchase in the monetisation reports. The customer's "Total revenue" and "Purchase revenue" figures drop by the refunded amount, automatically, with no custom-report arithmetic.

That netting is the entire reason refunds are worth sending at all. Without it, a refund is just a number sitting in a table that someone has to remember to subtract by hand. With it, GA4's revenue numbers stay truthful on their own. This is a capability the ad platforms simply do not expose, and it is what makes GA4 the one channel where a refund earns its place on the wire.

What the ad platforms do with a refund: nothing useful

Run down the channels Beaconry forwards to and ask the only question that matters, "does this platform have a native refund event that nets against the purchase":

  • Meta CAPI: no. Refund is not a supported Conversions-API event type. Meta does have a Cancellation-and-Refund API, but that is the Commerce-Platform order-management API for managing shop orders, a completely different surface, not the Conversions API the pixel and CAPI optimise on. A custom Refund event sent to CAPI is inert: it never surfaces in the standard Events Manager view and bidding algorithms do not act on it.
  • TikTok Events API: no refund or return standard event. Per TikTok's own docs, refund handling is "not applicable" to the campaign-optimisation event set. A custom event would be a passive counter at best.
  • Snapchat CAPI: no native refund event. Worse, with no standard mapping it falls onto the same CUSTOM_EVENT_1 slot that Beaconry reserves for form leads. Firing a refund there would collide with your lead events and make the two impossible to separate in the Snap Events Manager.
  • Pinterest Conversions API: no native refund event. You could send a custom refund and Pinterest would even display it in the manager, but it carries zero Smart-Bidding value and does no revenue netting. It is a counter with a friendly label.
  • Reddit Conversions API: same story as Pinterest. A custom refund would show up but optimise nothing and net nothing.
  • Microsoft Ads: the OfflineConversions API has no refund-conversion concept to map to in the first place, so there is nothing to send.

Notice the pattern. On Meta and TikTok a custom refund is swallowed silently. On Snapchat it is actively harmful (slot collision). On Pinterest and Reddit it is visible but worthless. Not one of them does what GA4 does. So the honest answer everywhere except GA4 is: skip it.

The trap: "but Pinterest and Reddit would show it"

This is the decision that looks reasonable and is not. Pinterest and Reddit DO render a custom refund event in their dashboards, so an obvious instinct is "well, send it to the channels that will display it, skip the ones that swallow it". That rule is arbitrary, and arbitrary rules rot.

It is arbitrary because the deciding factor is "is this event visible in the vendor UI", not "does this event mean anything". Visibility is an accident of each platform's dashboard, not a signal of value. A refund event on Pinterest does not net revenue, does not adjust bidding, and does not correct attribution. It just increments a number that nobody downstream is wired to interpret. Sending it to Pinterest-because-visible but not Meta-because-invisible would split one logical decision across two contradictory rules, and the next person to touch the dispatcher would have to reverse-engineer why.

The principled rule is cleaner and it generalises: a conversion event is dispatched to a channel only if the channel has a real concept of it. For refunds that resolves to GA4 alone, and it stays true for any future channel Beaconry adds without anyone having to re-litigate it.

How the skip is enforced in code

The dispatcher has a per-channel skip list, BCNR_Forwarder::SKIP_BY_CHANNEL. The refund name appears in the skip list for meta, tiktok, snapchat, pinterest, and reddit. Microsoft Ads never had a refund mapping to begin with. GA4 has no skip entry for refund, so it receives the event normally. The relevant slice of the dispatch loop looks like this:

// In BCNR_Forwarder::dispatch(), after the pre-dispatch filter:

if ( self::has_ga4() ) {
    self::dispatch_ga4( $event );          // refund lands here, nets vs purchase
    self::after_dispatch( 'ga4', $event );
}

if ( self::has_meta() && self::should_dispatch_to_channel( 'meta', $event ) ) {
    // should_dispatch_to_channel('meta', ...) returns false for 'refund'
    // because 'refund' is in SKIP_BY_CHANNEL['meta'] - so this is skipped
    self::dispatch_meta( $event );
    self::after_dispatch( 'meta', $event );
}
// ... same skip outcome for tiktok / snapchat / pinterest / reddit

Each channel's should_dispatch_to_channel() check reads its own SKIP_BY_CHANNEL entry and returns false when the event name is on the list. So a refund reaches dispatch_ga4() and stops there. The skip is data, not branching logic scattered through five dispatch methods, which is why a sixth ad channel inherits the right behaviour the moment you add 'refund' to its skip set.

One subtlety worth calling out: a refund is NOT classed as an "analytics-only" event the way form_start and form_abandon are. Those route through a separate central gate (ANALYTICS_ONLY_EVENTS) that hard-stops before any ad channel. A refund is a genuine commerce event, it just happens to have only one channel that supports it. The two mechanisms look similar from the outside (both end up GA4-only) but they exist for different reasons: analytics-only is "behavioural signal, never a conversion", refund is "real conversion, only one channel implements it". Conflating them in code would be a mistake the moment some future ad platform ships a real refund event.

The refund payload itself

When WooCommerce fires its order-refunded hook, Beaconry's handle_refund() builds the event. Three details are load-bearing:

The value is negative. The refund value is sent as -1 * delta, a negative number. GA4's native handling does the netting on its own, but the negative sign also means that if a customer DOES build a custom report that sums event values across the pipeline, the refund subtracts rather than adds. It is the correct sign for the one channel that gets it and harmless arithmetic for any downstream consumer:

BCNR_Forwarder::dispatch( [
    'name'      => 'refund',
    'event_id'  => 'bcnr_refund_' . $order_id . '_' . (int) ( $total_refunded * 100 ),
    'timestamp' => time(),
    'page_url'  => self::current_url(),
    'params'    => [
        'transaction_id' => (string) $order_id,
        'currency'       => $order->get_currency(),
        'value'          => -1 * $delta,   // negative: nets against the purchase
    ],
    'user_data' => self::user_data_from_order( $order ),
] );

Partial refunds are handled by delta. The handler reads get_total_refunded() (the cumulative refunded amount on the order) and compares it to the last total Beaconry already reported, stored in order meta. It sends only the difference, the delta. Refund 30 of a 100 order today and the event value is -30. Refund another 20 next week and the next event value is -20, not -50. GA4 nets each correction against the same transaction_id in turn, so the running revenue figure tracks reality at every step instead of double-subtracting.

The event_id is idempotent. The event_id bakes the refunded total in cents into the string: bcnr_refund_<order_id>_<cents>. WooCommerce can fire the same refund hook more than once for a single refund action, and the stored-total comparison already short-circuits a re-fire (if total_refunded <= prev_total, return early). The cents-stamped event_id is the second line of defence: the same refund amount produces the same id, so even a duplicate that slipped through the guard deduplicates rather than double-counting. EDD and SureCart use the same shape through their own refund hooks (surecart/models/refund/created for SureCart, the EDD refund path for EDD), so the GA4-only routing and the netting behaviour are identical across all three commerce platforms.

What this pre-empts: "my refund disappeared from Meta"

Here is the support ticket this design heads off. A customer issues a refund in WooCommerce, opens GA4, and sees revenue drop correctly. Then they open Meta Events Manager, look for a matching refund, and find nothing. The instinct is "the plugin is dropping my refunds". It is not. Meta has nowhere to put a refund. There is no Meta refund event for it to land in, so the absence is correct behaviour, not a bug.

The honest thing to do, and what Beaconry does, is to not send a phantom event to Meta in the first place. A custom Refund event on CAPI would "appear" only in the sense of inflating a custom-event counter that nobody acts on, while doing zero netting and zero optimisation. That would actually make the confusion worse, because now there IS a Meta refund number, it just does not do anything and does not reconcile against your purchase count. Skipping it entirely is both the more honest behaviour and the one that keeps the Events Manager view clean.

So the answer to the ticket is short: refunds live in GA4, where the platform nets them against the purchase for you. The ad channels keep optimising on the conversions they actually support, with counts that are not polluted by inert refund events. If you want the full refund-corrected revenue picture, it is in GA4 monetisation, already netted, no manual subtraction.

How the ad platforms correct for refunds without an event

It is fair to ask: if Meta and TikTok never see the refund, do their ROAS numbers stay permanently wrong? No, and this is why GA4-only is doc-conformant behaviour rather than a gap. The ad platforms handle value correction through their own attribution-decay and conversion-adjustment mechanisms, not through a real-time refund event. There is no negative-value Purchase you are supposed to send them; their model is to age and adjust attributed conversions on their side. Trying to bolt a refund event onto a system that was not built to consume one does not improve its accuracy, it just adds noise. The division of labour is clean: GA4 owns the netted, refund-corrected revenue truth; the ad channels own optimisation on the positive conversions they support and correct value their own way.

Take-away

Refunds go to GA4 only because GA4 is the only channel with a native refund event that nets revenue back against the purchase. Every ad platform either has no refund event (Meta, TikTok, Microsoft), or has one that would only pollute or collide (Snapchat's lead-slot collision), or has a cosmetic counter that optimises nothing (Pinterest, Reddit). The principle, "send a conversion to a channel only if that channel has a real concept of it", is what keeps the routing honest and what makes a future channel slot in without a re-think. The practical payoff is two-fold: GA4 revenue stays truthful with zero manual subtraction, and your ad-channel conversion counts stay clean, no phantom refund events inflating the numbers your bidding depends on. The "where did my Meta refund go" question answers itself once you know Meta never had a place to put it.