| name | attribution-modeling |
| description | Build, audit, and operate a profit-first attribution model that connects ad-platform spend to your real revenue and profit, not the platforms' reported ROAS. Use when asked to "set up attribution", "build the profit pipeline", "join ads to revenue", "fix our ROAS reporting", "design an incrementality test", "audit our attribution model", or anytime someone says "the platform numbers don't match the bank account". |
| metadata | {"version":"1.0.0"} |
Attribution Modeling
The deeper, more rigorous version of the profit-first principle that runs through this entire skill stack. This skill is for the case where you need to design, build, and operate the actual data pipeline that produces your ground-truth profit-per-campaign — and the incrementality tests that tell you which of those numbers are real.
When to use
- "Our ROAS looks great but profit isn't growing" — start here
- "Set up attribution modeling for our account" — this is the build skill
- "How do we know if our paid ads are incremental?" — incrementality test design
- "We want to join Google Ads to our BigQuery / Shopify / Stripe data" — pipeline design
- "Audit the attribution model we already have" — gap analysis
- Setting up
paid-ads-context.md section 4 (Attribution & data)
The North Star
Profit-to-spend ratio is the metric. Not ROAS. Not conversions. Not platform-reported conversion value.
Why: ad platforms have an incentive to make themselves look good. View-through conversions, multi-touch attribution that defaults to last-click, lookbacks that overlap with email and direct — all of these inflate reported ROAS without inflating your bank account.
Profit-to-spend is computed from YOUR data:
profit_to_spend = (revenue from your own system - COGS - refunds - fees - partner payouts) / ad spend
Every component except spend comes from inside your business. Spend comes from the ad platforms. The pipeline is the join.
What the skill builds
A four-layer attribution model:
- Data ingestion — pulling spend (from platforms) and orders (from your system)
- Identity / UTM resolution — joining the two on a stable key
- Profit calculation — applying refunds, COGS, fees, partner cuts
- Incrementality overlay — the holdout tests that tell you which numbers are real
Each layer has known failure modes. The skill builds defenses for each.
Layer 1: Data ingestion
Spend sources
| Source | How to pull | Refresh cadence |
|---|
| Google Ads | GAQL via the official MCP, or BigQuery Data Transfer Service | Hourly possible; daily is enough |
| Meta | Marketing API insights endpoint | Same |
| LinkedIn | Marketing API | Daily |
| TikTok | Marketing API | Daily |
| Other | CSV export, refreshed by hand | Weekly minimum |
The skill defaults to daily ingestion at 9am account-time, pulling yesterday's data. See n8n-recipes/daily-ad-spend-to-sheet for the implementation.
Order sources
The "true revenue" layer. From your platform:
| Platform | Where the data lives | Join key options |
|---|
| Shopify | Orders API, Stripe Sigma if you use Stripe | utm_campaign, landing_site_ref |
| Stripe | Sigma queries, or directly via the API | metadata.utm_*, customer-level email |
| Custom warehouse | Whatever schema you have | Whatever join column you chose |
| Marketplace (e.g., AppSumo, App Store) | Often delayed and aggregated | UTM if it survives the marketplace redirect |
Refunds / margin layer
This is the part most attribution models skip and that destroys their accuracy:
- Refunds: pull at least 60 days after the order date (refund tail is long for most categories)
- Partner payout: subtract if you're a marketplace
- Payment processing fees: ~3% on average, sometimes more for international cards
- Variable COGS: per-order shipping, fulfillment, packaging
The profit_usd column is the sum after all these subtractions. A dollar of revenue is not a dollar of profit.
Layer 2: Identity / UTM resolution
The join key is what makes the attribution model work or fail.
Three join strategies (best to worst)
1. Server-side enhanced conversions / first-party data
You set conversion events server-side with the order's email hash, customer ID, and gclid/fbclid. Platforms accept this and can match to actual ad clicks.
Best for: e-commerce with checkout flows you control.
2. UTM parameter resolution
Every paid click gets UTMs. Every order captures the landing-page UTMs. Match on utm_campaign (or whatever level you chose).
Best for: B2B SaaS, content sites, anywhere the conversion is form-fill not server-checkout.
3. Last-touch via Google Analytics
You trust GA4's attribution and pull from there.
Best for: nothing. Use this only when nothing else is available. GA4's default attribution model is opinionated and not yours.
UTM hygiene rules
The join is only as good as your UTMs. Enforce these:
- Every paid ad has a
utm_campaign that matches its campaign name (or a derived label you control)
utm_campaign is lowercase, no spaces, kebab-case (search-non-brand-aitools not Search Non-Brand AI Tools)
- Your order capture stores
utm_* server-side at the moment of conversion, not client-side later
- You also store
gclid and fbclid for platform-level joins as a backup
- Audit your UTM dictionary quarterly
Common UTM failures
| Symptom | Cause | Fix |
|---|
| Many orders attributed to "direct" | UTM gets stripped on a redirect | Audit the redirect chain end-to-end |
| Orders show wrong campaign | UTM persists across sessions in localStorage with no expiration | Set expiration to 30 days, refresh on each new paid click |
| Mobile orders all "direct" | App / Safari ITP stripping | Use server-side conversion APIs as primary, UTMs as secondary |
| Marketplaces show no UTM | Marketplace redirect drops UTM | Negotiate UTM pass-through with the marketplace, or use marketplace's own attribution layer |
Layer 3: Profit calculation
The final table the skill outputs has these columns:
| Column | Type | Notes |
|---|
| date | date | Day-level granularity |
| campaign_id | string | Platform's campaign ID |
| campaign_name | string | Cleaned, role-labeled name |
| platform | string | google / meta / linkedin / tiktok / etc |
| spend_usd | numeric | From platform API |
| revenue_usd | numeric | From your order system |
| refunds_usd | numeric | Within the lookback window |
| fees_usd | numeric | Processing + fulfillment |
| cogs_usd | numeric | Variable cost per order |
| partner_payout_usd | numeric | Marketplaces only |
| profit_usd | numeric | revenue - refunds - fees - cogs - partner_payout |
| orders | integer | Count |
| new_buyer_orders | integer | First-time buyers |
| profit_to_spend | numeric | profit_usd / spend_usd |
This is the table that google-ads-manager and paid-channel-recap consume.
Layer 4: Incrementality
Even with perfect attribution, you have a question: would these conversions have happened anyway?
This is the cannibalization problem. The platform claims credit for a buyer who would have come through email, direct, or organic. Without an incrementality overlay, you over-spend on cannibalized channels.
Incrementality methods, ranked
1. Geo-holdout (gold standard)
Pause ads in 2-3 comparable geographic regions for 3-4 weeks. Compare total revenue (not just attributed) in test vs control.
Use references/geo-holdout-design.md for the full protocol.
2. Google Conversion Lift / Meta Conversion Lift
The platforms run their own incrementality experiments. Available at $5K+ budget (used to be $100K+). Useful as a sanity-check on what you'd find with a geo-holdout.
3. Spend-step changes
Briefly +20% / -20% a campaign's daily budget for 14 days. Compare incremental revenue to incremental spend. Crude but cheap.
4. New-vs-returning buyer ratio (proxy)
If a campaign is 90%+ returning customers, it's likely cannibalizing email and direct. The new-buyer percentage is your incrementality proxy.
Reports from google-ads-manager include this column.
What incrementality typically tells you
For most B2B SaaS / DTC accounts:
- Brand search: 20-40% incremental (high cannibalization)
- Non-brand search: 70-90% incremental (mostly new demand)
- Performance Max / Advantage+: 30-60% incremental (variable, depends on signals)
- Retargeting / display: 10-30% incremental (heavy cannibalization)
Your numbers will vary. The point is: a 5x platform-reported ROAS at 30% incrementality is a real 1.5x — and that's the number you should manage to.
Anti-patterns
| Anti-pattern | Why | Fix |
|---|
| Trust Google's "conversion value" for profit | View-through inflates it 30-50% in most accounts | Always join to your own order data |
| Skip the refund layer | Refunds happen 7-90 days later; ignoring them inflates profit | Include refund tail in the lookback |
| Use GA4 default attribution | Opinionated, not yours, hides cannibalization | Build your own model with your own join |
| No incrementality testing | You'll over-scale cannibalized channels | At least annual geo-holdouts on top-spend channels |
| Average profit-to-spend across days | Hides the day where one campaign collapsed | Compute per-campaign, per-day |
| Single attribution model for all decisions | Different decisions need different models | Use platform-reported for bid signals; use your model for budget allocation |
When you don't have the data yet
If you can't build the full pipeline today, the skill helps you triage:
Minimum viable attribution:
- A weekly manual export from each ad platform
- A weekly manual export from your order platform
- A Google Sheet that joins them on
utm_campaign
- A
profit_usd column computed from revenue * (1 - refund_rate) * (1 - cogs_pct) - spend
- A
profit_to_spend column computed weekly
This is bad. But it's better than trusting platform ROAS. Use it for 30 days while you build the real pipeline.
Related skills
References