> For the complete documentation index, see [llms.txt](https://docs.adpage.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.adpage.io/optimize-your-setup/webhook-matching.md).

# Webhook Matching

Ensure AdPage's webhook functionality always sends purchase events towards GA4 and marketing platforms with the correct consent signals and marketing data

### What does Webhook Matching do?

Purchase events that fire in the browser are fragile. Ad blockers can stop them, cookie restrictions can strip them, and Safari's ITP can cut the data short before it ever reaches you. Server-side tagging fixes these issues, but there are still three scenarios where the browser-side purchase tracking won't work.

1. Visitors who never come back to your thank-you page. They pay in their banking app, see the confirmation there, and close it. Job done in their eyes. If they don't land on your thank-you page, the browser event never fires.
2. Visitors who do land on the thank-you page but leave too quickly. Your tracking scripts need a moment to load and send. Close the tab before that and the event tracking dies with it.
3. Someone starts their journey in an in-app browser, maybe inside an LLM app or a social app, then gets sent off to their payment app. On the way back, the payment app hands them over to whatever browser their phone has set as default. Now they're in a totally different browser with a different session and no cookies from the original visit, so even a perfectly loaded thank-you page reports it as something other than what it is.

Backend webhooks from your shop platform (Shopify, WooCommerce, Magento) don't have that problem because they get sent straight from the server. The trade-off is that they can show up without any browser context, so no `gclid`, no user-agent, and no items array. AdPage's Webhook Matching links the browser data and the webhook data together using a shared match identifier, merges the payloads, and forwards a single validated event downstream. The result: no duplicate conversions, and no missed ones either.

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2F3TFREPT1hh29haoO8acq%2FWebhook%20Notifier%20(7).png?alt=media&amp;token=25d9be4a-5492-427d-bb88-e12ccd8b21d8" alt=""><figcaption></figcaption></figure>

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2F4kSU4XBsHmHgBawhABCC%2FWebhook%20Notifier%20(6).png?alt=media&amp;token=3ae3419f-fc23-4bfc-95ac-9c9ccfb3c86e" alt=""><figcaption></figcaption></figure>

### Setting up Webhook Matching

There are two steps to setting up Webhook Matching.

**Step 1** is processing the incoming webhook (Trigger) and a begin\_checkout event (Prepare) to ensure that those can be matched.

After the Matching rate is at a high enough percentage (>90%) these matched webhooks can be sent towards Google Analytics and your marketingplatforms, setting that up is **Step 2**.

{% stepper %}
{% step %}

#### Step 1: Setting up the Webhook Matching Rate

1. Open the server container in Google Tag Manager
2. Go to Templates → Tag Templates → New
3. Click the three dots in the top right → Import → select `adpage-event-notifier.tpl`. You can download it here: <https://adpage.b-cdn.net/GTM-Templates/adpage-event-notifier.tpl>
4. Click Save. The "AdPage Event Notifier" template now appears in the templates list
5. Create two tags using this template:

**Tag 1 — Prepare (event received from client-side):**

* Mode: `Prepare`
* Container ID: found in the URL of your AdPage server container
* Platform preset: choose whichever applies (see below)
* Event name: `purchase`
* Trigger: a browser event just before completing the checkout (e.g. `begin_checkout`)

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2FzDelZYkvyynWS7DIIjGV%2Fimage.png?alt=media&amp;token=deb4f40f-4270-4d46-b453-700276884be6" alt=""><figcaption></figcaption></figure>

**Tag 2 — Trigger (server-side webhook):**

* Mode: `Trigger`
* Container ID: found in the URL of your AdPage server container
* Platform preset: same as above (see below)
* Transaction ID: an Event Data variable with `transaction_id` as the Key Path
* Trigger: the webhook event from your commerce platform (the webhook that gets processed by [the AdPage Webhook Client](/extensions/google-tag-manager-templates/adpage-webhook-client.md))

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2FrYzzeYqJgg3oxVBb3xk0%2Fimage.png?alt=media&amp;token=b8f963c1-217b-484a-9441-7c209a62a95f" alt=""><figcaption></figcaption></figure>

**Platform Preset**

The platform preset option is the BasketKey you want to use to link the Prepare and Trigger sides together, both must send the same value, or they won't match.

| Setup                                                 | Preset                       | BasketKey                                        |
| ----------------------------------------------------- | ---------------------------- | ------------------------------------------------ |
| Client has the AdPage Tagging plugin/script installed | AdPage Tagging (universal) ⭐ | `user_id` (from the `trytagging_user_id` cookie) |
| Native Shopify (no Tagging plugin)                    | Shopify                      | `cart_token`                                     |
| Native Magento                                        | Magento                      | `quote_id`                                       |
| Native Lightspeed C-Series                            | Lightspeed                   | `quote_id`                                       |
| Native Shopware 6                                     | Shopware                     | `cart_token`                                     |
| Something else / custom integration                   | Custom                       | define your own                                  |

{% hint style="info" icon="webhook" %}
For around 95% of clients, **AdPage Tagging (universal)** is the right choice, it works across any platform because the AdPage tagging stack uses the same `user_id` UUID everywhere: in the browser (`trytagging_user_id` cookie) and in the webhook (`marketing.user_id`).
{% endhint %}

{% hint style="danger" %}
**Never forward the BasketKey `user_id` as GA4's own `user_id` parameter.**

The `user_id` used here is AdPage's own matching identifier — it only exists to link Prepare and Trigger together and has nothing to do with GA4's reserved, top-level `user_id` field from GA4's User-ID feature.

If this BasketKey value gets mapped into the outgoing Measurement Protocol payload as GA4's `user_id` (for example through a custom field override on the MP client/tag), GA4 starts treating it as a persistent, cross-session identity even though it isn't one. Under **Observed** or **Blended** Reporting Identity, GA4 gives User-ID priority over Client ID when deciding whose journey a hit belongs to. Because the forwarded `user_id` on a webhook-matched purchase usually doesn't carry the same `ga_session_id` as the visitor's browser session, GA4 can no longer recognise the webhook hit as the same order as the client-side hit — instead of being deduplicated on `transaction_id` within one identity, it gets counted as a second, separate purchase under a different "Effective User ID".

\
Only forward `user_id` to GA4 if it's a real, stable GA4 User-ID that the client already sets consistently on login (client-side, via `gtag('config', ...)`, per Google's own User-ID requirements) — never the BasketKey matching value.
{% endhint %}

