Architecture decision ADR-015

First-party session cookie for engagement tracking

Accepted · 2026-05-26

Historical decision text is preserved. Accepted describes the decision, not deployment of every capability discussed. Consult the Architecture and Infrastructure tabs for the verified running system.

Status: Accepted Date: 2026-05-26 Deciders: Daniel Meza Related: FEAT-012 — Product Request CTAs and Offer Display, FEAT-015 — Lead Capture and CRM, Architecture reference §11.7 (Ley 8968)

Context

FEAT-012 captures engagement events for every "Solicitar" CTA (impression, click, form-open, form-open-failed) and FEAT-015 attaches each lead to the visitor's pre-submission browsing window. The current release ships without user accounts, so there is no user_id to attribute events to. We need a stable per-visitor identifier that:

Decision

Issue a first-party urza_session_id cookie on the visitor's first request to any /tarjetas-de-credito/** URL. The value is a 128-bit random ID generated by crypto.randomUUID() and base64url-encoded. Properties:

Property Value Reason
Name urza_session_id Predictable, namespaced.
HttpOnly false The client-side beacon dispatcher needs to read it to attach to fire-and-forget POSTs. The cookie carries no auth weight, so reading it from JS is acceptable.
Secure true in production TLS-only outside of localhost.
SameSite Lax Defaults; allows the cookie on top-level navigation, blocks it on cross-site <img> and <form> posts.
Path / Available on every route.
Max-Age 30 days Long enough to span a research session that spreads over multiple days, short enough that a stale id rolls naturally.
Domain unset (host-only) Restricted to the exact host that set it; no leakage to subdomains.

The cookie is set by a Next.js middleware (apps/web/src/middleware.ts). Issuance rules:

  1. If the request already has a valid urza_session_id (non-empty, base64url-shaped), pass through unchanged.
  2. Otherwise, generate crypto.randomUUID(), base64url-encode it, set the response cookie with the properties above.
  3. Skip API / static / Next-internal paths so they don't waste the round-trip.

The FastAPI side reads the same cookie from request headers when the /api/v1/cta-events and /api/v1/leads endpoints fire. If a request arrives without the cookie (a curl from a script, an older browser session), the api generates a fresh id and returns it in the response cookie.

Considered alternatives

  1. Local storage UUID. Survives the same way, but requires client-side JS to populate. Misses every server-rendered first-paint event. Rejected.
  2. IP-derived hash. Cheap but unstable (mobile networks rotate IPs mid-session) and arguably more privacy-invasive because the hash is reversible against an IP table. Rejected.
  3. Fingerprinting (canvas, fonts, etc.). Identifies visitors across sessions even without consent. Explicitly rejected on privacy grounds — incompatible with the project's "minimum PII, maximum transparency" posture.
  4. Anonymous Clerk session. Brings back the entire deferred Clerk dependency for a single id. Rejected.

The urza_session_id cookie is classified as a functional cookie because the request-to-lead attribution is core to the product (without it, the lead inbox cannot show "viewed N cards before requesting" context, and FEAT-012's funnel metrics break). It is NOT classified as a "strictly necessary" cookie because the visitor can browse, compare, and use calculators without it.

The platform's Ley 8968 cookie banner lists urza_session_id explicitly:

Cookie funcional urza_session_id — Identificador aleatorio que asocia tus interacciones (impresiones de CTA, clics, apertura del formulario de solicitud) con una sesión de navegación. No contiene datos personales. Caduca a los 30 días.

The lead form's consent checkbox is the PII consent moment. The cookie banner is the functional cookie consent moment. They are distinct.

Consequences

Positive:

Negative:

Env vars + secrets

None. The cookie value is generated locally by crypto.randomUUID() and persisted client-side; no secret on either side.

Acceptance