All posts
Measurement

Server-Side Tagging: When It Pays for a Lead Business

Server-side tagging keeps Safari click ids 7 days from a hosted subdomain and longer from your own origin. Estimate what each recovers from your CRM first.

October 2, 2026·11 min read·by Olexander Cheberko
Table of contentstap to expand

Server-side tagging pays a lead business when enough Safari and iPhone visitors book more than a day after the ad click, because for click ids a server buys one thing: a cookie Safari keeps longer than a page script's. A container on a hosted tagging subdomain keeps that cookie for 7 days, and only a cookie set from your site's own origin and address range keeps it past the first week.

Server-side tagging moves tag requests from the browser to a server you control, which forwards them to vendors.

How long does a click id survive in Safari?

Who sets the click-id cookieHow long Safari keeps itSource
Page script, landing from a domain Safari has classified, with a query string or fragment24 hoursWebKit, ITP 2.2 and 2.3
Page script, any other landing7 daysWebKit, ITP 2.1
A server whose IP differs from the site's in the first half of the address (/16 for IPv4, /64 for IPv6), or a subdomain cloaked to another provider7 daysWebKit PR #5347, WebKit CNAME defense
A server on the site's own origin and address rangeThe cookie's own expirySame sources: neither cap applies

Checked October 1, 2026. Safari's Intelligent Tracking Prevention (ITP) classifies domains on-device without a public list, so treat a paid-search landing as the first row.

Most hosted setups land in the third row: a tagging subdomain such as metrics.example.com at a host's address only moves the cliff from 24 hours to a week.

Link Tracking Protection strips known tracking parameters before the page loads, by default only in Mail, Messages and Private Browsing unless a user extends Advanced Tracking and Fingerprinting Protection to all browsing. No server restores an id that never arrived.

Estimate the expiry shortfall before you buy hosting

Only Safari and iPhone ids booked after the first day can die of a cookie clock, so the estimate borrows the other browsers' lag curve to say how many should exist and counts the missing.

  1. Rule out a broken page with the entry-path split of click id capture. If paths disagree, fix that and stop.
  2. Bucket browsers as WebKit (Safari and every iPhone browser) or other.
  3. Take click-to-booking lag from your own stored click time. Google Ads returns only a click date per gclid (click_view, last 90 days, one day per query) and nothing for gbraid or wbraid.
  4. Split the late ids at 7 days, where a hosted subdomain's cookie dies. For each late bucket (24 hours to 7 days, over 7 days): expected WebKit ids = WebKit same-day ids × (other ids in that bucket ÷ other same-day). Expected minus observed is that bucket's shortfall. A hosted 7-day cookie recovers the first bucket's, a same-origin cookie both.
  5. Stop under 10 a month: other browsers need 10 in each lag bucket or their ratios are guessing, and below 10 expected WebKit over-24-hour ids even a perfect cookie cannot pay.
  6. Split the browsers' capture-rate gap into the expiry slice (both buckets' shortfall ÷ WebKit bookings) and the remainder: ids gone before any cookie existed. Over 30 points, the top of the entry-path post's band for ordinary loss, the remainder hides a fixable leak: gbraid and wbraid unread, or a booking iframe keeping the id in a cross-site cookie, which Safari blocks by default. No server touches that part, so fix it first.

Weakest assumption: Safari and iPhone users book on everyone else's lag curve. If your iPhone traffic skews to impulse bookings, the estimate runs high.

