# Conversion events are linked back to the originating email send

- **Pattern:** `ab-000591` (`campaign-analytics-attribution.attribution-conversion.conversion-linked-to-send`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000591
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000591)

## 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:

```ts
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:

  ```ts
  // 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)
- ccpa §1798.120
- eprivacy Art. 5(3)

Taxons: data-integrity, observability

HTML version: https://auditbuffet.com/patterns/ab-000591
