Skip to content

Add purchase event deduplication with event_id and localStorage - #45

Merged
koenvdwetering merged 1 commit into
masterfrom
claude/purchase-pixel-deduplication-0jb9vp
Aug 13, 2026
Merged

Add purchase event deduplication with event_id and localStorage#45
koenvdwetering merged 1 commit into
masterfrom
claude/purchase-pixel-deduplication-0jb9vp

Conversation

@koenvdwetering

Copy link
Copy Markdown

Summary

This PR implements purchase event deduplication across page reloads by introducing a deterministic event_id field and persistent transaction tracking via localStorage. This ensures purchase events are not double-counted when the success page is reloaded, and aligns browser-side and server-side events for proper Meta deduplication.

Key Changes

  • New EventIdGenerator utility (Util/EventIdGenerator.php): Generates deterministic event IDs in the format purchase.<increment_id> for orders, ensuring consistency between browser and Conversions API events
  • New EventId tag (DataLayer/Tag/Order/EventId.php): Provides the event ID from the checkout session's last order
  • Purchase deduplication logic: Added to both push.js and script-pusher.phtml:
    • stableStringify(): Serializes objects with sorted keys for consistent hashing regardless of key assembly order
    • getPushedTransactions(): Retrieves previously pushed transaction IDs from localStorage
    • rememberTransaction(): Persists transaction IDs to localStorage (keeps last 50)
    • getTransactionId(): Extracts transaction ID from purchase events
    • Checks prevent duplicate purchases across page reloads before pushing to dataLayer
  • Updated event classes: Purchase.php and PurchaseWebhookEvent.php now include the event_id field in their output
  • Documentation: Added "Purchase deduplication" section to USAGE.md explaining the event_id mapping and localStorage behavior
  • Version bump: Updated to 1.8.0

Implementation Details

  • Transaction deduplication uses localStorage with fallback to in-memory guard (reset on page load)
  • The stable stringify function ensures event hashing is deterministic even when object keys are assembled in different orders server-side
  • Event ID format (purchase.<increment_id>) is shared between browser dataLayer events and the order_created webhook for Meta's event deduplication
  • Both Hyva and standard theme implementations are updated in parallel to maintain consistency

https://claude.ai/code/session_01Et1MecPwQ7nDwHxAxUsEQ2

The success page pushes trytagging_purchase along two independent paths: the
server-rendered layout event from checkout_onepage_success.xml and the session
event that reaches the page through the gtm-checkout customerData section. Both
carry the same values, but the layout path builds ecommerce with items first
(Tag\Order\Order is a MergeTag that TagParser unsets and merges back), so the
JSON.stringify hash that guards against duplicates differed and the second push
went through. On Luma that means two purchase hits per order; Hyva only reads
the cart and customer sections, so it saw one.

- Hash events on a key-sorted serialization, so two events holding the same
  values dedup regardless of the order the keys were assembled in.
- Dedup purchases on their transaction as well, kept in localStorage, since the
  in-memory guard is reset on every page load and does not survive a reload of
  the success page or a session event arriving on the next page view. Falls
  back to the in-memory guard when storage is unavailable.
- Add a deterministic event_id (purchase.<increment_id>) to both browser events
  and to the order_created webhook. Meta deduplicates browser and Conversions
  API events on event_id, not on transaction_id, so both sides need the same
  value. EventIdGenerator is the single place that defines the format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Et1MecPwQ7nDwHxAxUsEQ2
@koenvdwetering
koenvdwetering merged commit 093a92a into master Aug 13, 2026
1 check passed

@MikeVerlinde MikeVerlinde left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Koen mentioned the fix is for GA4. But GA4 deduplicates on transaction_id. Meta deduplicates on order_id.

If the fix is for GA4, the already present transaction_id should suffice. If for Meta, this fix is correct.

Copy link
Copy Markdown
Author

It is both, but they are two separate parts of this PR — and the two claims are worth taking apart.

The duplicate pushes are the actual defect, and they are platform-agnostic.

On Luma the success page pushes trytagging_purchase twice: once from the server-rendered layout event in checkout_onepage_success.xml, and once from the session event that reaches the page through the gtm-checkout customerData section (Observer/TriggerPurchaseDataLayerEventCustomerData/GtmCheckoutgeneric.js). Both payloads hold the same values, but the layout path builds ecommerce with items first, because Tag\Order\Order is a MergeTag that TagParser unsets and merges back. JSON.stringify preserves insertion order, so the hash that was supposed to catch duplicates differed and the second push went straight through. Hyvä only reads the cart and customer sections, never gtm-checkout, which is why it was the one shop measuring 1.0×.

That is what the key-sorted hash and the per-transaction guard fix, and they fix it before the dataLayer — so it lands for GA4, Meta, Ads and our own logs at once. Relying on any platform's dedup to absorb a double push that our own module emits would leave the defect in place.

On GA4 and transaction_id: it did not suffice here. From the hit-level counts, GA4 recorded 354 purchases against 305 real orders (+16%) with transaction_id present on every hit. Google's own wording is "minimize duplicate key events", not eliminate: the documented behaviour is web-stream only, operates per user, and states no time window or guarantee. What GA4 dedups is the reported purchase metric — the events still arrive twice, so anything built on the raw event (Ads conversion import, audiences) still sees both. On the affected shop the duplicates also went to two measurement IDs simultaneously, which no amount of transaction_id dedup collapses.

On Meta and order_id: the browser↔CAPI dedup key is event_id + event_name, and only for events received within 48 hours of the first event carrying that event_id (fbp/external_id is the documented fallback). order_id does exist as a custom_data parameter, but it is documented only as "the order ID for this transaction as a string" — no dedup role is given for it. So event_id is the correct key, which is what this PR adds: one deterministic value (purchase.<increment_id>, from Util/EventIdGenerator) on the browser event and on the order_created webhook, so both sides cannot drift.

References:

No code change follows from this, so I have not pushed anything. Two things do still sit outside this repo before the Meta half pays off: event_id has to be mapped onto the Event ID field of the Meta tag in GTM, and the /order_created consumer has to forward the new event_id to the CAPI call instead of generating one server-side.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants