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:
- Survives client-side navigation within a single session
- Lets the api correlate engagement events back to the eventual lead submission
- Carries no PII and is not shared across domains
- Is disclosed in the platform's Ley 8968 cookie banner
- Costs no third-party SDKs (no Google Analytics, no Mixpanel, no Segment)
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:
- If the request already has a valid
urza_session_id(non-empty, base64url-shaped), pass through unchanged. - Otherwise, generate
crypto.randomUUID(), base64url-encode it, set the response cookie with the properties above. - 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
- Local storage UUID. Survives the same way, but requires client-side JS to populate. Misses every server-rendered first-paint event. Rejected.
- 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.
- 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.
- Anonymous Clerk session. Brings back the entire deferred Clerk dependency for a single id. Rejected.
Ley 8968 + cookie-banner posture
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:
- One round-trip per first visit; subsequent requests have the cookie already.
- No third-party SDKs, no cross-site tracking, no fingerprinting.
- Attribution survives client-side route transitions because the cookie is host-scoped.
- Drop-in compatibility with the FEAT-015 lead pipeline — the same id ties pre-submission events to the eventual lead row.
Negative:
- A privacy-conscious visitor who clears cookies between visits will look like multiple unique sessions in the funnel. Acceptable — the platform's metric of interest is "leads per session," not "leads per unique person."
- Visitors with cookies disabled won't have attribution. The api still accepts the event (logs a synthetic id) so the route stays useful as a count. Funnel attribution is best-effort.
- The cookie is not HttpOnly, so it's readable from JavaScript. That's deliberate — the client-side beacon dispatcher needs it. It carries zero auth weight, so the exposure is benign. Documented here so a future reader doesn't "fix" it.
Env vars + secrets
None. The cookie value is generated locally by crypto.randomUUID() and persisted client-side; no secret on either side.
Acceptance
- Visiting
/tarjetas-de-credito/(cold) issues anurza_session_idcookie with the documented properties. - Visiting any sub-path with the cookie already set does NOT re-issue it.
- The cookie is listed by name in the platform's Ley 8968 cookie disclosure.
POST /api/v1/cta-eventsaccepts the cookie as thesession_idand rejects requests with malformed (non-base64url, wrong length) values with a 400.POST /api/v1/leadsuses the same cookie to back-fill theleads.session_idcolumn so the admin inbox can show pre-submission context.