Publish the Google Tag Manager server container with these 2 new tags. The incoming begin\_checkout events and webhooks will be automatically matched in your AdPage server container. Check back in a couple of days to check on the Webhook Matching Rate.
{% endstep %}

{% step %}

### Step 2: Validate the webhook matching rate

A couple of days after publishing step 1 you are able to check the webhook matching rate within your AdPage server container.

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2FFve4wkkcN3HQjIHI9tkO%2Fimage.png?alt=media&amp;token=89e74b41-c781-4b96-813c-500db2c648f5" alt=""><figcaption></figcaption></figure>

If the match rate is above 90% you can continue with Step 3 below.

If the match rate is below 90% you should alter the webhook matching configuration. Alter the configuration in the top right behind the **Configuration** button. Here you'll find the following configuration settings:

**Extra identifier (fallback)**\
Pick a field present in both prepare and trigger (`user_id`, `cart_token`). It's the only deterministic option, takes precedence over the others, and keeps the normal Matched status. Usually the biggest single gain.

**Transaction ID prefix**\
Only needed when the same order arrives via two sources with different ID formats. Fill in the prefix and tick the "ignore" checkbox so the bare duplicate is dropped. Leave empty if there's one source.

**Probabilistic matching**\
Off by default. Links missed orders to a recent prepare based on items, value, identity and timing. Status becomes Probable. Enable when no fallback field is available; be careful on shops with many near-identical low-value orders.

**Trigger-only**\
Off by default. Sends the order on the `_ga` client\_id alone, without a prepare. Recovers orders where the prepare never fired. Weaker attribution, but the conversion is preserved. Status: Trigger-only.

**Unconsented conversions**\
On by default, leave it on. Sends a cookieless, PII-free ping for denied-consent orders so GA4 can model them. Only switch off after checking the client's privacy policy.
{% endstep %}

{% step %}

#### Step 3: Utilizing the matched Webhooks

