Reference architecture

Cloudflare Worker to BigQuery to dbt to Conversions API fan-out

This is a server-side attribution architecture for B2B companies that need conversions tied to real pipeline, not form fills. Events are collected on your own domain by a Cloudflare Worker, stored raw in BigQuery, matched to HubSpot contacts and deals with dbt, and only then sent to Google Ads, Meta, LinkedIn and back to HubSpot. It is more work than server-side GTM, and for most teams server-side GTM is enough.

By Vladimir Cvetkoski · Last updated

Overview

The whole pipeline on one line

  1. 01
    Browser
    A small first-party script sends page views, form submits and click IDs, with the current consent state attached.
  2. 02
    Cloudflare Worker
    Runs on a subdomain of your site. Validates, enriches and batches events. Sets first-party identifiers.
  3. 03
    BigQuery raw events
    Every event stored as received, append-only, partitioned by day. Nothing is overwritten.
  4. 04
    dbt models
    Identity stitching with HubSpot contact IDs, sessionization, and joins to lifecycle stages and deals.
  5. 05
    Conversions fan-out
    Qualified conversions sent to Google Ads, Meta CAPI, LinkedIn CAPI, and source data written back to HubSpot.
Events are collected on your own domain, stored raw in your own warehouse, modelled against HubSpot, and only then sent to ad platforms. Consent state travels with every event through every layer.

This is a reference architecture I design and build. It is not a product, and it is not the starting point for most clients. It is what I recommend when the questions a team asks have outgrown what a tag manager can answer: which campaigns produce opportunities, not just leads, and how to tell every ad platform the same thing about what a good lead looks like.

The idea is simple. Collect once, on your own domain. Keep everything raw, in a warehouse you own. Decide what counts as a conversion in one place, with your CRM data next to it. Send each platform only what it needs, only when consent allows it.

Layer 1

First-party collection in the browser

What it does

A small script on your site sends events to an endpoint on your own domain: page views, form submissions, and the click IDs ad platforms put on landing page URLs (gclid, fbclid, li_fat_id) along with UTM parameters. Each event carries the visitor's current consent state and, where it exists, the HubSpot hubspotutk cookie so the event can later be matched to a contact.

Why it exists

Requests to third-party analytics and ad domains are the ones ad blockers and browser privacy features stop first. Requests to your own domain are far less likely to be blocked. This layer does not get around consent. It gets around the data loss that has nothing to do with consent.

What it costs to run

Almost nothing to run. The maintenance is in keeping the event list in step with your site: new forms, new booking tools, new landing page templates. If you already run GTM, the script can be fired from it.

Layer 2

A Cloudflare Worker on a first-party subdomain

What it does

The Worker receives every event on something like collect.yourdomain.com. It checks the payload, drops malformed or bot traffic, adds server-side context such as the timestamp and country, sets or refreshes a first-party identifier cookie, and writes events to BigQuery in batches.

Why it exists

It is the same job a server-side GTM container does, with two differences. It is code you own and can test like any other code, and it writes to the warehouse first instead of forwarding straight to vendors. Safari caps cookies set by JavaScript at 7 days, or 24 hours after a click from a decorated link. A cookie set by your own server can last longer, but Safari also caps server-set cookies at 7 days when the subdomain is a CNAME to a third-party host or resolves to a third-party IP address, so how the subdomain is hosted matters.

What it costs to run

Cloudflare Workers are billed per request and B2B traffic volumes are small, so running cost is low. The real cost is ownership: this is custom code, so someone has to deploy it, version it and read the logs when something changes. That is a bigger commitment than a hosted sGTM container.

Layer 3

BigQuery as the raw event store

What it does

Every event lands in an append-only table, partitioned by day, exactly as it was received. A separate scheduled job exports contacts, deals and lifecycle stages from the HubSpot API into the same project, so web data and CRM data sit side by side.

Why it exists

Raw data is what makes the pipeline replayable. When you change what counts as a qualified lead, fix a bug in a model or add a new ad platform, you rebuild from the raw events instead of waiting months for new data. It also gives you a record of exactly what was collected, under which consent state, which matters when someone asks.

What it costs to run

BigQuery bills for storage and for the data each query scans. At B2B event volumes, storage is modest. Query cost stays low if tables are partitioned and models only read the days they need. It grows quickly if people run unbounded queries on raw tables, so access and query habits are part of the setup.

Layer 4

dbt models: identity, sessions, lifecycle

What it does

A small set of SQL models, versioned in git, turns raw events into something you can act on:

  • Identity stitching. Anonymous first-party IDs are linked to HubSpot contact IDs when a visitor submits a form or is otherwise identified, so earlier visits and click IDs attach to the right contact.
  • Sessionization. Events are grouped into sessions with your own rules, so the source of a visit is defined once, in code you can read.
  • Lifecycle joins. Contacts are joined to lifecycle stages and deals from HubSpot, so a conversion can be “became an SQL” or “opportunity created”, not just “filled in a form”.

Why it exists

This is the layer a tag manager cannot provide. A server-side container sees one request at a time. It cannot look back at a contact's first visit or forward to the deal that closed later. The models can, and they define conversions the same way for every platform.

What it costs to run

dbt Core is open source and runs on a schedule wherever you already run jobs. The ongoing cost is attention: models need an owner who updates them when HubSpot properties, lifecycle stages or pipeline definitions change. This is the layer that most often decides whether the architecture is worth it.

Layer 5

Fan-out to ad platforms and HubSpot

What it does

A scheduled job reads the conversion models and sends each platform what it can use: click conversions and enhanced conversion data to Google Ads, server events to the Meta Conversions API, conversions to the LinkedIn Conversions API. Emails and phone numbers are normalized and hashed before they leave the warehouse. Source and conversion data is also written back to the HubSpot contact, so the CRM shows the same story as the ad platforms.

Why it exists

Ad platforms optimize toward whatever you tell them is a conversion. If they only see form fills, they find more form fills. Sending pipeline-stage conversions from one model means Google Ads, Meta and LinkedIn all learn from the same definition of a good lead. It is the same goal as offline conversion imports, done from the warehouse instead of from inside HubSpot.

What it costs to run

The job itself is cheap to run. Maintenance comes from the platforms: API versions are retired, access tokens expire, required fields change. Every send is logged with its response, and monitoring checks that conversions keep arriving, so a silent failure becomes an alert instead of a slow drift in campaign performance.

Trade-offs

Compared with Stape or a server-side GTM setup

A server-side GTM container, often hosted on Stape, is simpler and is enough for most B2B companies. It has ready-made tags for GA4, Google Ads, Meta and LinkedIn, it can be managed in a familiar interface, and it fixes most of the data loss from ad blockers and short-lived browser cookies. When I build server-side tracking for a client, this is usually what I build.

The warehouse architecture is worth it when you need things sGTM is not designed for:

  • Warehouse-grade identity. Linking anonymous visits to HubSpot contacts across devices and weeks, not just within one request.
  • Replayable data. Rebuilding conversions from raw history when a definition changes, without losing months of signal.
  • One conversion definition. Lifecycle-based conversions defined once and sent to every platform, instead of configured separately in each tag.
  • An audit trail. A queryable record of what was collected, under which consent state, and what was sent where.

The price is ownership. sGTM can be maintained by a technical marketer. This needs someone comfortable with code, SQL and a cloud project. The two are also not exclusive: a common path is sGTM first, with a warehouse added once the questions outgrow it.

Fit

Who this is for, and who it is not for

A good fit

  • →B2B companies with HubSpot as the CRM and a sales cycle long enough that the conversion you care about happens weeks after the click
  • →Teams running paid campaigns on more than one of Google Ads, Meta and LinkedIn, who want every platform to optimize on the same pipeline definition
  • →Companies with EU or UK traffic that need consent enforced in one place, with a record of what was sent and why
  • →Teams that already use BigQuery, or have someone who can own a small data project
  • →Companies that need to replay or correct conversion history when a definition changes, instead of starting from zero

Not a good fit

  • →Teams whose problems are still at the capture layer: stripped UTMs, broken forms, missing cross-domain tracking. Fix those first, they feed everything else.
  • →Companies with one ad channel and a short path from form fill to deal. Native HubSpot integrations plus a clean server-side GTM setup will do.
  • →Teams with nobody to own a warehouse and a set of SQL models. An unowned pipeline is worse than a simpler one that works.
  • →Anyone whose current server-side GTM setup is working and answers the questions they actually ask.
Privacy and consent

Consent travels with every event

Consent is not a filter added at the end. It is a field on every event, from the browser to the final send.

  • The browser script reads the consent state from your consent banner and attaches it to each event, using the same signals as Google Consent Mode: analytics_storage, ad_storage, ad_user_data and ad_personalization.
  • The Worker stores the consent state with the event. Identifier cookies are only set when the consent state allows it.
  • The dbt models carry consent through every join, so a conversion knows the consent state of the visit it came from.
  • The fan-out job checks consent before every send. Where consent is required, nothing goes to an ad platform without ad_user_data granted. Google Ads uploads also carry the consent fields Google asks for.
  • Personal data such as emails is hashed before it is sent to any platform, and the raw data lives in your own cloud project, under retention rules you set. Deletion requests are handled in one place.

None of this replaces a consent banner configured correctly in the first place. If your banner and Consent Mode are not set up properly, start with Consent Mode setup.

Where to start

Most teams should not start here

The architecture only works if the inputs are clean. If HubSpot shows the wrong sources, forms drop UTMs or visitors lose their source between domains, a warehouse will store the same errors more precisely. The usual order is: fix capture, add server-side tracking and offline conversions, and only then decide whether the questions you ask need a warehouse.

The HubSpot attribution guide explains each of those layers. If you are not sure where your setup stands, an Attribution Audit tells you, with every leak ranked by cost.