The same offer goes into a Facebook group, a WhatsApp status, a Telegram channel and a classifieds site. The platform won't tell you which one works — but the link you publish can.
Before the code, four things you should never confuse:
- which tagged link generated the visit;
- which platform the visitor actually came from;
- which visit turned into an inquiry;
- which inquiry turned into a sale.
UTM answers #1. It never answers #2. Conversations are #3, sales are #4 — see "clicks are not sales" below.
Step 0 — a table that measures something
No UTM is useful without a place to land. A minimal events table is enough; no data warehouse, no customer SDK:
marketing_events (
created_at,
path,
event,
source,
medium,
campaign,
content
)
source/medium/campaign/content start empty and get filled once you tag links. If storage is your constraint, aggregate by day instead of storing every event.
Storage depends on deployment, not on fashion:
- SQLite — right on a small persistent server (one local file, no external service).
- PostgreSQL/Supabase — right when that's already your stack (add a table, not a service).
- Serverless (Vercel, Lambda) — the local filesystem is ephemeral: per-invocation, recycled, sometimes read-only. A SQLite file there is silent data loss. Put the database outside the function.
Step 1 — tag the link, don't beg the platform
The platform sends the full URL when someone clicks. So before publishing, embed what you need:
https://your-site.com/offer?utm_source=facebook
The label travels with the click. The platform is just transport.
The four parameters you'll actually use:
| Param | Role | Example |
|---|---|---|
utm_source |
origin you want to tell apart | facebook |
utm_medium |
channel type | social |
utm_campaign |
promotion grouping | camisas-2026-09 |
utm_content |
specific variant of the piece | grupo-venta |
Same campaign, three sources:
?utm_source=facebook&utm_medium=social&utm_campaign=camisas-2026-09&utm_content=grupo-venta
?utm_source=whatsapp&utm_medium=messaging&utm_campaign=camisas-2026-09&utm_content=estado
?utm_source=revolico&utm_medium=classified&utm_campaign=camisas-2026-09&utm_content=anuncio-1
Start with only utm_source if you want; the rest is added when a question demands it.
Explicit assumption: you control the destination page (to read the query string) and you generate the links before posting. If a shortener or a third party generates them, you don't own the data anymore.
What UTM does and doesn't prove
- Does: tells you which labeled link was used.
-
Doesn't: tell you the physical platform. Links get copied and forwarded; a "facebook" link clicked from WhatsApp still reads
utm_source=facebook. That's inherent to the method, not a bug of your code.
The Referer header is a secondary signal only: browsers default to strict-origin-when-cross-origin (cross-origin sends just the origin), some policies strip it, apps often send none. Don't build primary attribution on it.
Step 2 — one axis per column, then GROUP BY
Giant compound labels are an antipattern:
utm_source=facebook-group-1-shirts-september-blue-post
Keep dimensions separate so each question has its own axis:
SELECT source, COUNT(*)
FROM marketing_events
WHERE campaign = 'camisas-2026-09'
GROUP BY source
ORDER BY 2 DESC;
source → which platform works. campaign → whether the whole campaign worked. Same campaign, many sources, comparable without losing identity.
Step 3 — single source of truth + one adapter per channel
The offer exists once:
id: oferta-2026-09-camisas
precio_cup: 2300
Each channel carries its own tracking + copy:
canales:
- id: grupo-venta
source: facebook
medium: social
content: grupo-venta
- id: estado-wa
source: whatsapp
medium: messaging
content: estado
- id: revolico
source: revolico
medium: classified
content: anuncio-1
URLs are generated from that config, so a price change is one edit and tagging can't be forgotten. Channels differ in text, image, tone and CTA, hence an adapter interface:
def render(oferta, canal):
adapter = ADAPTERS[canal.id]
return adapter.render(
oferta=oferta,
tracking=tracking_params(canal),
)
What you published is a separate concern from analytics:
publicaciones (
oferta,
canal,
pieza_hash,
publicado_at
)
pieza_hash detects content changes so you don't republish the same piece twice — deduplication/idempotency, not security.
Step 4 — clicks are not sales
| Channel | Visits | Inquiries | Sales |
|---|---|---|---|
| 100 | 10 | 1 | |
| 40 | 20 | 8 | |
| Classifieds | 200 | 5 | 0 |
Classifieds won traffic; WhatsApp won business. Publication → visit auto-measures. Visit → inquiry → sale doesn't: record it (one column, a sheet), start manual, automate only when volume justifies it.
Step 5 — opaque IDs (why, when)
When you need URLs that don't expose campaign internals (shorter, stable, non-editable):
?c=7f3a
7f3a → a row with campaign/channel/content. It doesn't make attribution truer; it changes representation.
Step 6 — signatures (why almost never, and when yes)
HMAC with a shared secret verifies one thing: a value was generated by whoever holds the secret.
Use it when the parameter has consequences: coupons, discounts, affiliate links, attribution that pays someone.
Verbatim non-goals:
- it does not prove the platform of origin;
- it does not stop someone reusing a legitimate link 300 times (replay of a valid link is still a valid link);
- it does not make tracking trustworthy in general — only the parameter it signs.
Decision checklist
- Just started → events table +
utm_source. Stop there. - Several channels per campaign → add
medium/campaign/content, group by axis. - Growing ≥3 places → single source YAML + adapters + generate URLs.
- Need to know what to keep publishing → manual conversion column before any dashboard.
- URL looks too informative / needs to change scheme without breaking → opaque ID.
- Parameter has money attached to it → HMAC. Otherwise don't.
The channel ranking is a GROUP BY. The data is yours. You don't need an enterprise tag manager to answer "which publication should I keep posting?"
Adapted from my original post on TallerWeb.
Top comments (0)