To be able to utilize the matched webhooks, you'll have to create a GA4 Measurement Protocol client. The Webhook Matching callback arrives on your sGTM `/mp` endpoint. A client needs to be in place there to claim the incoming Measurement Protocol request and convert it into an event that your downstream tags (GA4, Google Ads, Meta CAPI, etc.) can pick up.

1. Create the client: In your server container, go to **Clients → New**, choose client type **Measurement Protocol (GA4)**, and give it a recognizable name such as "AdPage MP" Set **Path / Activation** to your callback URL, `/mp`.

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2Ftn1AU94XTkykQUEgCtcT%2Fimage.png?alt=media&amp;token=c1d683c7-8868-4e34-84b5-9b56de6ab9f4" alt=""><figcaption></figcaption></figure>

2. Block the purchase events in your regular GA4 trigger: add the condition to your regular GA4 trigger that 'Event Name' does not equal 'purchase'.

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2FsMHv1jOOQqyXpbhan7El%2Fimage.png?alt=media&amp;token=23ed5e72-c417-4b6f-8266-3c8feeefdb81" alt=""><figcaption></figcaption></figure>

3. Add a new Measurement Protocol Client trigger: create a new trigger that fires on all incoming requests in your Measurement Protocol Client. After creating this trigger, add it to your Google Analytics 4 tag.

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2FVALr883pzWJhvOyvBrU9%2Fimage.png?alt=media&amp;token=b8fd9b75-f6c1-402c-bbaa-325aedccff6e" alt=""><figcaption></figcaption></figure>

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2FinkOI4Qvfvni7WVFgrqu%2Fimage.png?alt=media&amp;token=f3eb56ba-90e4-4bce-acfb-fe9efe0b6891" alt=""><figcaption></figcaption></figure>
{% endstep %}
{% endstepper %}

### Testing and debugging

Before publishing the changes made for **Step 2**, confirm that Prepare and Trigger are actually matching.

{% stepper %}
{% step %}

#### Use the Webhook Replay functionality to test the matched webhooks

To test the matched webhook without placing a new order,

1. Go to your AdPage container's **Webhook Logs**
2. Find a Matched Webhook and open it by clicking on it
3. Find the **Replay** button at the bottom

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2FdzhKmFGiC8jw81vO7GoI%2Fimage.png?alt=media&amp;token=699f0716-6b8c-4ffc-b5ad-1501b630bd4e" alt=""><figcaption></figcaption></figure>

4. Go to your Google Tag Manager server container and start the preview mode
5. Click on the 3 dots in the top right and select the option **Send requests manually**, copy the X-Gtm-Server-Preview HTTP header.
6. Paste the X-Gtm-Server-Preview HTTP header in the Matched Webhook replay input field
7. The webhook replays in the Google Tag Manager preview mode so you can see exactly which tags fire and what the outgoing payloads of these tags are.

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2FAd2oSHV7tCPYiDAD5GRN%2Fimage.png?alt=media&amp;token=7117972d-a068-4e10-a63b-7b4827331743" alt=""><figcaption></figcaption></figure>
{% endstep %}
{% endstepper %}

### Reading the Webhook Matching dashboard

Under **Webhook Matching → Match health**, you can see at a glance how well Prepare and Trigger have matched over the selected period (7 days by default), and where things are going wrong.

The percentage at the top is the share of orders processed successfully — matched, handled via in-app browsers, or cookieless — against the total number of incoming orders.

* **Matched** — Prepare and Trigger were linked on the Basket ID. This is the healthy path.
* **In-app browsers** — orders from in-app browsers (Instagram/Facebook app, etc.) where cookies work in a limited way.
* **Cookieless** — orders with no trackable cookie (see "Sending unconsented conversions" under Advanced options below).
* **Not reached** — orders that weren't matched. Hover over this segment for a breakdown by cause: **stale cookies** (the Prepare cookie had already expired by the time the Trigger arrived — too much time between checkout start and order confirmation), or **other/unclear** (a catch-all, usually incidental).

Below the bar you'll see the number of orders without consent — orders that are (partly) not tracked in GA4 because the visitor declined tracking.

Use **Analyze missed** to run an analysis on "not reached" orders and see whether probabilistic matching or trigger-only forwarding (see Advanced options) could have recovered them.