-- Expiry shortfall: Safari and iPhone click ids a cookie clock removed before the booking.
-- browser_family: 'webkit' for Safari and every iPhone browser (all WebKit in the US).
-- click_at comes from your own click cookie, not from the ad platform.
with rows_90d as (
  select
    if(browser_family = 'webkit', 'webkit', 'other') as browser,
    -- an id with no click time cannot be bucketed by lag, so it counts as not captured
    coalesce(gclid, gbraid, wbraid) is not null and click_at is not null as captured,
    timestamp_diff(booked_at, click_at, hour) as lag_h
  from crm.web_bookings
  where booked_at >= timestamp_sub(current_timestamp(), interval 90 day)
),
counts as (
  select browser,
         count(*) as bookings,
         countif(captured and lag_h < 24) as same_day,
         countif(captured and lag_h >= 24 and lag_h < 168) as in_week,  -- a hosted 7-day cookie keeps these
         countif(captured and lag_h >= 168) as after_week               -- only a same-origin cookie keeps these
  from rows_90d
  group by browser
),
est as (
  select
    w.bookings as w_bookings, w.same_day + w.in_week + w.after_week as w_captured,
    w.in_week as w_in_week, w.after_week as w_after_week,
    o.bookings as o_bookings, o.same_day + o.in_week + o.after_week as o_captured,
    safe_divide(w.same_day * o.in_week, o.same_day) as w_in_week_expected,
    safe_divide(w.same_day * o.after_week, o.same_day) as w_after_week_expected
  from counts w
  cross join counts o
  where w.browser = 'webkit'
    and o.browser = 'other'
    and o.same_day >= 30 and o.in_week >= 30 and o.after_week >= 30  -- 10 a month per bucket, else no row back
),
shortfall as (
  select *,
    greatest(w_in_week_expected - w_in_week, 0) as short_in_week,
    greatest(w_in_week_expected - w_in_week, 0)
      + greatest(w_after_week_expected - w_after_week, 0) as short_all
  from est
)
select
  round(w_in_week_expected + w_after_week_expected, 1) as webkit_later_expected,
  (w_in_week_expected + w_after_week_expected) / 3 >= 10 as ceiling_clears_bar,  -- false: a perfect cookie cannot pay, stop
  round(short_in_week / 3, 1) as hosted_7d_per_month,   -- 7-day cookie from a hosted subdomain
  round(short_all / 3, 1) as same_origin_per_month,     -- full-expiry cookie from your own origin
  -- captured ids stand in for uploaded ids; use your upload log if the two differ
  round(100 * short_in_week / (w_captured + o_captured), 1) as hosted_pct_of_uploads,
  round(100 * short_all / (w_captured + o_captured), 1) as same_origin_pct_of_uploads,
  round(100 * (o_captured / o_bookings - w_captured / w_bookings), 1) as device_gap_pts,
  round(100 * short_all / w_bookings, 1) as expiry_slice_pts,
  round(100 * (o_captured / o_bookings - w_captured / w_bookings)
        - 100 * short_all / w_bookings, 1) as remainder_pts
from shortfall;

Most CRMs store no click time. Do not buy a server to find out: the free edge cookie below stores it, a hidden field passes it to the CRM with the user agent, and the query runs 90 days later.

My yes bar: 10 recoverable ids a month and a tenth of monthly uploads, a remainder of 30 points or less, and an upload running to carry the ids. Below it, I spend on capture first. Read hosted_7d_per_month for a container on a tagging subdomain and same_origin_per_month for a cookie from your own origin; the gap between them is the over-7-day bucket, the only case for same-origin over hosted.

Yes, behind a CDN that runs code. This Cloudflare Worker sets the click cookie from the site's own hostname.

// Runs on the site's own hostname (route: example.com/*), so the cookie comes from the
// same origin and IP as the page: neither Safari's script-cookie caps nor its third-party-IP
// rule applies.
// [URL parameter, click id type].
const CLICK_PARAMS = [
  ["gclid", "gclid"],
  ["gbraid", "gbraid"],
  ["wbraid", "wbraid"],
];
const MAX_AGE = 90 * 24 * 60 * 60; // match your conversion window, no longer
 
export default {
  async fetch(request) {
    const response = await fetch(request);
    const url = new URL(request.url);
    const hit = CLICK_PARAMS.find(([name]) => url.searchParams.get(name));
    if (!hit) return response;
    const [name, type] = hit;
 
    // Same consent rule as your ad cookies: replace with your CMP's ad_storage signal.
    // Landing before the banner is answered? Post the id to a same-origin path after consent
    // and set the cookie there instead.
    const cookies = request.headers.get("Cookie") || "";
    if (!/(?:^|;\s*)ad_consent=granted/.test(cookies)) return response;
 
    // Keep the click time: the expiry estimate needs it.
    // Stored as type|id|unix_seconds, URL-encoded; the form script runs decodeURIComponent,
    // then splits on "|".
    const value = [type, url.searchParams.get(name), Math.floor(Date.now() / 1000)].join("|");
    const out = new Response(response.body, response);
    out.headers.append(
      "Set-Cookie",
      `lead_click=${encodeURIComponent(value)}; Max-Age=${MAX_AGE}; Path=/; Secure; SameSite=Lax`
    );
    return out; // not HttpOnly: the booking form's script has to read it
  },
};

Served from the page's own origin and address, the cookie keeps its 90 days and stays readable for the form's hidden field; a container's HttpOnly click cookie would leave that field, and the CRM row, blank. Narrow the route to landing paths to stay in the free tier.

  1. Deploy the edge cookie above. Tag gateway does not count: its cookies are still set by script.
  2. Verify in Safari. Load a landing URL with a dummy gclid and find the cookie in Web Inspector's Storage tab: it should expire more than 7 days out and not be HttpOnly. A direct load is representative, because the 24-hour cap applies only to script-set cookies.
  3. Point the form at your own cookie for click id and click time.
  4. Rerun the estimate on the 90 days before and after go-live. The expiry slice should shrink toward zero with the upload job untouched.
  5. Add a container only for what a cookie cannot do: one consent check for several destinations, stripped fields, a warehouse event stream. Build it with same-origin routing, two instances, the request-log filter and a consent check in every non-Google tag.

