GeoComply Geolocation: 12-Step FastAPI Gate, 40 Min [2026]

In the United States, an online sportsbook or casino can hold every license it needs and still be shut down for one reason: it let someone place a bet from the wrong side of a state line. GeoComply geolocation is the control that stops that from happening, and by 2026 it sits under roughly 95% of all legal North American online sports bets. GeoComply’s own figures put the platform at more than 1.2 billion geolocation checks a month across 400 million-plus devices, with a published 99% transaction pass rate and location resolution “to within meters.” When that gate fails open, the fine is not the worst part – losing market access in a regulated state is.

This tutorial is a from-scratch, production build of that gate using Python 3.13 and FastAPI. It is deliberately different from a broad microservice walk-through: the spine here is the part most guides skip – the license-token lifecycle, the browser (web) PLC SDK as a first-class citizen, a reason-code decision matrix you can actually ship, and a cost-aware re-check cadence that keeps a per-check bill under control when you are running millions of transactions a day. Every number below traces to GeoComply’s 2026 product pages, US state regulators, and reporting from May–June 2026. Where an exact field name or endpoint is proprietary to your onboarding pack, the code says so instead of inventing it.

Budget 40 minutes to stand up the skeleton and get a green verdict flowing end to end, then a few days to harden it for a regulator’s technical review. By the end you will have a stateless FastAPI service, a Redis-backed token and verdict cache, a React front end that collects the geolocation packet, and an audit trail you can hand to the New Jersey Division of Gaming Enforcement without flinching.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why Geolocation Is iGaming’s Hardest Gate in 2026

Geolocation is the one compliance control that has to be right on every single transaction, in real time, with no human in the loop. KYC happens once at onboarding. AML scoring can run slightly behind the money. Geolocation cannot: the question “is this person physically inside a state where I am licensed, right now?” has to be answered before the bet is accepted and again before the withdrawal clears. Get it wrong once and you have accepted an illegal wager.

The Regulatory Reality Behind the Requirement

Nevada dealt the first licensed online poker hand on April 30, 2013 using a mix of IP address and cellular GPS. New Jersey went further, pioneering the Wi-Fi-triangulation approach that became the industry standard because it was far more precise for players near a state border. Pennsylvania, licensing in 2018, added a wrinkle that trips up newcomers: online play is prohibited inside the state’s brick-and-mortar casinos, so geolocation must block specific buildings, not just draw a state outline. The New Jersey Division of Gaming Enforcement set the testing bar the rest of the country now follows, and its precedents flow downstream to every state that legalizes. If you want the statutory detail, the ICLG New Jersey gambling report and the reference summary on geolocation in online gambling both lay out the state-by-state map.

Three Failure Modes Regulators Actually Test

Auditors probe three things. First, spoofing: can a user fake their location with a VPN, proxy, or a GPS-spoofing app and get a bet through? Second, border leakage: does a player standing 200 metres outside the line get incorrectly approved? Third, session drift: a player passes the check in-state, then drives across a border mid-session – does the gate catch it? A production build has to close all three, and the last one is the reason a single check at login is never enough.

GeoComply’s 2026 Footprint

The market is concentrated. GeoComply handled more than 90% of US online sports betting by 2023 and roughly 95% by 2026. It is not a quiet monopoly, though: on April 17, 2026 the company cut about 18% of its workforce – fewer than 80 people – calling it a “deliberate strategic evolution” driven by regulatory shifts and AI. Commercially it kept expanding, growing its deal with Hard Rock Digital in May 2026 and launching a unified identity platform in Brazil in March 2026. The company is backed by Blackstone and Atairos (2021) and by Arctos Partners and Norwest Venture Partners (2023), and was valued above $1 billion. The takeaway for an integrator: you are building against a dominant but cost-focused vendor, so an efficient integration that minimizes wasted checks is now a first-class engineering goal, not an afterthought.

Metric (2026) Figure Where it comes from
Share of legal North American online sports bets ~95% 2026 market analysis
Geolocation checks processed per month 1.2 billion+ GeoComply Core
Devices with the SDK installed globally 400 million+ GeoComply
Published transaction pass rate 99% GeoComply Core
Location resolution To within meters GeoComply Core
Checks run per geolocation transaction Hundreds GeoComply Core
Workforce reduction (Apr 17, 2026) ~18% (fewer than 80 staff) Company statement

Two things follow from that table. The 99% pass rate means your gate should treat a denial as a genuine signal, not routine noise – a sudden climb in your own deny rate is an incident. And the 1.2-billion-checks-a-month figure is the reason Step 7’s cost discipline exists: at that volume, small per-request inefficiencies compound into large invoices.

How GeoComply Core Works: Packet, License Server, Verdict

The mental model that keeps integrations correct is simple: the client collects, the server decides. GeoComply Core’s SDK – a native library on mobile, a browser plugin or sidecar application on the web – gathers a bundle of signals from the device and packages them into an encrypted geolocation packet. It hands your front end back a transaction reference, not a verdict. Your backend then calls GeoComply server-to-server, with your operator credentials, to fetch the allow/deny decision for that transaction. The device never gets to say “I’m approved.”

The Signals Inside the Packet

Per GeoComply’s Core product documentation, the packet fuses “GPS, GSM, WiFi, plus IP addresses” and then runs “hundreds of location data, device integrity, and identity fraud checks on every geolocation transaction.” Its Data Integrity layer specifically hunts for “VPNs, data centers, anonymizers, proxies, Tor exit nodes, remote desktop detection, WiFi emulators, and GPS spoofing.” Device Integrity flags “jailbroken devices, fake location apps and emulators,” while User Integrity watches for “location jumping, proxy betting, account sharing and account takeover.” A machine-learning risk engine scores each packet against billions of historical transactions. All of that happens in milliseconds.

The Product Map You Are Integrating Against

Do not confuse the components. Core is the geolocation compliance engine. GeoGuard is the VPN/proxy/spoofing detection layer (also sold to streaming firms). IDComply is KYC and age verification. PinPoint and OneComply cover fraud signals and licensing workflow respectively. This tutorial integrates Core (with GeoGuard signals arriving inside the verdict), and it assumes identity is handled elsewhere in your stack.

What a Verdict Contains

When your backend fetches the result, you get a decision plus a set of reason codes. The decision alone is not enough to run a compliant business – you need the reason codes to decide whether a failure means “deny and log,” “ask the user to reposition,” or “silently re-check.” Mapping those codes to actions is where most of the real engineering lives, and it is the core of Steps 6 and 7 below.

Architecture: A Stateless FastAPI Geolocation Gate

The design goal is a small, horizontally scalable service that sits between your front end and your wallet/bet engine. It owns exactly one responsibility: turn a GeoComply transaction reference into a trustworthy, cached, auditable verdict, and enforce that verdict at the moments money moves. Everything else – user accounts, odds, balances – stays out of it.

Why FastAPI and Async httpx

A geolocation gate is almost pure I/O: it waits on GeoComply’s API and on Redis, and does very little CPU work. That is the exact profile FastAPI’s async stack is built for. One Uvicorn worker can hold hundreds of concurrent verdict fetches in flight without threads piling up, which matters on a Sunday NFL slate when tens of thousands of players hit “place bet” inside the same 30-second window. Python also keeps the compliance team – often analysts, not systems engineers – able to read and audit the policy code, which regulators appreciate.

Components and Data Stores

Three moving parts: the FastAPI app (stateless, many replicas), Redis (verdict cache, license-token cache, and a single-flight lock), and an append-only evidence store (Postgres or an object store with object-lock). The service holds no session state in memory, so any replica can serve any request and you can scale to zero-downtime deploys.

The Gate’s Public Contract

Keep the surface tiny – three routes:

  • POST /geo/verify – accepts a GeoComply transaction reference plus the player and session IDs, returns a normalized verdict.
  • GET /geo/status/{session_id} – returns the current cached verdict for a session (used by the front end to decide whether to prompt a fresh check).
  • POST /geo/gate/{action} – the hard gate: given action of bet or withdraw, returns allow/deny and writes an evidence record.

Prerequisites and Pinned Versions

Pin everything. A geolocation gate that behaves differently between staging and production is a compliance incident waiting to happen, and regulators expect reproducible builds. The GeoComply SDK versions are intentionally left as “from your onboarding pack” – GeoComply distributes the web plugin and mobile SDK builds directly to licensed operators, and you should pin the exact build you were issued in your lockfile rather than trusting a public version string.

Accounts, Licenses, and Credentials

Before writing code you need, from GeoComply onboarding: an operator/API key and secret for server-to-server calls, an environment/license identifier issued per environment (staging vs production), the web PLC plugin and/or mobile SDK build, and your licensed-state list with boundary definitions. You also need a KYC provider running in parallel – if you have not wired one yet, our walkthrough of a production KYC onboarding flow pairs cleanly with this gate.

Runtime and Library Versions

Component Minimum version Role
Python 3.13 Runtime (async, typed)
FastAPI 0.115 HTTP framework
Uvicorn 0.32 ASGI server
httpx 0.28 Async server-to-server client
redis-py 5.1 Cache + lock client
Redis server 7.4 Verdict/token cache, single-flight lock
pydantic / pydantic-settings 2.9 / 2.5 Typed models + config
Docker 27 Reproducible build/runtime
GeoComply Web PLC + Mobile SDK onboarding pack Client packet collection

Secret Layout

Nothing sensitive in the image or the repo. Load from your secret manager into environment variables at runtime:

# .env.example  – real values come from your secret manager, never committed
GEOCOMPLY_API_BASE=https://your-region.geocomply-endpoint.example
GEOCOMPLY_API_KEY=op_live_xxxxxxxxxxxxxxxx
GEOCOMPLY_API_SECRET=sk_live_xxxxxxxxxxxxxxxx
GEOCOMPLY_ENV_ID=prod-nj-01
LICENSED_STATES=NJ,PA,MI,WV
REDIS_URL=rediss://cache.internal:6379/0
VERDICT_TTL_SECONDS=60
EVIDENCE_DSN=postgresql://gate:***@evidence.internal:5432/geo_evidence

Steps 1–2: Scaffold the Service and Load Credentials

Start with a clean, typed skeleton. Two files matter first: a settings module that fails loudly if a credential is missing, and the app entrypoint.

Project Layout

geo-gate/
├── app/
│   ├── main.py            # FastAPI app + routes
│   ├── settings.py        # typed config (pydantic-settings)
│   ├── license.py         # Step 3: license-token lifecycle
│   ├── geocomply.py       # Step 5: server-to-server verdict fetch
│   ├── policy.py          # Step 6: reason-code decision matrix
│   ├── cache.py           # Step 7: verdict cache + re-check cadence
│   ├── gate.py            # Step 8: enforce at bet/withdraw
│   └── evidence.py        # Step 9: append-only audit log
├── web/                   # Step 4: React PLC front end
├── Dockerfile
├── compose.yaml
└── pyproject.toml

Typed Settings That Fail Fast

# app/settings.py
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="forbid")

    geocomply_api_base: str
    geocomply_api_key: str
    geocomply_api_secret: str
    geocomply_env_id: str
    licensed_states: str = Field(default="NJ")
    redis_url: str = "redis://localhost:6379/0"
    verdict_ttl_seconds: int = 60
    evidence_dsn: str

    @property
    def states(self) -> set[str]:
        return {s.strip().upper() for s in self.licensed_states.split(",") if s.strip()}


@lru_cache
def get_settings() -> Settings:
    # Raises at startup if any required credential is missing – fail closed, not open.
    return Settings()

Expected result: importing get_settings() with a missing variable raises a ValidationError immediately on boot. That is intentional. A geolocation gate must never start in a half-configured state where it might approve traffic it should not.

Step 3: The GeoComply License-Token Lifecycle

This is the part almost every public guide glosses over, and it is where naive integrations quietly melt down at scale. Server-to-server calls to GeoComply are authenticated with a short-lived credential – call it a license token – that you exchange your API key/secret for. Fetching a fresh token on every verdict request doubles your outbound call volume and can trip GeoComply-side rate limits during a peak. Caching it wrong is worse: a thundering herd of workers all refreshing an expired token at once will hammer the auth endpoint at exactly the moment you are busiest.

Why the Token Is Cached in Redis

The token is shared state across all your stateless replicas, so it belongs in Redis, not in per-process memory. Cache it with a TTL a little shorter than its real lifetime, so you always refresh before expiry rather than after a 401.

Single-Flight Refresh

Use a short-lived Redis lock so that when the token expires, exactly one worker refreshes it while the others wait and then read the new value. This is the single most important reliability pattern in the whole build.

# app/license.py
import time
import httpx
from redis.asyncio import Redis
from .settings import get_settings

TOKEN_KEY = "geo:license:token"
LOCK_KEY = "geo:license:lock"


async def _fetch_new_token(client: httpx.AsyncClient) -> dict:
    s = get_settings()
    # Field names/endpoint follow YOUR onboarding pack; this is the typical shape.
    resp = await client.post(
        f"{s.geocomply_api_base}/auth/token",
        json={"apiKey": s.geocomply_api_key, "environmentId": s.geocomply_env_id},
        headers={"X-Api-Secret": s.geocomply_api_secret},
        timeout=5.0,
    )
    resp.raise_for_status()
    return resp.json()  # e.g. {"token": "...", "expiresIn": 900}


async def get_license_token(redis: Redis, client: httpx.AsyncClient) -> str:
    cached = await redis.get(TOKEN_KEY)
    if cached:
        return cached.decode()

    # Single-flight: only one worker refreshes; others briefly wait and re-read.
    got_lock = await redis.set(LOCK_KEY, "1", nx=True, ex=10)
    if not got_lock:
        for _ in range(20):
            time.sleep(0.05)
            cached = await redis.get(TOKEN_KEY)
            if cached:
                return cached.decode()

    try:
        data = await _fetch_new_token(client)
        ttl = int(data.get("expiresIn", 900)) - 30  # refresh 30s before real expiry
        await redis.set(TOKEN_KEY, data["token"], ex=max(ttl, 30))
        return data["token"]
    finally:
        await redis.delete(LOCK_KEY)

Expected output: under load, your auth-endpoint call rate stays near one refresh per token lifetime, not one per request – the difference between a handful of auth calls a minute and tens of thousands.

Step 4: Integrate the Web PLC SDK in a React Sportsbook

The browser is where most US casino traffic still lives, and it is the surface most tutorials wave past. GeoComply delivers web geolocation through a browser plugin or a lightweight sidecar “helper” application; the AWS gaming team describes exactly this pattern – a “browser plugin or sidecar application for web apps” – in its reference architecture for iGaming geolocation. Your React app loads the PLC client, asks it to collect a packet, and receives a transaction reference to hand to your backend.

Loading the PLC Client and Collecting a Packet

The exact global object and method names come from your issued SDK build. The shape below is representative; swap the import and call signatures for the ones in your onboarding docs. For broader market context, see our coverage of best sweepstakes casino sites.

// web/useGeoCheck.tsx
import { useCallback, useState } from "react";

type GeoState =
  | { status: "idle" }
  | { status: "collecting" }
  | { status: "needs-install" }
  | { status: "ready"; transactionId: string }
  | { status: "error"; message: string };

// The PLC client is provided by your GeoComply SDK build (global or npm package).
declare const GeoComplyClient: {
  configure(opts: { license: string; environmentId: string }): void;
  requestGeolocation(): Promise<{ transactionId: string }>;
  isInstalled(): Promise<boolean>;
};

export function useGeoCheck(license: string, environmentId: string) {
  const [state, setState] = useState<GeoState>({ status: "idle" });

  const collect = useCallback(async () => {
    setState({ status: "collecting" });
    try {
      if (!(await GeoComplyClient.isInstalled())) {
        setState({ status: "needs-install" });
        return;
      }
      GeoComplyClient.configure({ license, environmentId });
      const { transactionId } = await GeoComplyClient.requestGeolocation();
      setState({ status: "ready", transactionId });
    } catch (e) {
      setState({ status: "error", message: String(e) });
    }
  }, [license, environmentId]);

  return { state, collect };
}

Handling the Degraded States Nobody Documents

The happy path is easy. Production is the unhappy path: the plugin is not installed, the helper app is blocked by corporate antivirus, or the browser denies the OS location prompt. Each needs a distinct UI, because “please install the location plugin” and “please enable location services” are different fixes. Never let a missing packet silently become an approval – if collect() does not reach status: "ready", the bet button stays disabled and the backend never receives a transaction reference to verify.

Step 5: Submit the Packet and Fetch the Verdict

Now the server side. Your front end sends the transactionId; your backend attaches the license token and fetches the verdict from GeoComply. This is a hard trust boundary: nothing the client says about its own location is believed.

The Verdict Fetch Client

# app/geocomply.py
import httpx
from redis.asyncio import Redis
from .settings import get_settings
from .license import get_license_token


class GeoComplyError(Exception):
    pass


async def fetch_verdict(
    redis: Redis, client: httpx.AsyncClient, transaction_id: str
) -> dict:
    s = get_settings()
    token = await get_license_token(redis, client)
    try:
        resp = await client.get(
            f"{s.geocomply_api_base}/geolocation/{transaction_id}",
            headers={"Authorization": f"Bearer {token}"},
            timeout=httpx.Timeout(4.0, connect=2.0),
        )
    except httpx.TimeoutException as e:
        raise GeoComplyError("verdict_timeout") from e

    if resp.status_code == 401:
        # Token rotated under us – drop it and let the caller retry once.
        await redis.delete("geo:license:token")
        raise GeoComplyError("token_expired")
    if resp.status_code >= 500:
        raise GeoComplyError("geocomply_unavailable")
    resp.raise_for_status()

    body = resp.json()
    # Normalize to a stable internal shape regardless of upstream field drift.
    return {
        "transaction_id": transaction_id,
        "decision": body.get("decision"),            # "ALLOW" | "DENY" | "REVIEW"
        "reason_codes": body.get("reasonCodes", []),  # e.g. ["VPN_DETECTED"]
        "state": body.get("region"),
        "accuracy_m": body.get("accuracyMeters"),
        "raw": body,
    }

Timeouts, Retries, and Circuit-Breaking

Set aggressive timeouts (a few seconds, not thirty). Retry once on a token-expiry error after clearing the cached token, but do not blindly retry a timeout – a hung upstream call multiplied by retries is how you turn a slow dependency into an outage. Wrap the whole thing in a circuit breaker so that if GeoComply is genuinely down, you fail predictably (see Step 8’s fail-closed rule) instead of stacking requests.

Step 6: The Reason-Code Decision Matrix

A raw “DENY” is not actionable. A player denied for OUT_OF_BOUNDARY should be told they cannot play from their location; a player denied for STALE_LOCATION should simply be re-checked; a player flagged for GPS_SPOOFING should be denied and routed to your fraud team. Encoding that logic as data – not scattered if statements – is what makes it auditable and per-state configurable. This is also where geolocation feeds your wider risk picture; the same spoofing and location-jump signals are gold for AI-driven iGaming fraud detection.

A Representative Reason-Code Matrix

Map GeoComply’s reason codes (use the exact strings from your reason-code dictionary) to one of four actions: ALLOW, DENY, STEP_UP (prompt a fresh, high-accuracy check), or REVIEW (allow-with-flag, escalate to fraud).

Reason code (representative) Meaning Default action
IN_BOUNDARY Inside a licensed state ALLOW
OUT_OF_BOUNDARY Outside every licensed state DENY
BORDER_UNCERTAIN / LOW_ACCURACY Near a line, poor fix STEP_UP
VPN_DETECTED / PROXY_DETECTED Anonymizing network DENY
DATA_CENTER / TOR_EXIT Non-residential origin DENY
REMOTE_DESKTOP Remote-access software DENY
GPS_SPOOFING / FAKE_LOCATION_APP Falsified GPS DENY + REVIEW
EMULATOR / JAILBROKEN Device-integrity failure DENY + REVIEW
LOCATION_JUMP Impossible velocity DENY + REVIEW
STALE_LOCATION / EXPIRED_PACKET Verdict too old STEP_UP
PLUGIN_NOT_INSTALLED No packet collected STEP_UP (block bet)

Encoding the Policy as Data

# app/policy.py
from enum import Enum


class Action(str, Enum):
    ALLOW = "ALLOW"
    DENY = "DENY"
    STEP_UP = "STEP_UP"
    REVIEW = "REVIEW"


# Precedence: DENY beats REVIEW beats STEP_UP beats ALLOW.
REASON_ACTIONS: dict[str, Action] = {
    "IN_BOUNDARY": Action.ALLOW,
    "OUT_OF_BOUNDARY": Action.DENY,
    "BORDER_UNCERTAIN": Action.STEP_UP,
    "LOW_ACCURACY": Action.STEP_UP,
    "VPN_DETECTED": Action.DENY,
    "PROXY_DETECTED": Action.DENY,
    "DATA_CENTER": Action.DENY,
    "TOR_EXIT": Action.DENY,
    "REMOTE_DESKTOP": Action.DENY,
    "GPS_SPOOFING": Action.DENY,
    "FAKE_LOCATION_APP": Action.DENY,
    "EMULATOR": Action.DENY,
    "JAILBROKEN": Action.DENY,
    "LOCATION_JUMP": Action.DENY,
    "STALE_LOCATION": Action.STEP_UP,
    "EXPIRED_PACKET": Action.STEP_UP,
    "PLUGIN_NOT_INSTALLED": Action.STEP_UP,
}
REVIEW_FLAGS = {"GPS_SPOOFING", "FAKE_LOCATION_APP", "EMULATOR",
                "JAILBROKEN", "LOCATION_JUMP"}
PRECEDENCE = [Action.DENY, Action.REVIEW, Action.STEP_UP, Action.ALLOW]


def evaluate(verdict: dict, licensed_states: set[str]) -> tuple[Action, list[str]]:
    codes = verdict.get("reason_codes") or []
    # Belt and braces: enforce the state list yourself, don't only trust the flag.
    if verdict.get("state") and verdict["state"] not in licensed_states:
        return Action.DENY, ["OUT_OF_BOUNDARY"]

    actions = {REASON_ACTIONS.get(c, Action.DENY) for c in codes} or {Action.ALLOW}
    review = bool(REVIEW_FLAGS.intersection(codes))
    for level in PRECEDENCE:
        if level in actions:
            if level == Action.DENY and review:
                return Action.DENY, [c for c in codes if c in REVIEW_FLAGS]
            return level, codes
    return Action.ALLOW, codes

Note the “belt and braces” line: even if GeoComply returns ALLOW, your own code re-checks the returned region against your licensed-state set. Defense in depth means never delegating your entire compliance obligation to a single external boolean.

Step 7: Cost-Aware Caching and Re-Check Cadence

Every verdict fetch is a billable geolocation check. At GeoComply’s scale – 1.2 billion checks a month across the industry – per-check pricing is real money, and re-checking too aggressively can multiply your bill without improving compliance. Re-check too rarely and you miss session drift. The art is caching a fresh verdict for a short, state-configurable window and forcing a new check only on events that actually change the risk. If you are still weighing vendors on exactly this cost axis, our breakdown of how GeoComply’s per-check pricing compares with Xpoint and LocationSmart quantifies the trade-off.

Session-Scoped Verdict Cache

# app/cache.py
import json
from redis.asyncio import Redis


def _key(session_id: str) -> str:
    return f"geo:verdict:{session_id}"


async def cache_verdict(redis: Redis, session_id: str, verdict: dict, ttl: int) -> None:
    await redis.set(_key(session_id), json.dumps(verdict), ex=ttl)


async def get_cached_verdict(redis: Redis, session_id: str) -> dict | None:
    raw = await redis.get(_key(session_id))
    return json.loads(raw) if raw else None


async def invalidate(redis: Redis, session_id: str) -> None:
    # Call this on network change, IP change, or state re-selection.
    await redis.delete(_key(session_id))

What Forces a Fresh Check

Drive re-checks off events, not a blunt timer. The table below is a sane default set; tune the idle interval per state to match its technical standard.

Trigger Force fresh check? Why
Session start / login Always Establish baseline location
Before placing a bet If cached verdict older than TTL Balance cost vs drift
Before a withdrawal Always (fresh) Highest-value hard gate
IP or Wi-Fi SSID change Always Player may have moved networks
Idle N minutes then active Per-state interval Catch cross-border drift
Player switches state selection Always New jurisdiction, new boundary

This event-driven cadence is the single biggest lever on your monthly bill. A sportsbook that re-checks every 30 seconds regardless of activity can spend several times what a well-tuned, event-driven gate spends for identical compliance coverage.

Steps 8–9: Enforce the Gate and Log the Evidence

Collecting a verdict is worthless if you do not enforce it exactly when money moves, and enforcement is worthless to a regulator if you cannot prove it happened. These two steps are inseparable.

The FastAPI Dependency Guard

Express the gate as a dependency so any money-moving route is protected by one line. On a bet, honor a cached ALLOW; on a withdrawal, always fetch fresh.

# app/gate.py
from fastapi import Depends, HTTPException, Request
from .policy import Action, evaluate
from .cache import get_cached_verdict, cache_verdict
from .geocomply import fetch_verdict, GeoComplyError
from .evidence import write_evidence
from .settings import get_settings