* Click **Details** on any row under Recent events to inspect an individual event: status, source (transaction ID), and callback time appear at the top.
* The **payload check** flags data mismatches, such as "value ≠ sum of items" when the order total doesn't match the sum of line items within tax/shipping tolerance — this is a warning, not a block; the event still goes through, but it's worth checking whether the client's integration is sending the right fields.
* **Tracking consent** shows whether consent was granted or denied, along with the underlying Consent Mode flags (`analytics_storage`, `ad_storage`, `gcs`).
* **Browser** shows the user-agent, useful for spotting patterns (e.g. a lot of "not reached" events from one browser/OS combination).
* **Prepare buffered** and **Trigger processed** show the exact timestamps of both events — the gap between them matters for stale-cookie issues. Further down you'll find four tabs — outgoing payload, Prepare data, order data, and raw Trigger body — useful for seeing exactly what came in before processing, plus options to copy the payload or replay the event.

<figure><img src="https://1274044937-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FA65KwL1NwewlJbtCXsXd%2Fuploads%2FRAu5x6WmZPeQMJjga7pu%2Fimage.png?alt=media&amp;token=ec3702df-3e82-44df-a399-922b175df4ea" alt=""><figcaption></figcaption></figure>

### What's in the outgoing payload

The Event Notifier's callback to sGTM carries a complete payload, ready to use across all major ad platforms. The template automatically forwards:

**GA4 (Measurement Protocol)**

```
event_name:           "purchase"
client_id:            (from browser or webhook marketing.ga4_client_id)
transaction_id:       (from order)
value, tax, shipping: (from order)
currency:             EUR / USD / more.
items[]:              (complete array with item_id, item_name, price, quantity, item_brand, item_category, etc.)
engagement_time_msec: 100 (default)
page_location:        real thank-you URL (Shopify Web Pixel sandbox-URLs are automatisch cleaned)
gcs, gcd:             Consent state
ip_override:          server-side geocoding
```

**Meta CAPI (Facebook + Instagram)**

{% hint style="warning" %}

### How deduplication works

Meta deduplicates the browser Pixel event and the server-side CAPI event for the same purchase when both have the **same** `event_id` and **same** `event_name` (`Purchase`). Meta keeps one event within a \~48-hour window and ignores the other.

The `event_id` is therefore the key. It must meet two requirements:

1. **Identical** in the browser Pixel and CAPI event for the same purchase.
2. **Unique per purchase** — otherwise, separate orders may be incorrectly merged.

The only value that meets both requirements is the `transaction_id` **(order ID)**. Use:

> ⚠️ Do **not** use `user_id` as the `event_id`. It remains stable per customer, not per order. A second purchase by the same customer within 48 hours would be incorrectly discarded as a duplicate. Use `user_id` as the BasketKey and `external_id`, not as the deduplication key.

> ⚠️ The `event_id` must be **exactly the same string** in both events. Watch for prefixes or formatting differences. If the browser sends `12345` and the server sends `order_12345`, Meta sees two separate events and does not deduplicate them.

> 💡 Ensure the CAPI `event_id` comes from the **Trigger/order side** (`transaction_id`). Do not use a random `event_id` generated during the Prepare/`begin_checkout` step. It never matches the browser `Purchase` event.
> {% endhint %}

|              | **Source**                                                       | `event_id`                    |
| ------------ | ---------------------------------------------------------------- | ----------------------------- |
| Browser-side | Meta Pixel `Purchase` thankyou page                              | `transaction_id`              |
| Server-side  | AdPage - Meta Conversion API on  the Measurement Protocol Client | `transaction_id` from payload |

```
bp:                  _fbp cookie (Facebook browser ID)
fbc:                  _fbc cookie (Facebook click ID)
em:                   email (SHA256 via FB CAPI tag)
fn, ln, ph:           first_name, last_name, phone (SHA256 also)
zp, ct, st, country:  zip, city, state, country
external_id:          customer.id (cross-order stable)
client_ip_address:    IP for IP-matching
client_user_agent:    UA for browser-fingerprinting
event_id:             for browser-pixel deduplication
```

**Google Ads (conversion + remarketing)**