No. Per Google's server-side consent guide, the Google tag passes consent choices to the server container as request parameters, and Google's product tags there adjust what they send. A tag for any other vendor reads nothing until someone writes the check, so I treat it as consent-blind until I have read its code. More in what server-side capture may do under consent. For a group with several brand sites, I put the check in each tag as part of consent-gated measurement.

Where does the offline upload belong, if not in the container?

Tagging server
  • Ad click landsgclid, gbraid or wbraid
  • Consent state read
  • Click id cookie setsame origin
Booking row savedclick id on the row
Upload job
  • Dedupe key storesurvives re-runs
  • Rolling re-read windowlate status edits
  • Retraction logtakes conversions back
Google Adsclick id, value, timestamp
The tagging server's job ends at the booking row; the stateful upload runs on a schedule.

A tagging server answers each request and forgets it. The upload needs memory: a dedupe key that survives a re-run, a rolling re-read window for late status edits, and a log for retracting a conversion after a no-show.

A kept appointment settles days after the click, in a system the tagging server never sees, so none of my offline uploads run through a server container. The scheduled job that connects kept appointments back to Google Ads starts at the booking row and sends Google only a click id, a conversion value and a timestamp.

What does each hosting option cost per month?

Vendor list prices, checked October 2, 2026.

OptionList priceSafari click-cookie lifetime
Edge cookie: the short Cloudflare Worker above, on your own hostname$0 within Workers Free (100,000 requests a day); Paid from $5 a monthThe cookie's own expiry
Google tag gateway: a CDN setting, no containerNo Google fee listed; free on any Cloudflare planSet by Google's script, so the first table's script rows apply: 24 hours after a classified referrer, else 7 days
Stape: a hosted server containerFree to 10K requests; Pro $20 a month or $200 a year, to 500K7 days unless served from your own origin or address range (same-origin path or Stape's Own CDN)
Your own server container on Cloud RunAbout $45 per server a month, two recommended: about $90 before logging7 days on a Google-address subdomain; full expiry through same-origin path routing

Stape's guide says "Cookie Keeper keeps first-party cookies valid for up to 400 days." It restores cookies from a master cookie that, per Stape's own Safari ITP guide, follows Safari's rules: from a different-IP subdomain it lasts 7 days too, so a lead who books on day 10 with no visit between finds nothing to restore.

Cloud Run also logs every request URL, click id included. Google's guide warns of "significant logging charges" past about a million requests a month and gives the filter NOT LOG_ID("run.googleapis.com/requests"). For a practice, condition-page URLs are browsing data it never chose to keep.

Tags

server-side-taggingserver-side-gtmsafari-itpclick-idgoogle-tag-gatewayoffline-conversions

Frequently asked questions

Does server-side tagging fix Safari's 7-day cookie limit?

Server-side tagging lifts Safari's 7-day cookie cap only when the server that sets the cookie answers from your site's own address range, ideally from a path on the same hostname. In Safari, a cookie set by a server whose IP differs from the site's in the first half of the address is still capped at 7 days. That still beats a page-script cookie on a paid-search landing, which Safari can cap at 24 hours, so a hosted container keeps bookings made within a week but not the ones after it.

How much does server-side Google Tag Manager cost per month?

Self-hosted on Cloud Run, Google's setup guide puts each server at about $45 a month and recommends at least two, so about $90 before logging charges. Stape lists a free tier up to 10,000 requests and a Pro plan at $20 a month, or $200 a year, up to 500,000. What decides it is whether the recovered click ids reach an upload.

Is Google tag gateway the same as server-side GTM?

No, Google tag gateway serves Google's tag from a path on your domain through your CDN, while server-side GTM is a container you host. Tag gateway has no container to configure and no Google fee listed, but its cookies are still set by script, so Safari's script-cookie caps apply. Only the container can drop fields, enforce one consent check and send to non-Google destinations.

Should Google Ads offline conversion uploads run through the server container?

No, offline conversion uploads belong in a scheduled job. An upload needs memory across runs: a dedupe key that survives re-runs, a re-read window for late status edits, and a record of what to retract. A container answers one request at a time unless you bolt on a store, so in the systems I build it stops at the first hop.

Related posts