Dilshat Rakhimov
July 2026 · 12 min read
Here's a spot most performance projects land in eventually. The site is a single-page app, the front end belongs to the client's team or a vendor, and you still have to measure the application funnel, qualify the leads, and push clean conversions into Google Ads, Meta, and Yandex. You can't touch the source. The dataLayer is empty or half-filled. And the "form submit" event on its own tells you nothing useful - because for a B2B product, what matters isn't that a form was submitted, it's that the right company submitted it: a legal entity or sole proprietor that fits, not some individual who tapped the button by accident.
The textbook answer is the one in the spec: "call a public API - a state registry, say - through a backend proxy and enrich the lead." It works, but it wants a backend, API keys, and sign-offs. There's a shorter path once you notice one thing: the site already calls those endpoints itself. It's checking the business or national ID against its own govtech calls to show the user a result. So the data is already moving through the browser - you just have to grab it on the way past.
This is a look at the fetch/XHR interceptor pattern, living entirely inside GTM: how to intercept a SPA's own API calls, qualify leads off them, and build value-based bidding out of what you intercept - and why the pattern is brittle, which one real incident made very clear.

Why plain GTM can't do this
GTM is good at clicks, form submits, and a SPA's history navigation. What it can't see, out of the box, is the response bodies of internal API calls - and that's exactly where the answer to "is this lead qualified?" lives. The site asks a state registry for a company's status, legal form, and industry code, then decides what to show based on the reply. For marketing, that same reply is the raw material for qualification and lead value. But it never shows up in the DOM, and nobody pushes it into the dataLayer, because the front end wasn't built with analytics in mind.
You could just fire the govtech request again yourself from GTM. But that's another external call, more rate limits, and a real chance of drifting out of sync with what the site actually saw. The site already made the call and got the answer - so read that answer instead of making a second one.
How it works: monkey-patching fetch and XMLHttpRequest
The interceptor is a Custom HTML tag in GTM that, on the DOM Ready trigger, swaps out window.fetch and the XMLHttpRequest.prototype methods for its own wrappers. Each wrapper calls the original and, on the way through, checks the URL. If it's an endpoint we care about, it clones the response - response.clone() for fetch, a hook on onreadystatechange/load for XHR - and pulls out the fields it needs.
There are usually three calls worth intercepting:
| Endpoint (generalized) | Purpose |
|---|---|
/govtech/entity-check (param iinBin) | check sole proprietor / individual |
/govtech/org-info (param bin) | legal-entity data from the state registry |
/gateway/submit | the application submit itself |
The flow:
The qualification call itself is a couple of lines in the tag, straight off the intercepted response:
The 2000 ms timeout on collecting both responses isn't optional - it's the guard against a race. entity-check and org-info come back asynchronously and in whatever order they feel like, and you can't decide qualified until both have landed or the clock runs out.
Build the dedup in on day one
Even if Meta and Google are only running a client-side pixel today, set eventID for Meta and orderId for Google Ads to the application's request_id from the very start. That makes the eventual move to server-side CAPI and Enhanced Conversions a non-event: a server hit carrying the same request_id dedups against the browser one on its own. It costs next to nothing to wire up now and saves a rebuild later.
From "qualified?" to value-based bidding
Here's the part people leave on the table. The intercepted org-info response holds far more than a yes/no on qualification. The tag usually reads three fields from it (regStatus.code, orgForm.code, status.code), while what's sitting right there includes:
| Field | What it buys you |
|---|---|
organization size (orgSize) | the main lead-value signal → value-based bidding |
industry (oked, primary activity) | audiences, lookalikes, routing to the right product |
| address (region, city, KATO code) | geo bid adjustments and geo reporting |
| registration date | company age → screen out leads that don't fit before they convert |
| registration / activity status | drop liquidated and dormant entities |
That's the jump from "lead / not a lead" to a graded value. Compute lead_value from company size, enrich the dataLayer with the business attributes, and send every channel a real number instead of value: 0:
Now Google Ads can move to Maximize Conversion Value / tROAS, Meta's Lead event carries a value, and Yandex gets order_price. Start the per-segment numbers (micro / small / medium / large) as estimates, then swap them for real expected margin after the first export of closed deals from the CRM. The whole point is that the interceptor hands you a value signal for free that you'd otherwise be pulling out of a backend.
The incident: passive capture broke without a sound
Now the honest part - the fragility. The interceptor is passive. It reads the calls the site chooses to make. That's its strength (no backend of your own) and its weakness in the same breath: you're tightly coupled to another team's front end.
On one project the client's developers reshipped that front end - pulled the govtech calls off the page and swapped the submit for a new chain (OTP plus a different gateway). Nothing technically "broke"; the site worked fine. But there was suddenly nothing for the interceptor to intercept. Qualification stopped firing, and conversions started going out with value: 0. The worst part was how quiet it was: no console error, no failed tag, just a slow slide in optimization quality that you only spot in the numbers weeks later.
What to bake in from the start so this doesn't blindside you:
- Watch the interception itself. Put an alert on the anomaly: if the share of leads with a non-empty
qualifiedsuddenly drops, the front end has almost certainly changed under you. - Have a path from passive to active. If the site stops calling the endpoint you rely on, let the tag make the call itself - the backend-proxy / direct-request approach from the original spec. Passive is more convenient; active survives front-end changes.
- Get an agreement with the front-end team. Any refactor of the submit or the govtech logic should come with a heads-up to analytics. The interceptor doesn't depend on you technically - but it absolutely depends on someone else's release process.
Three more things that are easy to miss
Gate every channel the same way. The classic slip: some tags - Meta and Yandex, say - re-read the last lead_iin_result on the submit trigger and only fire when qualified === true, while Google Ads and GA4 gate on nothing but form.status === success. The day the front-end gate for individuals fails, Google starts getting unqualified conversions and optimizing on garbage. The qualified === true check has to sit on every channel, identically.
A raw national or business ID in the dataLayer is PII. It's convenient to keep the intercepted ID around as a variable, but for an individual a national ID is personal data. It doesn't belong in the conversion tags, and it shouldn't sit raw in the dataLayer - if you need it for matching, hash it (SHA-256) first.
Don't forget consent. It's easy to leave intercepted tags on consentStatus: NOT_SET. For finance and other regulated verticals, wire up Consent Mode v2 from the start - at minimum in a ready-to-flip state.
When to reach for this, and when not to
The fetch/XHR interceptor is the right tool when two things are both true: you can't touch the front end, and the site already calls the endpoints that carry the data you want. In that corner, it's the fastest route to qualification and value-based bidding with no backend.
But if you own the code, don't build an interceptor on top of your own app. A plain dataLayer.push from the front end, or a server event from the backend, is more reliable and won't shatter on a refactor. The interceptor is a tactical move for someone else's surface, not a default you reach for. Its strength is that it gives you a measurable funnel where there'd otherwise be none; its price is a permanent coupling to a front end you don't control - and one you have to keep watching.
FAQ
Why can't plain GTM track a funnel inside a SPA?
There is no page load, form submit or DOM event that corresponds to the step you care about. The qualification signal lives in the API response the app receives, and GTM's built-in triggers never see it.
How does the fetch/XHR interceptor work?
It monkey-patches `fetch` and `XMLHttpRequest` from inside GTM, reads the response the app already receives, and pushes what it finds into the data layer. Build the dedup in on day one: set Meta's `eventID` and Google Ads' `orderId` to the application's own `request_id` from the start, so a later move to server-side CAPI does not double-count.
When should I not use this?
When a dataLayer push from the front-end team is one ticket away, when the payload contains anything sensitive, or when nobody will own the monitoring. Passive capture fails silently — a front-end change once broke the whole thing without producing a single error — so it needs a volume alarm on the intercepted stream.