async def enforce(request: Request, action: str, session_id: str,
                  transaction_id: str | None = None) -> dict:
    s = get_settings()
    redis, client = request.app.state.redis, request.app.state.http

    verdict = None
    if action == "bet":
        verdict = await get_cached_verdict(redis, session_id)
    if verdict is None:
        if not transaction_id:
            raise HTTPException(428, "fresh_geolocation_required")
        try:
            verdict = await fetch_verdict(redis, client, transaction_id)
        except GeoComplyError:
            # FAIL CLOSED: if we cannot verify, we do not let money move.
            await write_evidence(session_id, action, "DENY", ["VERIFY_FAILED"], None)
            raise HTTPException(503, "geolocation_unavailable")
        await cache_verdict(redis, session_id, verdict, s.verdict_ttl_seconds)

    decision, codes = evaluate(verdict, s.states)
    await write_evidence(session_id, action, decision.value, codes,
                         verdict.get("transaction_id"))
    if decision in (Action.DENY, Action.STEP_UP):
        raise HTTPException(403, {"decision": decision.value, "reason_codes": codes})
    return verdict

The FAIL CLOSED comment is the whole philosophy in two words. When in doubt, deny. A denied legitimate player is a support ticket; an approved illegal wager is a regulatory finding.

The Append-Only Evidence Record

Store, for every decision: the session and player IDs, the action, the decision, the reason codes, the GeoComply transaction ID, and a UTC timestamp – write-once, never updated. This record is what you hand an auditor, and it is the same evidentiary spine you will want feeding a real-time AML transaction-monitoring pipeline, which happens to be built on the same FastAPI foundation. For broader market context, see our coverage of leading online casino sites.

# app/evidence.py  (append-only; use an object-lock store or WORM table)
import asyncpg
from datetime import datetime, timezone
from .settings import get_settings

_pool: asyncpg.Pool | None = None


async def write_evidence(session_id: str, action: str, decision: str,
                         reason_codes: list[str], txn_id: str | None) -> None:
    global _pool
    if _pool is None:
        _pool = await asyncpg.create_pool(get_settings().evidence_dsn)
    await _pool.execute(
        """INSERT INTO geo_evidence
           (session_id, action, decision, reason_codes, geocomply_txn_id, ts_utc)
           VALUES ($1, $2, $3, $4, $5, $6)""",
        session_id, action, decision, reason_codes, txn_id,
        datetime.now(timezone.utc),
    )

Retention should meet the strictest state you operate in – several US regulators expect multi-year retention of geolocation evidence, and it must be tamper-evident. Aligning this with broader duties like the 2026 MGA and UKGC casino-API audit rules keeps one evidence store serving multiple regulators.

Steps 10–12: Containerize, Observe, and Roll Out

The gate is only production-ready when it builds reproducibly, tells you when it is unhealthy, and can be rolled out without a big-bang cutover.

Step 10: Docker and Compose

# Dockerfile
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --upgrade pip && pip install .
COPY app ./app
EXPOSE 8080
# 1 worker per core; the async loop handles concurrency within each.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "2"]
# compose.yaml
services:
  gate:
    build: .
    env_file: .env
    ports: ["8080:8080"]
    depends_on: [redis]
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/healthz').status==200 else 1)"]
      interval: 10s
      timeout: 3s
      retries: 3
  redis:
    image: redis:7.4
    command: ["redis-server", "--appendonly", "yes"]

Step 11: Metrics and SLOs

Instrument four numbers: verdict-fetch latency (p50/p95/p99), verdict-fetch error rate, cache-hit ratio, and the deny/step-up rate by reason code. A sudden spike in VPN_DETECTED denials is either an attack or a broken upstream – either way you want an alert. Set an SLO like “p95 verdict latency under 800 ms” and “verify-error rate under 0.5%,” and page on sustained breaches, not single blips.

Step 12: Canary Rollout and Regulator Sign-off

Never flip a new geolocation gate on for 100% of traffic at once. Canary it: route 1% of a single state’s sessions through the new gate, compare its allow/deny distribution against the incumbent, and widen only when the numbers match. Most US regulators require a technical review or certification (often via a lab such as GLI) before a geolocation change goes live, so bake a sign-off checkpoint into the rollout plan – code-complete is not the same as approved-to-launch.

Security Hardening: Protecting the Gate Itself

A geolocation gate is a high-value target: anyone who can forge its output can gamble illegally, and anyone who can knock it offline can either freeze your business or, if you have built it wrong, force it open. The controls below are the ones a technical reviewer will look for beyond “does it return the right verdict.”

Transport, Secrets, and Credential Rotation