```
_gcl_aw, _gcl_dc, _gcl_gb:  gclid cookies (set by Conversion Linker)
FPGCLAW, FPGCLDC:            first-party gclid variants
gclid, gbraid, wbraid:        URL-parameters (iOS app campaigns)
client_id, ga_session_id:     for stiching with page_view events
value, currency:              conversion value
```

**TikTok Events API**

```
_ttp, ttp:            TikTok pixel cookie
ttclid:               TikTok click ID (from URL ?ttclid=)
email, phone_number:  (TikTok uses complete fields instead of FB's shorts)
first_name, last_name, city, state, zip_code, country_code: idem
external_id:          customer.id
ip, user_agent:       for matching
```

**Pinterest Conversions API**

```
_epik, epik:          Pinterest click ID cookie + URL param
em, fn, ln, ph, zp:   (same conversion as FB CAPI)
ct, st, country:      adres-velden
external_id:          customer.id
client_ip_address:    IP
client_user_agent:    UA
```

**Consent, across every platform**

```
ad_storage, ad_user_data, ad_personalization, analytics_storage: "granted"/"denied"
consent.ad_storage, consent.ad_user_data, …: aslo (nested object)
gcs, gcd: Google Consent strings (forGA4 / Google Ads)
```

### Troubleshooting

* **"Match identifier value could not be resolved"** — The BasketKey wasn't found in the event data. Check the error log in the sGTM console, find where the UUID actually lives in the EventDataSnapshot, and set that path via **Identifier value override** in the tag config.<br>
* **Trigger returns a 308 redirect** — The URL ends with `/trigger/` and no value. Same root cause as above — recheck BasketKey resolution.<br>
* **Trigger returns a 400** — Body validation failed, usually because `transaction_id` or `value` is empty. With the AdPage Tagging plugin (WooCommerce/PrestaShop) this lives at `ecommerce.transaction_id` — confirm the webhook is sending it.<br>
* **`missed` status in Event Notifier logs** — A Trigger arrived with no matching Prepare. The browser-side Prepare likely failed (ad blocker, ITP, or no browser event right before checkout) — check sGTM preview on the storefront.<br>
* **`callback_failed`** — sGTM isn't receiving the Event Notifier's callback. Check the callback URL in AdPage's tenant config — usually a missing `/data` suffix or the wrong subdomain.

### Advanced options

* **Multi-market setups** (Shopify Markets or multi-locale shops) — enter all GA4 property IDs comma-separated in the GA4 Measurement IDs field (e.g. `G-NL123, G-BE456, G-DE789`). Event Notifier then forwards the matching `_ga_<MID>` cookie per property so sGTM can route downstream.
* **WooCommerce without the Tagging plugin** — enter the exact `wp_woocommerce_session_<hash>` cookie name in the WooCommerce session cookie field.
* **Custom event identifiers** — choose the Custom preset and define your own identifier name and variable reference.
* **Duplicate-identifier prevention** — if an order arrives with both a canonical identifier (e.g. `vtnl-...`) and a bare variant (e.g. just the order number), the bare variant is ignored and only the canonical one proceeds.
* **Probabilistic matching** — attempts to link an otherwise-missed order to a recent Prepare event based on items, amount, customer identity, and timing. When confidence is high enough, the event is sent to GA4 with status **Probable** instead of **Matched**. Off by default — only enable this if you trust it for a given client.
* **Trigger-only forwarding** — sends an otherwise-missed order (no Prepare, not even via probabilistic matching) to GA4 anyway, using the GA4 `client_id` from the Trigger's own `ga` cookie. Only works if that client ID is present; otherwise the order stays missed. Status becomes **Trigger-only**. Off by default.
* **Sending unconsented conversions** — forwards an order where tracking consent was declined (so no GA4 client ID) as a privacy-safe, cookieless ping: a disposable client ID with "denied" consent flags and no PII, so GA4 can still model the conversion. Status becomes **Unconsented**. Off by default — only enable this after discussing consent implications with the client, since it forwards data even without tracking consent.

### Need help?

If a client's setup doesn't fit anything covered here, ask our support at <support@adpage.io> with the container ID and a screenshot of the sGTM console log.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.adpage.io/optimize-your-setup/webhook-matching.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
