Conversion events are linked back to the originating email send
Why it matters
Without a foreign key or UTM-derived reference linking conversion records to the originating email send, you cannot compute campaign ROI, per-campaign conversion rates, or the revenue contribution of any individual sequence or A/B test. You can observe that a purchase happened, but you cannot answer which campaign drove it. GDPR Art. 5(1)(a) (lawfulness, purpose limitation) and CCPA §1798.120 (right to opt out of sale tied to identifiable data processing) both implicitly require that you understand why you hold each piece of customer data — including behavioral data collected via ePD Art. 5(3) tracking pixels. Blind conversion logs satisfy none of these requirements and make all email program measurement impossible.
Severity rationale
Critical because unlinked conversion data makes it impossible to compute campaign ROI, evaluate A/B test outcomes on revenue metrics, or attribute any business result to an email program — the entire analytics layer loses its core function.
Remediation
Parse UTM parameters from the conversion request and persist them as explicit columns alongside the conversion record. Do this server-side — client-side analytics tools lose the data on ad blockers and private browsers:
async function recordConversion(
contactId: string,
type: string,
value: number,
req: Request
) {
const url = new URL(req.url)
await db.conversions.create({
data: {
id: crypto.randomUUID(),
contact_id: contactId,
type,
value,
utm_source: url.searchParams.get('utm_source') ?? null,
utm_medium: url.searchParams.get('utm_medium') ?? null,
utm_campaign: url.searchParams.get('utm_campaign') ?? null,
utm_content: url.searchParams.get('utm_content') ?? null,
converted_at: new Date()
}
})
}
Add a campaign_id column to your conversions table and populate it by joining utm_campaign against your campaigns table at write time so queries don't require a join on every read.
Detection
-
ID:
conversion-linked-to-send -
Severity:
critical -
What to look for: Examine how conversion events (purchases, signups, form completions) are stored and whether they can be traced back to a specific email send. Look for a foreign key or reference from the conversion record to a send or campaign ID. Check for conversion tracking routes that parse UTM parameters from the landing page URL and store the campaign reference alongside the conversion. Without this link, you cannot compute true campaign ROI or per-campaign conversion rates.
-
Pass criteria: Conversion events include a reference (campaign ID, send ID, or UTM-derived campaign identifier) that links back to the originating email send. The link is stored in the database, not just logged. Count all conversion-recording code paths and verify at least 1 column stores a campaign reference.
-
Fail criteria: Conversion events are stored without any campaign reference. UTM parameters are not captured at conversion time. You cannot query "how many conversions came from campaign X" from the database.
-
Skip (N/A) when: The project does not track conversion events (no purchases, signups, or measurable goal completions).
-
Detail on fail: Example:
"Conversion events table has no campaign_id or utm_campaign column — cannot attribute conversions to email sends"or"UTM parameters captured in analytics tool but not stored server-side — cannot join with send data" -
Remediation: Capture and store UTM parameters at conversion time:
// On your conversion endpoint or page server action async function recordConversion( contactId: string, conversionType: string, value: number, utmParams: Partial<UtmParams> ) { await db.conversions.create({ data: { id: crypto.randomUUID(), contact_id: contactId, type: conversionType, value, utm_source: utmParams.source ?? null, utm_medium: utmParams.medium ?? null, utm_campaign: utmParams.campaign ?? null, utm_content: utmParams.content ?? null, utm_term: utmParams.term ?? null, converted_at: new Date() } }) } // Parse UTMs from the request's Referer or cookie function extractUtmParams(req: Request): Partial<UtmParams> { const url = new URL(req.url) return { source: url.searchParams.get('utm_source') ?? undefined, medium: url.searchParams.get('utm_medium') ?? undefined, campaign: url.searchParams.get('utm_campaign') ?? undefined, content: url.searchParams.get('utm_content') ?? undefined, term: url.searchParams.get('utm_term') ?? undefined } }
External references
- gdpr · Art. 5(1)(a) — Lawfulness, fairness and transparency — data processed lawfully
- ccpa · §1798.120 — Right to opt-out of sale of personal information
- eprivacy · Art. 5(3) — Confidentiality of communications — consent before tracking
Taxons
History
- 2026-04-18·v1.0.0·Initial import from campaign-analytics-attribution·automated