Every hop must be TLS 1.2 or higher – GeoComply’s server-to-server API, your Redis connection (use rediss://), and your evidence database. Keep the API key, secret, and environment ID in a managed secret store, injected at runtime, never baked into the image or logged. Rotate them on a schedule and after any staff departure; because the license token in Step 3 is short-lived and cached separately, a key rotation propagates cleanly without a redeploy. Scope credentials per environment so a leaked staging secret can never touch production traffic.

Replay Protection and Idempotency

A transaction reference should be consumable once. If an attacker captures a valid transactionId from a legitimate in-state session, they must not be able to replay it later from elsewhere. Bind each reference to its session and mark it consumed in Redis after the first verdict fetch, and reject a reference that arrives with a mismatched session or after its packet’s freshness window has passed. This closes the gap between “the packet was valid once” and “the packet is valid now.”

Rate-Limiting the Gate Endpoints

Protect both your wallet and your GeoComply bill by capping how fast a single session can request checks. A simple Redis counter per session per window stops a client bug – or an abuser – from generating thousands of billable checks a minute.

# app/ratelimit.py
from fastapi import HTTPException
from redis.asyncio import Redis


async def allow_request(redis: Redis, session_id: str,
                        limit: int = 30, window_s: int = 60) -> None:
    key = f"geo:rl:{session_id}"
    count = await redis.incr(key)
    if count == 1:
        await redis.expire(key, window_s)
    if count > limit:
        raise HTTPException(429, "geo_check_rate_exceeded")

Pair this with global circuit-breaking on the GeoComply client so that a burst of failures trips the breaker for the whole fleet rather than letting every replica retry into a struggling upstream.

Beyond the Standard Coverage: What Existing GeoComply Guides Miss

Search results for GeoComply integration are dominated by two kinds of content: GeoComply’s own marketing pages and generic “install the plugin” walk-throughs aimed at players, not engineers. Even the better production guides tend to stop at “fetch the verdict server-to-server.” Here is what a real 2026 build has to handle that those sources leave out.

The License-Token Herd and Cost Economics

No public guide covers the single-flight token refresh in Step 3, yet it is the failure mode that takes you down during your busiest hour. Likewise, none of them treat the economics: because every check is billable and the industry runs 1.2 billion of them a month, an event-driven re-check cadence (Step 7) is a design requirement, not a nice-to-have. The cost conversation nobody publishes is that a poorly tuned gate can cost several times a well-tuned one for identical compliance.

The Web SDK’s Degraded States

Because most guides are mobile-first, they skip the browser’s failure modes: plugin-not-installed, antivirus-blocked helper apps, and OS-level location denials – each needing a different UI and none allowed to fail open. Step 4 makes the web surface first-class precisely because that is where the messy edge cases live and where a fail-open bug is most likely.

Reason Codes as Policy, Not Plumbing

The decision matrix in Step 6 – with DENY/STEP_UP/REVIEW precedence and a “belt and braces” re-check of the state list – is the difference between a demo and a system a regulator will certify. And a 2026-specific wrinkle: some states, New Jersey included, have been revisiting how often a session must be re-checked, so encoding cadence as per-state configuration (rather than a hard-coded 30 seconds) future-proofs you against exactly the kind of rule change that landed in the last year.

5 Production Pitfalls to Avoid

  1. Trusting the client verdict. If any code path reads an allow/deny from the browser or mobile app instead of your server-to-server fetch, you have a spoofable gate. The device collects; the server decides. Always.
  2. Failing open on a timeout. The tempting “if GeoComply is slow, let the bet through” is a compliance breach. Fail closed. A slow dependency must never become an open door.
  3. Refreshing the license token per request. Without the single-flight cache from Step 3, a peak-hour token expiry triggers a thundering herd against the auth endpoint and cascades into 401s across your fleet.
  4. Re-checking on a blind timer. A fixed 30-second re-check regardless of activity burns money on idle sessions and still misses the mid-session border crossing. Drive checks off events (Step 7).
  5. Only drawing the state outline. Pennsylvania forbids play inside its own brick-and-mortar casinos; some tribal compacts carve out specific parcels. A naive “is the point inside the polygon” check that ignores these exclusion zones will approve wagers a regulator explicitly prohibits.

8 Troubleshooting Scenarios

  1. Every verdict returns DENY with OUT_OF_BOUNDARY, but users are clearly in-state. Check your LICENSED_STATES and the region-string casing – the “belt and braces” check in Step 6 is case-sensitive by design; normalize both sides.
  2. Intermittent 401s under load. Your token TTL is longer than its real lifetime. Shave the refresh window (the expiresIn - 30 in Step 3) and confirm only one worker holds the lock.
  3. Web players stuck on “collecting” forever. The PLC helper app or plugin is not installed or is antivirus-blocked. Route needs-install to an install page; do not spin indefinitely.
  4. Verdict latency spikes on Sunday afternoons. You are token-refreshing or cache-missing at peak. Verify cache-hit ratio and pre-warm the token before known peak windows.
  5. Withdrawals approved with a stale location. Your withdrawal path is reading the cache. Withdrawals must always fetch fresh (Step 8) – check that action == "withdraw" bypasses the cache.
  6. Legitimate border-town players constantly denied. They are hitting BORDER_UNCERTAIN. Make sure that code maps to STEP_UP (a fresh high-accuracy check), not DENY, so they get a second chance rather than a wall.
  7. GeoComply returns fields your parser does not expect. Upstream field drift. The normalization layer in Step 5 exists for this – log the raw body and add the mapping; never let an unknown field crash the request into a fail-open.
  8. Evidence table shows gaps around deploys. A replica was serving traffic before the evidence pool initialized. Make evidence-store connectivity part of the readiness probe so a pod does not receive traffic until it can write proof.

Advanced Production Tips

Pre-Warm the Token and Cache Before Peaks

If you know the NFL Sunday slate opens at a fixed time, run a scheduled job a minute prior that refreshes the license token and primes hot-path state config. The goal is that the first 10,000 bettors hit a warm gate, not a cold-start token refresh.

Separate the Read and Write Paths

The GET /geo/status read path can serve from cache with generous concurrency, while the POST /geo/gate write path is where you spend your careful timeout and circuit-breaker budget. Splitting them lets the front end poll status cheaply without loading the enforcement path.

Treat SDK Upgrades as Releases

GeoComply ships new SDK and plugin builds regularly. Pin the exact build, test upgrades in the canary lane against your reason-code matrix, and keep the previous build ready to roll back. A silent SDK change that alters a reason-code string can turn STEP_UP into DENY overnight if your matrix has not caught up.

Complete Project Example: A Bet Placed, Start to Finish

Here is the whole flow for a single wager, tying every step together:

  1. A logged-in player in New Jersey clicks “place bet.” The React hook from Step 4 calls collect(); the PLC client gathers a packet from GPS, Wi-Fi, GSM, and IP and returns transactionId=tx_abc123.
  2. The front end POSTs { session_id, transaction_id: "tx_abc123" } to POST /geo/gate/bet.
  3. The enforce dependency (Step 8) checks Redis for a cached verdict. None fresh, so it proceeds to fetch.
  4. Step 3‘s get_license_token returns a cached token from Redis – no auth round-trip needed.
  5. Step 5‘s fetch_verdict calls GeoComply server-to-server with the token, gets {"decision":"ALLOW","reasonCodes":["IN_BOUNDARY"],"region":"NJ"}, and normalizes it.
  6. Step 6‘s evaluate confirms NJ is in the licensed set and the only code maps to ALLOW. Result: Action.ALLOW.
  7. Step 7 caches the verdict for 60 seconds so the player’s next quick bet reuses it – no second billable check.
  8. Step 9 writes an append-only evidence row: session, action=bet, decision=ALLOW, codes=[IN_BOUNDARY], txn=tx_abc123, timestamp. The bet engine is called; the wager is accepted.

Now the same player drives toward the Pennsylvania line and tries again 20 minutes later. Their IP changes, invalidating the cache; the fresh check returns BORDER_UNCERTAIN; Step 6 maps it to STEP_UP; the front end prompts a high-accuracy re-check; the player is now clearly in PA, which is also licensed, and the bet proceeds against the PA boundary – with a fresh evidence row proving it. That is a compliant gate.

Frequently Asked Questions

Do I have to integrate GeoComply’s SDK directly, or can my platform provider do it?

Many operators run on a licensed platform (a turnkey casino or sportsbook provider) that has already integrated GeoComply, in which case you consume verdicts through the platform rather than building this gate yourself. Build it directly when you run your own wallet and bet engine and need full control over caching, cost, and evidence – which is exactly the scenario this tutorial targets.

Why fetch the verdict server-to-server instead of reading it in the browser?

Because anything the client can read, an attacker can forge. The device collects the encrypted packet and returns only a transaction reference; your backend exchanges that reference for the real decision using credentials the client never sees. This trust boundary is the foundation of a compliant integration.

How often must I re-check a player’s location?

There is no single national number – it is set per state and has been changing. The safe pattern is event-driven: always at login and before a withdrawal, on any network change, and on a per-state idle interval. Encode the cadence as configuration so a rule change is a config edit, not a redeploy.

What does a geolocation check cost, and how do I control the bill?

Pricing is per check and negotiated per operator, so there is no public MSRP. Because the industry runs over 1.2 billion checks a month, the controllable lever is your re-check cadence: caching a fresh verdict for a short window and re-checking only on real risk events (Step 7) can cut wasted checks dramatically without reducing coverage.

Does GeoComply detect VPNs and spoofing, or do I need a separate tool?

GeoComply Core’s Data Integrity layer already flags VPNs, data centers, anonymizers, proxies, Tor exit nodes, remote desktop, Wi-Fi emulators, and GPS spoofing, with GeoGuard providing the anti-spoofing engine. You consume those as reason codes in your decision matrix rather than bolting on a second vendor.

What happens if GeoComply’s API is unavailable when a bet comes in?

You fail closed: the bet or withdrawal is denied, and an evidence row records the failed verification. A denied legitimate player is recoverable; an approved illegal wager during an outage is not. Wrap the fetch in a circuit breaker so an upstream outage degrades predictably.

Is this stack (Python 3.13 / FastAPI) suitable, or should I use Node?

Both work; the gate is I/O-bound, so any modern async runtime is fine. Python and FastAPI are chosen here for readable, auditable policy code and strong async ergonomics. The architecture – stateless service, Redis token/verdict cache, append-only evidence – is language-agnostic.

How do I prove compliance to a regulator like the New Jersey DGE?

Through the append-only evidence store from Step 9: every money-moving action has a row with the decision, reason codes, GeoComply transaction ID, and timestamp, retained for the multi-year window your strictest state requires. Combined with your canary rollout records and lab certification, that is the package a technical review expects.

Casey Mitchell

Casey Mitchell

Crypto Casino Editor

Casey Mitchell tracks crypto casinos, on-chain gaming, provably fair RNG, and the regulatory gray zones around US-facing crypto gambling platforms. Reviews wallets, deposit flows, and KYC policies across BTC, ETH, and stablecoin operators.

View all articles