Step 0: The Prerequisite Check
Two questions, and both have to be yes.
Do you own a page the click lands on? A landing page, a prelander, a review page, a bridge page, anything served from a domain you control where you can run one line of JavaScript. If your links go straight from the ad to the advertiser's offer with nothing of yours in between, stop. There is nowhere to capture identity and nothing to join the sale to later. Put a page in the middle first.
Do you get a conversion signal back? A postback URL, an IPN, a webhook, a callback with your subid in it. Something arriving at a server, not an email digest and not a number you read off a network dashboard on Tuesday. If your network offers no postback URL, you cannot do this and no amount of container hosting fixes it.
Answer no to either and the honest advice is to fix that first. A network that will not postback is a network you cannot measure, and switching offers is cheaper than building around the gap.
Nothing you control runs while the sale is happening, so the purchase event has to be assembled afterwards from two records you stored yourself.
Step 1: Decide Where the Collection Point Lives
Three routes, and the honest costs.
A GTM server container you provision through Tag Manager. Google's automatic flow creates a GCP project and deploys the container on Cloud Run. Read Google's own wording on that default before you trust it with real traffic: the deployment restricts available resources and is recommended for testing limited traffic volumes only. For live traffic Google recommends a minimum of three instances per container for redundancy, and says that once you upgrade the Cloud Run environment you can expect $30 to $50 per server per month, more with heavy network traffic. So the real number for a redundant setup is a multiple of that, not the free tier everyone quotes.
Your own container on your own infrastructure. Google supports manual provisioning on non-Google platforms. You then own DNS, TLS renewal on the subdomain, image updates, and monitoring. That last one is the item people forget. A tag server that quietly stops answering does not page anyone. It just returns nothing, and your measurement goes to zero while your dashboards keep showing yesterday's shape until you happen to look.
A tracker that does the collection natively. No container, no image, no Cloud Run bill, and the ownership question moves to whoever runs it.
What breaks if you skip it: nothing immediately, which is the trap. You pick the free default, traffic grows, the single instance starts shedding requests under load, and you lose events without an error anywhere. Decide the hosting on the traffic you expect in six months.
Step 2: Make the Subdomain Actually Yours
The tagging server comes up on a run.app URL. Google's guidance is to map a subdomain of your own site to it, because that is what lets the server read and write cookies that page scripts cannot touch.
Here is the trap that cancels most of the benefit, and almost no GA4 guide mentions it.
WebKit runs a defense against CNAME cloaking. If your tracking subdomain resolves through a CNAME that points at a domain other than your first party, Safari caps the expiry of any cookie set in that HTTP response at seven days. Server-set or not. You have paid for infrastructure and landed back at the seven-day ceiling you were trying to escape.
Check it before you build on it. Resolve your tracking hostname and look at what comes back. If the chain ends in a CNAME to a vendor domain, you have the problem. If it resolves to an A or AAAA record, you probably do not, with a caveat: WebKit named an extension of the defense to A and AAAA records but has never published the matching rule, and the IP-range comparison practitioners describe is reported behaviour rather than documented behaviour. Do not take my word or theirs for it. Land a real Safari device on the page, set a cookie, come back a week later, and see what survived.
What breaks if you skip it: the cookie lifetime you built the whole thing for. Everything downstream still works, so you will not notice, and your Safari attribution will keep collapsing exactly as before.
Step 3: Capture the client_id
This is the step every affiliate guide skips, and the one the entire build rests on.
GA4 identifies a browser by client_id. Your server-side purchase event has to carry that same value or GA4 has no way to know it belongs to the visitor who clicked your ad. Invent one, hash something, use your own click id in that field, and the event still lands, still shows revenue, and shows it against a user GA4 has never seen before, arriving from nowhere. The report looks populated and means nothing. I have watched people chase that for weeks.
The documented way to read it is the Google tag's get command. Field names supported for a GA4 target are client_id, session_id and session_number. Google's own example on that reference page is literally headed "Send event to the Measurement Protocol", which tells you what this API is for.
<script>
const GA_ID = 'G-XXXXXXXXXX';
gtag('get', GA_ID, 'client_id', (clientId) => {
gtag('get', GA_ID, 'session_id', (sessionId) => {
const p = new URLSearchParams(location.search);
fetch('https://data.yoursite.com/click', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({
cv_click_id: p.get('cv_click_id') || crypto.randomUUID(),
ga_client_id: clientId,
ga_session_id: sessionId,
gclid: p.get('gclid'),
gbraid: p.get('gbraid'),
wbraid: p.get('wbraid'),
fbclid: p.get('fbclid'),
ttclid: p.get('ttclid'),
landing_url: location.href
})
});
});
});
</script>
Both get calls are asynchronous and the value only exists inside the callback, which is why they are nested rather than read inline.
You will see a cookie-parsing version of this everywhere:
// Unofficial. Google documents no layout for the _ga cookie.
function clientIdFromCookie() {
const m = document.cookie.match(/(?:^|;\s*)_ga=GA\d\.\d\.(\d+\.\d+)/);
return m ? m[1] : null;
}
It works today. It is not documented by Google anywhere I could find, so treat it strictly as a fallback for when the tag has not finished loading, and keep gtag('get', ...) as your primary path.
What breaks if you skip it: every purchase you send lands as a brand new user attributed to Direct. Your acquisition report becomes a list of sales with no campaign attached, which is a worse position than not sending them at all, because now the numbers look complete.
Step 4: Persist the Click Identifiers Next to It
Same request, same record: gclid, gbraid and wbraid for Google, fbclid, ttclid, plus your own click id. That last one is the key everything else hangs off, because it is the only identifier that will make the round trip through the network and come back to you. Details on the click id family if you want the differences between gbraid and wbraid.
Two rules. Store the click id server-side against the record, not only in a cookie, since the cookie will not exist when the postback arrives days later. And pass it to the network as the subid on the outbound redirect, because a click id the network never receives is a click id that never comes back.
https://network.example.com/offer/4471?aff_sub=8c1f4a2e
What breaks if you skip it: the GA4 half works and the platform half does not. You will have GA4 purchases you cannot push back to Google Ads as conversions, because the gclid was never stored.
Step 5: Catch the Conversion Server-Side
The network fires a postback to your endpoint with your subid in it. Your job is to look up the click record and refuse to guess when you cannot find one.
app.get('/postback/clickbank', async (req, res) => {
const click = await clicks.findOne({ cv_click_id: req.query.cid });
if (!click) {
await unmatched.insert({ ...req.query, received_at: new Date() });
return res.sendStatus(200); // ack it, then go read the queue
}
await sendGa4Purchase(click, {
transaction_id: req.query.txn,
value: Number(req.query.amount),
currency: req.query.currency || 'USD'
});
res.sendStatus(200);
});
Return the 200 fast, whatever happens. Networks retry, and a slow endpoint turns one sale into four postbacks. Deduplicate on transaction_id before you send anything to GA4.
The unmatched table is not optional and it is the most useful diagnostic you will build. If it is filling up, your subid is not surviving the redirect chain, and that is a link problem rather than a tracking problem.
What breaks if you skip it: you send a purchase with no user context at all, which shows up in GA4 as revenue with no source. That is the third failure mode in the diagram below.
Step 6: Send the Purchase with the Measurement Protocol
Verified against Google's current documentation, since a wrong parameter name here costs you a week.
The endpoint is POST https://www.google-analytics.com/mp/collect, with region1.google-analytics.com as the EU collection host. Only POST is supported. Two query parameters are required for a web stream: measurement_id and api_secret. The api_secret is generated under Admin, Data collection and modification, Data streams, your stream, Measurement Protocol API secrets. Keep it on the server. Anyone holding it can write arbitrary events into your property.
In the body, client_id identifies the browser and events[] carries the payload. session_id and timestamp_micros go inside the event's params.
{
"client_id": "1953454283.1717426142",
"timestamp_micros": 1755782400000000,
"consent": {
"ad_user_data": "GRANTED",
"ad_personalization": "GRANTED"
},
"events": [
{
"name": "purchase",
"params": {
"session_id": "1717426142",
"engagement_time_msec": 1,
"transaction_id": "CB-9F2K71",
"currency": "USD",
"value": 97.00,
"affiliation": "clickbank"
}
}
]
}
curl -s -X POST \
'https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=YOUR_API_SECRET' \
-H 'Content-Type: application/json' \
--data @purchase.json
The timing rules are where affiliates get caught, because our conversions are slow and the windows are short.
For the purchase to inherit the source, medium and campaign of the original session, Google requires session_id in the event params, the request sent no later than 24 hours after the start of the online session, and, if you override timestamp_micros, a value between session start and session end. Twenty-four hours. A network that batches postbacks overnight can miss that on its own.
Google's separate caveat is worth reading twice: Measurement Protocol events intended to be joined with events collected by gtag.js should be received within 48 hours of the original client-side event timestamp, and events received later may not be processed as expected, particularly for conversion attribution.
Two longer windows exist for different jobs. Export to linked advertising platforms does not need session_id and allows 63 days after the latest online event, with timestamp_micros inside the last 72 hours. Audience creation on a web stream allows 30 days after the latest online event for the same client_id. So a sale that lands on day 5 can still reach Google Ads and still build an audience. It just will not carry the original session's campaign attribution. Know which of those three outcomes you are buying.
The rest of the documented limits: 25 events per request, 25 parameters per event, event names 40 characters or fewer using alphanumerics and underscores and starting with a letter, parameter names 40 characters or fewer, parameter values 100 characters or fewer on a standard property, body under 130kB, backdating 72 hours maximum. On the default RELAXED validation behaviour an older timestamp is accepted and rewritten to 72 hours ago rather than rejected, which is a silent data problem if you are backfilling.
One field I am not going to state confidently. engagement_time_msec is documented as the milliseconds elapsed since the preceding event, and a server-side postback has no preceding event to measure from. Google requires a positive number for DebugView and recommends the parameter for engagement metrics, so a small synthetic value is what people send. I could not find a documented value for the server-side case, so treat mine as a placeholder rather than a spec.
What breaks if you skip it: with no session_id, the purchase becomes an orphan event on the right user in the wrong session.
Three separate joins have to hold, and every one of them fails quietly with revenue still visible in the report.
Step 7: Verify It, Two Screens
The validation server first. Swap /mp/collect for /debug/mp/collect and send the identical request. You get back a validationMessages array with a field path, a description and a code.
curl -s -X POST \
'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=YOUR_API_SECRET' \
-H 'Content-Type: application/json' \
--data @purchase.json
# {"validationMessages":[]}
Two limits Google states plainly. The validation server does not check your api_secret or your stream ID, so a perfectly clean response tells you nothing about whether your credentials are right. And events sent to the debug endpoint never appear in reports, so it is a syntax check, not a delivery check.
Then Realtime, then DebugView. Send the real event to the production endpoint and watch Realtime, where events typically appear within a few seconds. For detail, add "debug_mode": true and a positive engagement_time_msec to the event params, still on the production endpoint, and open DebugView.
The failure signature to hunt for is specific, because it looks like success. Events arriving, revenue showing, and the user attributed to Direct or counted as a new user. That is not a Measurement Protocol problem. That is your client_id join failing, and the fix lives back in Step 3.
Step 8: What This Does Not Fix
Bluntly, because plenty of people will sell you the opposite.
GA4 is not your system of record. It samples, it thresholds, it models, and it will not reconcile a refund three weeks later on its own. Your database of clicks and postbacks is the ledger. GA4 is a reporting surface you feed. Treat it the other way round and you will spend your life explaining variances that do not matter.
Consent obligations attach to the processing, not to where the code runs. Sending a purchase over the Measurement Protocol for a visitor who declined analytics storage is the same violation it was in the browser. Google's payload has a consent object with ad_user_data and ad_personalization for exactly this reason, and you are the party doing the processing now.
And the Measurement Protocol cannot manufacture identity nobody ever collected. If you never captured a client_id on the landing page, no server-side call invents one. That is the whole point of the identity handoff gap, and it is also why the affiliate version of this build is 80 percent click capture and 20 percent API call. The API call is the easy half.
One planning note while you are here. Google's Measurement Protocol pages now carry a standing banner: the protocol is a finalized product with no deprecation planned, but Google recommends the Data Manager API for new server-to-server event integrations. That is not a reason to delay. It is a reason to put your GA4 send behind one function you can swap.
If you would rather not run a container, a click store and a Measurement Protocol client yourself, this is the part I built into a tracker: the click record, the postback receiver and the platform sends behind one collection point, with native IPN receivers for ClickBank, JVZoo and WarriorPlus. Either way, the rules above hold. Capture the client_id at click time, keep the click id round-tripping, and send inside the window.
FAQ
Can I do server-side GA4 without owning the checkout?
Yes, and it is a different build from the standard guide. You collect client_id, session_id and the click identifiers on your own landing page, receive the conversion as a network postback on your server, then send the purchase event to GA4 over the Measurement Protocol with that stored client_id. No tag ever fires on the advertiser's page, because none can.
Why do my Measurement Protocol purchases show as Direct in GA4?
Almost always the client_id. If the value you send does not match the one the Google tag generated in the visitor's browser, GA4 treats the event as a new user with no prior history, and a new user with no referrer is Direct. Read it with gtag('get', 'G-XXXXXXXXXX', 'client_id', callback) on the landing page and store it against your click record.
How long do I have to send a conversion to GA4?
It depends what you want from it. For the event to inherit the original session's source, medium and campaign, send within 24 hours of the session start with session_id in the params. For export to linked advertising platforms, 63 days after the latest online event. For audience creation on a web stream, 30 days. Google also states that events meant to be joined with gtag.js-collected events should arrive within 48 hours of the original client-side timestamp.
Do I need a GTM server container for this?
No. The Measurement Protocol is a plain HTTPS POST from any server you control. A container is useful when you want one collection point fanning out to Meta, Google and TikTok as well, and it brings a real bill: Google recommends three Cloud Run instances for redundancy at $30 to $50 per server per month once you leave the default deployment.
What breaks if I skip session_id?
The purchase still lands and still counts revenue, but it is not tied to the online session, so it does not inherit that session's campaign attribution. You end up with the right user, the right money, and no idea which ad produced it.
