# Geo Targeting

> Show the right consent experience per jurisdiction with Zest geo gating

Source: https://cookiezest.com/docs/geo/

_Since v2.4.0._

## Overview

By default Zest shows the consent banner to **everyone**. Opt into geo gating and Zest resolves the visitor's location and decides *which* consent experience to present:

```javascript
window.ZestConfig = { geo: true };   // that's it
```

Or via data attribute:

```html
<script src="https://cdn.jsdelivr.net/npm/@freshjuice/zest" data-geo="on"></script>
```

Default behavior once enabled, matching the legal model:

| Jurisdiction | Action | What the visitor sees |
|---|---|---|
| GDPR / EEA / UK | `consent` | Opt-in — full banner, tracking blocked until they choose |
| US state-privacy (CCPA, CPRA, VCDPA…) | `notice` | Opt-out — tracking runs, a small **"Do Not Sell or Share My Personal Information"** link |
| Everywhere else | `allow` | Nothing — tracking allowed, no UI |

> **No behavioral change unless you opt in.** With no `geo` key, Zest shows the banner to everyone exactly as before.

## Choosing the Verdict Source

`geo: true` is shorthand for `{ provider: 'gateway' }`, which uses the hosted **zest-geo** gateway at `https://geo.cookiezest.com/privacy` — a Cloudflare Worker that reads the edge geo of the request and returns the applicable privacy regimes. It stores nothing, logs nothing, and the `/privacy` endpoint carries no IP / city / coordinates. Zero infrastructure on your side.

The verdict source is pluggable — pick ONE:

```javascript
window.ZestConfig = {
  geo: {
    provider: 'gateway',                          // hosted zest-geo (default)
    // endpoint: 'https://geo.example.com/privacy', // your self-hosted zest-geo
    // resolver: async () => ({ isGDPR, isUSPrivacy, isCCPA, isEU, isEEA, regulations }),

    // Optional — map the verdict to an action (this is the default):
    decide: (geo) =>
      geo.isGDPR ? 'consent' : geo.isUSPrivacy ? 'notice' : 'allow',

    timeout: 1500,        // ms before giving up
    fallback: 'consent'   // action if resolution fails/times out (fail-closed)
  }
};
```

The **`resolver`** option is the recommended path if your CDN already knows the country — read its geo header (`CF-IPCountry`, `x-vercel-ip-country`, etc.) and return the verdict yourself, no extra request. Whatever the source, it must return the gateway's shape:

```typescript
{ isEU, isEEA, isGDPR, isCCPA, isUSPrivacy: boolean, regulations: string[] }
```

## Actions

`decide()` must return one of four actions:

| Action | Interceptors | UI |
|---|---|---|
| `'consent'` | stay blocked until decision | full banner / modal |
| `'notice'` | allow + replay queue (opt-out) | "Do Not Sell" link |
| `'allow'` | allow + replay queue | nothing |
| `'block'` | stay blocked | nothing (fail-closed) |

Anything outside this set is clamped to `fallback`.

## The "Do Not Sell" Notice

For the `'notice'` action the full build shows a minimal, dismissible Shadow-DOM link. It is **not** a consent gate — tracking already runs (opt-out model); clicking the link flips to a full reject. Customize the text via `labels.notice`:

```javascript
window.ZestConfig = {
  geo: true,
  labels: {
    notice: {
      title: 'Your Privacy Choices',
      optOut: 'Do Not Sell or Share My Personal Information',
      dismiss: 'Dismiss'
    }
  }
};
```

## How It Works (and the One Caveat)

Interceptors install **synchronously** on script eval, so trackers stay blocked no matter what. Geo resolves **asynchronously** — Zest holds the UI until the verdict lands, then mounts the right experience. On timeout or error it fails **closed** to `fallback` (default `'consent'`).

Trade-off: in `allow` / `notice` regions, trackers are held for the brief gateway round-trip (~tens of ms) before being released. That's correct fail-closed behavior — nothing leaks while the verdict is in flight.

### Security

Geo verdicts are untrusted input (they cross the network or come from consumer code), so every field is sanitized before any decision — booleans forced, `regulations[]` filtered to short strings and capped, unknown keys dropped (prototype-pollution safe). `geo.endpoint` is validated to http/https only.

## Reacting to the Verdict

Once resolution completes, the `onGeo(action, verdict)` callback and the `zest:geo` DOM event fire:

```javascript
window.ZestConfig = {
  geo: true,
  callbacks: {
    onGeo: (action, verdict) => {
      console.log('Geo action:', action);          // 'consent' | 'notice' | 'allow' | 'block'
      console.log('Regulations:', verdict?.regulations);
    }
  }
};

document.addEventListener('zest:geo', (e) => {
  console.log(e.detail.action, e.detail.verdict);
});
```

## Headless

Geo works in headless too — the resolver lives in the shared core; only the visual notice is full-build-only. Headless renders no UI, so the result reaches you via the `onGeo` callback, the `zest:geo` event, or `await Zest.resolveGeo()`. Tracking is already accepted for `notice` / `allow` by the time it fires:

```javascript
import Zest from '@freshjuice/zest/headless';

Zest.init({
  geo: true,
  callbacks: {
    onGeo: (action, verdict) => {
      if (action === 'consent') myBanner.show();        // GDPR — opt-in
      if (action === 'notice')  myDoNotSellLink.show(); // US — opt-out
      // 'allow' / 'block' — render nothing
    }
  }
});

// …or await it directly:
const { action, verdict } = await Zest.resolveGeo();
```

> When `geo` is set, the `init()` snapshot returns `geoPending: true` until the verdict resolves. Branch on that rather than reading `hasConsentDecision()` immediately — it's still `false` while resolution is in flight.

## Framework Plugins

The [Astro and Eleventy plugins](/docs/getting-started/#framework-integrations-astro-eleventy) serialize config to an inline `window.ZestConfig`, so the serializable geo forms (`geo: true`, `provider`, `endpoint`, `timeout`, `fallback`) work; the function forms (`resolver` / `decide`) do not survive serialization — use them in client-side JS instead.

## Next Steps

- [Configuration Options](/docs/configuration/) - All other config options
- [Events & Callbacks](/docs/events/) - The `zest:geo` event in context
- [API Reference](/docs/api/#zest-resolvegeo) - `Zest.resolveGeo()` and friends

