# Paywall Worker: Python on Cloudflare

Nov 4, 2026 · Tutorial, Reference, and Repo description

## Overview

A Python Worker sits in front of a static single-page app and decides, for every request, whether to serve the file, count a free page load, or show a paywall. The operator can switch the wall off, make it login-only, change prices, plans, currencies, free paths, comped email domains and enabled providers at any time from `/_pw/admin`, with no redeploy; changes reach every edge location within about 10 seconds.

The SPA's HTML, JavaScript and CSS are served only after `gate.decide()` allows them; payment confirmations arrive as webhooks or returns and are applied once by `fulfill()`.

The code is about 1,900 lines of standard-library Python (no third-party packages, so nothing to vendor and nothing to slow cold starts). The core is runtime-neutral, so all 15 tests run under plain CPython against fake KV, D1 and HTTP. Every file of the tested repository is on the companion [Paywall Worker Source](https://claude.ai/artifact/G2DW2fUmoVaEb5X26gFvfX) page.

### Payment and sign-in coverage

| Market | Method offered | Integration | Why this one |
| --- | --- | --- | --- |
| Global | PayPal | PayPal Orders v2 | Requested; also the single most-used payment brand in Europe |
| European Union | Cards plus Apple Pay and Google Pay, with Wero / iDEAL, Bancontact, SEPA Debit and Klarna | Stripe Checkout | In Worldpay's 2026 report, European e-commerce splits 38% wallets, 19% credit cards, 17% debit, 14% pay-by-bank, 9% BNPL ([Frisbii summary](https://frisbii.com/blog/the-most-popular-payment-methods-in-europe/)). PayPal leads the wallets and is covered above, so the largest remaining block is cards and card-based wallets. One Stripe integration also picks up Wero, which replaces iDEAL in the Netherlands from Q4 2026 ([Stripe](https://support.stripe.com/questions/ideal-to-wero-migration)). |
| India | UPI | Razorpay Payment Links | UPI was 85.5% of India's digital payment volume in H2 2025 per the RBI ([report summary](https://www.chronicleindia.in/current-affairs/16845-upi-accounts-for-85-5-of-digital-payment-volume)) |
| Brazil | Pix | Mercado Pago Payments API | Pix carried 42% of Brazilian e-commerce value in 2025, ahead of credit cards at 40% ([Poder360, citing Worldpay](https://www.poder360.com.br/poder-economia/pix-chega-a-42-dos-pagamentos-on-line-no-brasil/)) |
| Anywhere | Voucher codes | Built in | Gifts, press passes, scholarships, offline sales |

Sign-in: Google, Facebook and GitHub as requested, plus Microsoft, Discord, GitLab (including self-hosted), any OpenID Connect issuer (Auth0, Okta, Keycloak, Zitadel) and passwordless email links.

### Design choices

- Every request runs the Worker first (`run_worker_first: true`), so gated JavaScript, CSS and images are protected, not just `index.html`.
- Durable records (users, identities, entitlements, payments, codes, config) live in D1; short-lived state (sessions, OAuth state, magic-link tokens) lives in KV with a TTL.
- Sessions cache the entitlement for 60 seconds, so a typical asset request costs one KV read and no D1 query.
- Prices are stored per plan in minor units per currency; the buyer's currency comes from Cloudflare's `CF-IPCountry` header and can be switched on the wall.
- Every provider funnels confirmed money into one `fulfill()` that checks amount and currency against the row written at checkout and grants access exactly once, however many webhooks or browser returns arrive.

## Part 1: Tutorial

This walks through building the Worker in the order the pieces depend on each other. Each step shows the code that matters; the [source page](https://claude.ai/artifact/G2DW2fUmoVaEb5X26gFvfX) has every file in full.

### Step 1: Scaffold a Python Worker

Install [uv](https://docs.astral.sh/uv/) and Node.js, then create the project with Cloudflare's `pywrangler` CLI ([docs](https://developers.cloudflare.com/workers/languages/python/)):

```bash
uvx --from workers-py pywrangler init paywall
cd paywall
mkdir -p src/paywall/auth src/paywall/pay tests spa/dist
```

Put your SPA's build output in `spa/dist` (for Vite, `npm run build` then copy or point `assets.directory` at its `dist`).

### Step 2: Route every request through the Worker

The key setting is `run_worker_first: true`. Without it, static assets are served before Python runs and only unmatched paths would be gated ([static assets docs](https://developers.cloudflare.com/workers/static-assets/binding/)). `not_found_handling` keeps client-side routes working by falling back to `index.html`.

```jsonc
{
  "name": "paywall",
  "main": "src/entry.py",
  "compatibility_date": "2026-11-04",
  "compatibility_flags": ["python_workers"],
  "assets": {
    "directory": "./spa/dist",
    "binding": "ASSETS",
    "not_found_handling": "single-page-application",
    "run_worker_first": true
  },
  "d1_databases": [{ "binding": "DB", "database_name": "paywall", "database_id": "..." }],
  "kv_namespaces": [{ "binding": "KV", "id": "..." }]
}
```

### Step 3: Keep the runtime at the edge of the program

`src/entry.py` is the only file that imports from `workers` or `js`. It turns the runtime's request into a plain dataclass, hands it to `App`, and turns the answer back into a response. When the answer is `ServeAsset`, it fetches the file from the `ASSETS` binding and adds headers such as `Cache-Control: private`.

```python
from workers import Response, WorkerEntrypoint
from paywall.app import App
from paywall.web import Req, ServeAsset

async def to_req(request) -> Req:
    js = getattr(request, "js_object", request)
    headers = {str(k).lower(): str(v) for k, v in js.headers.entries()}
    body = b""
    if js.method not in ("GET", "HEAD"):
        body = (await js.clone().arrayBuffer()).to_bytes()
    return Req(str(js.method), str(js.url), headers, body)

class Default(WorkerEntrypoint):
    async def fetch(self, request):
        result = await App(self.env).handle(await to_req(request))
        if isinstance(result, ServeAsset):
            js_request = getattr(request, "js_object", request)
            asset = await self.env.ASSETS.fetch(js_request)
            if not result.headers:
                return asset
            from js import Response as JsResponse
            out = JsResponse.new(asset.body, asset)
            for name, value in result.headers.items():
                out.headers.set(name, value)
            return out
        return Response(result.body, status=result.status, headers=result.headers)
```

The payoff is testability: everything below `entry.py` runs under CPython with fakes, so you can test OAuth and webhooks without a network or a Workers runtime.

### Step 4: Write the access decision as a pure function

`gate.decide()` is the paywall policy in 20 lines. It takes the config and what we know about the visitor, and returns `allow`, `meter` (serve and count one free page load) or `wall`.

```python
def decide(cfg, path, *, user, entitled, meter_used, is_document):
    if cfg["mode"] == "off" or is_public(cfg, path):
        return ALLOW
    if user and (cfg["mode"] == "login" or entitled or is_comped(cfg, user)):
        return ALLOW
    limit = cfg["metered_free_loads"]
    if limit:
        if is_document and meter_used < limit:
            return METER
        if not is_document and 0 < meter_used <= limit:
            return ALLOW  # let a metered page's JS and CSS finish loading
    return WALL
```

If your business model differs (meter per article, free weekends, a student tier by country), this is the function to change; its tests in `tests/test_paywall.py` show the expected shape.

### Step 5: Sessions that cost one KV read

The browser holds one opaque `__Host-pw` cookie (HttpOnly, Secure, SameSite=Lax). KV stores the session under the SHA-256 of the token, with the user's id, verified email and a cached entitlement expiry refreshed at most once a minute. Sign-in rotates the token to prevent session fixation. Anonymous visitors only get a session when they need one: to start an OAuth flow or to be metered.

### Step 6: Make configuration data, not code

`config.py` defines defaults and a strict `validate()`. The live config is one JSON row in D1, cached per isolate for 10 seconds. An invalid update is rejected with a message; an invalid stored row falls back to defaults rather than taking the site down.

```bash
# Turn on metering: 3 free page loads, then the wall
curl -X PUT https://app.example/_pw/admin/config \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"metered_free_loads": 3}'

# Drop the paywall entirely, e.g. during an outage or a free week
curl -X PUT https://app.example/_pw/admin/config \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"mode": "off"}'
```

Admins listed in `admin_emails` (or the `ADMIN_EMAILS` secret, for bootstrapping) can do the same from the `/_pw/admin` page after signing in.

### Step 7: Sign-in from a provider table

`auth/oauth.py` drives every OAuth provider from one table. Adding one is a dictionary entry:

```python
"github": {
    "label": "GitHub",
    "authorize": "https://github.com/login/oauth/authorize",
    "token": "https://github.com/login/oauth/access_token",
    "userinfo": "https://api.github.com/user",
    "emails": "https://api.github.com/user/emails",
    "scope": "read:user user:email",
    "pkce": True,
},
```

The flow: `/_pw/login/github` stores a random `state` in KV bound to the browser's session hash (plus a PKCE verifier), then redirects. The callback checks that the state exists, matches the provider and was started by this same browser, exchanges the code, reads the profile, and maps it with `normalize()`. Accounts are linked across providers only when both sides have a verified email; Microsoft addresses are never treated as verified because tenant admins can set them freely.

Magic links (`auth/magic.py`) send a one-time, 15-minute token through any JSON email API (Resend by default). The link opens a confirm button that POSTs, so mail scanners that prefetch links cannot burn the token.

### Step 8: One payment contract, five providers

Each module in `pay/` exposes `configured()`, `create()`, `on_return()` and `webhook()`. Checkout writes a `payments` row first (our `ref`, plan, days, amount, currency), then sends `ref` to the provider as its reference field. Whatever confirms the payment later (webhook, browser return, or a poll) calls the same function:

```python
async def fulfill(app, ref, provider, amount, currency, external_id=""):
    row = await app.store.payment_get(ref)
    if not row or row["provider"] != provider:
        return False
    if row["status"] == "paid":
        return True  # duplicate notification
    if int(amount) != int(row["amount"]) or currency.upper() != row["currency"]:
        await app.store.payment_transition(ref, "mismatch", external_id)
        return False
    if await app.store.payment_transition(ref, "paid", external_id):
        await app.store.grant(row["user_id"], int(row["days"]), f"{provider}:{external_id}")
    return True
```

`payment_transition()` is a single `UPDATE ... WHERE status IN ('created', 'pending')`; only the caller that changes a row grants access, so retries and races cannot double-grant.

What differs per provider is how payment is confirmed:

- Stripe: HMAC-SHA256 over `timestamp.body` in `Stripe-Signature`, 5-minute tolerance; `checkout.session.completed` with `payment_status: paid`, or `async_payment_succeeded` for SEPA and other delayed methods.
- PayPal: capture on return, idempotent via `PayPal-Request-Id`; the webhook is checked by PayPal's verify-webhook-signature API.
- Razorpay: the redirect carries an HMAC over link id, reference, status and payment id; the webhook is an HMAC of the raw body.
- Mercado Pago (Pix): no redirect at all. The Worker shows the QR code and copy-and-paste code, polls, and on webhook verifies `x-signature` over `id:...;request-id:...;ts:...;` before fetching the payment to read its status.

### Step 9: Run it

```bash
uv run pytest                       # 15 tests, under a second
cp .dev.vars.example .dev.vars      # fill in sandbox keys
npx wrangler d1 execute paywall --local --file=schema.sql
uv run pywrangler dev               # http://localhost:8787
```

Visit `/`, get bounced to `/_pw/wall`, sign in, buy a pass with a sandbox card, and land back in the app. To receive Stripe webhooks locally, run `stripe listen --forward-to localhost:8787/_pw/webhook/stripe` and put the printed `whsec_...` in `.dev.vars`. For PayPal, Razorpay and Mercado Pago, expose the dev server with a tunnel (for example `cloudflared tunnel --url http://localhost:8787`) and register that URL in each sandbox dashboard.

### Step 10: Let your SPA know who is reading

The SPA can call `GET /_pw/me` for `{user, entitled, entitled_until, mode, wall}` to show a name, an upgrade link, or a sign-out button. Any gated non-HTML request returns 401 (signed out) or 402 (signed in, unpaid) with the wall URL, so a client-side fetch wrapper can redirect on those codes.

## Part 2: Reference

### Routes

All paywall routes live under `/_pw/` so they cannot collide with the SPA's own routes. State-changing requests must come from the site's own origin (checked via `Origin` or `Sec-Fetch-Site`), except webhooks and calls bearing the admin token.

| Method | Path | Purpose | Who |
| --- | --- | --- | --- |
| GET | `/_pw/wall?next=/path` | Paywall: sign-in buttons, plans, payment buttons, voucher form | Anyone |
| GET | `/_pw/me` | JSON: user, entitled, entitled\_until, mode | Anyone |
| GET | `/_pw/login/{provider}?next=` | Start OAuth | Anyone |
| GET | `/_pw/callback/{provider}` | OAuth redirect URI | Provider |
| POST | `/_pw/magic` | Send an email sign-in link (`email`, `next`) | Anyone |
| GET, POST | `/_pw/magic/verify?t=` | Confirm page, then sign-in | Link holder |
| POST | `/_pw/logout` | End the session | Signed in |
| POST | `/_pw/checkout/{provider}` | Start payment (`plan`, `currency`) | Signed in |
| GET | `/_pw/return/{provider}` | Browser return from the provider | Buyer |
| GET | `/_pw/status/{ref}` | Poll a payment: created, pending, paid, failed, mismatch | The buyer |
| POST | `/_pw/webhook/{provider}` | Provider notifications | Signed by provider |
| POST | `/_pw/redeem` | Redeem a voucher (`code`) | Signed in |
| GET | `/_pw/admin` | Admin page | Admin |
| GET, PUT | `/_pw/admin/config` | Read or update config (partial JSON is merged) | Admin |
| POST | `/_pw/admin/grant` | Give a user access (`email`, `days`; 0 = lifetime) | Admin |
| POST | `/_pw/admin/codes` | Mint vouchers (`count` up to 500, `days`, `uses`, `expires_at`) | Admin |
| GET | `/_pw/admin/payments?limit=50` | Recent payments | Admin |

Any other path is the SPA. Gated HTML requests redirect to the wall; gated non-HTML requests get 401 (signed out) or 402 (signed in, unpaid) with `{"error": "payment_required", "wall": ...}`.

### Configuration keys

| Key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `mode` | `off`, `login`, `paid` | `paid` | No wall; sign-in only; sign-in plus a pass |
| `public_paths` | list of globs | `/favicon.ico`, `/robots.txt`, `/public/*` | Always served, never walled |
| `metered_free_loads` | integer | 0 | Free page loads per browser session before the wall; 0 disables |
| `plans` | list | 30-day pass, lifetime | `{id, name, days, prices}`; `days: 0` is lifetime; `prices` maps currency to integer minor units |
| `default_currency` | ISO 4217 | `USD` | Used when the country is not mapped |
| `country_currency` | object | IN to INR, BR to BRL, GB to GBP, euro area to EUR | Picks the buyer's currency from `CF-IPCountry` |
| `payment_providers` | list | all five | Which payment buttons may appear (each also needs its secrets) |
| `provider_currencies` | object | Razorpay INR, Mercado Pago BRL, Stripe and PayPal eight majors | A button appears only for currencies its provider accepts |
| `auth_providers` | list | google, facebook, github, magic | Which sign-in options may appear (each also needs its secrets) |
| `admin_emails` | list | empty | Verified emails that can open `/_pw/admin`; they also get free access |
| `free_email_domains` | list | empty | Verified emails at these domains get free access (for example a university) |
| `razorpay_upi_only` | boolean | false | Create UPI-only Razorpay links instead of the full hosted checkout |
| `session_days` | integer | 30 | Session lifetime in KV and the cookie |
| `site_name`, `wall_message` | string |  | Text on the wall and in emails |

Unknown keys, non-integer prices and duplicate plan ids are rejected with HTTP 400 and a message. A purchase records the plan's `days` at checkout time, so later price or plan edits never change what someone already paid for.

### Secrets and variables

Set secrets with `npx wrangler secret put NAME` (production) or in `.dev.vars` (local). A provider appears on the wall only when its secrets are present and it is listed in config.

| Name | Needed for | Notes |
| --- | --- | --- |
| `ADMIN_TOKEN` | Admin API from scripts | Long random string; sent as `Authorization: Bearer` |
| `ADMIN_EMAILS` | Bootstrapping the first admin | Comma-separated; must match a verified sign-in email |
| `PUBLIC_ORIGIN` | Optional | Canonical `https://host` used to build redirect URIs |
| `SESSION_COOKIE` | Optional | Override the `__Host-pw` cookie name |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google sign-in |  |
| `FACEBOOK_CLIENT_ID`, `FACEBOOK_CLIENT_SECRET` | Facebook sign-in | App ID and App Secret; `FACEBOOK_GRAPH_VERSION` var, default `v23.0` |
| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | GitHub sign-in | An OAuth App (not a GitHub App) |
| `MICROSOFT_`, `DISCORD_`, `GITLAB_CLIENT_ID` and `_CLIENT_SECRET` | Those sign-ins | `GITLAB_BASE_URL` for self-hosted GitLab |
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_LABEL` | Generic OpenID Connect | Endpoints come from the issuer's discovery document |
| `EMAIL_API_KEY`, `EMAIL_FROM`, `EMAIL_API_URL` | Magic links | Defaults target Resend; any API taking `{from, to, subject, html}` works |
| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | Stripe | `sk_test_...`, `whsec_...` |
| `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `PAYPAL_ENV` | PayPal | `PAYPAL_ENV` is `sandbox` or `live` |
| `RAZORPAY_KEY_ID`, `RAZORPAY_KEY_SECRET`, `RAZORPAY_WEBHOOK_SECRET` | Razorpay UPI |  |
| `MERCADOPAGO_ACCESS_TOKEN`, `MERCADOPAGO_WEBHOOK_SECRET` | Mercado Pago Pix | Secret from the Webhooks page of your application |

### Sign-in providers

Register the redirect URI `https://YOUR_HOST/_pw/callback/{provider}` in each console.

| Provider | Scopes | PKCE | Email used for account linking |
| --- | --- | --- | --- |
| Google | `openid email profile` | Yes | When `email_verified` is true |
| Facebook | `email,public_profile` | No | Yes; Graph returns only confirmed emails |
| GitHub | `read:user user:email` | Yes | Primary email, when GitHub marks it verified |
| Microsoft | `openid email profile` | Yes | Never (tenant admins can set arbitrary addresses) |
| Discord | `identify email` | No | When Discord marks the account verified |
| GitLab | `openid email profile` | Yes | When `email_verified` is true |
| Any OIDC issuer | `openid email profile` | Yes | When `email_verified` is true |
| Email link | none | n/a | Always; possession of the inbox is the proof |

### Payment providers

| Provider | Methods | Webhook URL to register | Events used | How confirmation is verified |
| --- | --- | --- | --- | --- |
| Stripe | Cards, Apple Pay, Google Pay, Link, Wero / iDEAL, Bancontact, SEPA, Klarna (whatever the Dashboard enables) | `/_pw/webhook/stripe` | `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed` | HMAC-SHA256 of `t.body`, 300 s tolerance |
| PayPal | PayPal balance, cards, Pay Later where offered | `/_pw/webhook/paypal` | `PAYMENT.CAPTURE.COMPLETED` | Capture response on return; webhook via PayPal's verify-webhook-signature API |
| Razorpay | UPI (plus cards, netbanking, wallets unless UPI-only) | `/_pw/webhook/razorpay` | `payment_link.paid` | Callback HMAC over link id, reference, status and payment id joined by pipes; webhook HMAC of the raw body |
| Mercado Pago | Pix | Sent per payment as `notification_url` | `payment` | `x-signature` HMAC over `id:..;request-id:..;ts:..;`, then the payment is fetched from the API |
| Voucher codes | n/a | None | n/a | Atomic decrement of `uses_left` |

### Data model (D1)

| Table | Key | Holds |
| --- | --- | --- |
| `config` | `id = 1` | The validated config JSON |
| `users` | `id` | Email, whether it is verified, display name |
| `identities` | `(provider, subject)` | Which provider accounts belong to which user |
| `entitlements` | `user_id` | Access expiry in Unix seconds; 253402300799 means lifetime; new passes stack on unexpired time |
| `payments` | `ref` | One row per checkout: provider ids, plan, days, amount, currency, status |
| `codes` | `code` | Vouchers with days, remaining uses, optional expiry |

KV keys: `sess:{sha256(token)}` (sessions), `oauth:{state}` (10 minutes), `magic:{sha256(token)}` (15 minutes), `magic-rate:{sha256(email)}` (at most 5 links per hour).

### Security properties

- Gated responses carry `Cache-Control: private, no-cache` and `Vary: Cookie`, so a shared cache never serves paid content to someone else.
- OAuth `state` is single-use, expires in 10 minutes, and is bound to the browser that started the flow; post-login `next` must be a same-origin path, which blocks open redirects.
- Amounts and currencies are checked against the server-side row, never trusted from the client or the redirect.
- The admin token is compared in constant time; session admins are recognised only by verified email.
- Webhook endpoints reject unsigned or stale payloads with HTTP 400 and grant nothing.
- Not built in: per-IP rate limiting of sign-in and checkout (add the Workers Rate Limiting binding or a WAF rule), refunds (do them in each provider's dashboard and revoke with an admin grant), and recurring subscriptions (passes stack instead).

### Python Workers gotchas this code works around

- Pyodide turns Python `None` into JavaScript `undefined`, which D1's `bind()` rejects, so the schema uses `''` and `0` instead of NULL.
- KV `put` options must arrive as a plain JavaScript object; `store._js_opts()` converts with `Object.fromEntries` so `expirationTtl` is not silently lost as a `Map`. KV's minimum TTL is 60 seconds.
- Webhook signatures are over raw bytes, so `entry.py` reads `arrayBuffer()` from a clone of the request rather than re-serialising parsed JSON.
- `getattr(env, NAME, "")` is used for every optional secret, so a missing secret disables a provider instead of raising.

## Part 3: README.md

### paywall-worker

An optional, operator-adjustable paywall for any static single-page app, written in pure Python for Cloudflare Python Workers. Sign in with Google, Facebook, GitHub, Microsoft, Discord, GitLab, any OIDC issuer, or an email link. Pay with Stripe (cards, wallets, Wero / iDEAL, SEPA), PayPal, UPI via Razorpay, Pix via Mercado Pago, or a voucher code.

### Repository layout

```text
paywall-worker/
├── wrangler.jsonc          Worker, assets, D1 and KV bindings
├── pyproject.toml          no runtime dependencies; dev: workers-py, pytest
├── schema.sql              D1 tables
├── .dev.vars.example       every secret, with sandbox placeholders
├── spa/dist/               your built SPA (index.html, assets/...)
├── src/
│   ├── entry.py            Workers entry point (only file touching the runtime)
│   └── paywall/
│       ├── app.py          router, wall, checkout, admin
│       ├── gate.py         the access decision
│       ├── config.py       defaults and validation
│       ├── session.py      KV-backed sessions
│       ├── store.py        D1 and KV access
│       ├── web.py          request/response types
│       ├── crypto.py       HMAC, PKCE, tokens
│       ├── net.py          outbound fetch
│       ├── auth/oauth.py   OAuth and OIDC providers
│       ├── auth/magic.py   email links
│       └── pay/            stripe, paypal, razorpay, mercadopago, codes
└── tests/                  CPython tests with fake KV, D1 and HTTP
```

### Prerequisites

- A Cloudflare account with Workers enabled (D1 and KV are included on the free plan).
- [uv](https://docs.astral.sh/uv/) and Node.js 20 or newer.
- Sandbox or test credentials for each provider you plan to enable. Any you skip simply do not appear.

### Installation

```bash
git clone https://github.com/you/paywall-worker && cd paywall-worker
uv sync                      # installs pywrangler and pytest into .venv
uv run pytest                # 15 tests should pass
```

### Build

The Worker has no build step: `pywrangler` uploads the Python sources as they are. Build your SPA so its output lands in `spa/dist`:

```bash
(cd ../my-spa && npm ci && npm run build) && rm -rf spa/dist && cp -r ../my-spa/dist spa/dist
```

If your SPA has public files that must load before sign-in (a logo on the wall, Open Graph images), put them under `spa/dist/public/` or add their globs to `public_paths`.

### Local development

```bash
cp .dev.vars.example .dev.vars           # fill in sandbox keys
npx wrangler d1 execute paywall --local --file=schema.sql
uv run pywrangler dev                    # http://localhost:8787
```

Browsers accept `Secure` cookies on `http://localhost`, so the `__Host-pw` cookie works in dev. Register `http://localhost:8787/_pw/callback/{provider}` as an extra redirect URI in each OAuth console while developing.

### Deployment

1. Create storage and paste the printed ids into `wrangler.jsonc`:

```bash
npx wrangler d1 create paywall
npx wrangler kv namespace create KV
npx wrangler d1 execute paywall --remote --file=schema.sql
```

2. Add secrets (repeat for each provider you enable):

```bash
npx wrangler secret put ADMIN_TOKEN        # e.g. output of: openssl rand -hex 32
npx wrangler secret put ADMIN_EMAILS       # you@example.com
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET
# ...facebook, github, paypal, razorpay, mercadopago, email as needed
```

3. Deploy, then attach a custom domain in the dashboard (Workers & Pages, your Worker, Settings, Domains & Routes):

```bash
uv run pywrangler deploy
```

4. Point each provider at the deployed host:

| Provider | Where | Value |
| --- | --- | --- |
| Google | Cloud Console, APIs & Services, Credentials, OAuth client (Web) | Redirect URI `https://HOST/_pw/callback/google` |
| Facebook | Meta for Developers, your app, Facebook Login, Settings | Valid OAuth Redirect URI `https://HOST/_pw/callback/facebook` |
| GitHub | Settings, Developer settings, OAuth Apps | Callback URL `https://HOST/_pw/callback/github` |
| Stripe | Developers, Webhooks | `https://HOST/_pw/webhook/stripe`; enable your payment methods under Settings, Payment methods |
| PayPal | Developer Dashboard, your app, Webhooks | `https://HOST/_pw/webhook/paypal`; copy its id to `PAYPAL_WEBHOOK_ID` |
| Razorpay | Dashboard, Accounts & Settings, Webhooks | `https://HOST/_pw/webhook/razorpay`, event `payment_link.paid` |
| Mercado Pago | Your integrations, your application, Webhooks | Copy the signing secret to `MERCADOPAGO_WEBHOOK_SECRET`; the URL is sent per payment |

5. Sign in at `https://HOST/_pw/wall` with an `ADMIN_EMAILS` address, open `/_pw/admin`, and set prices, plans and providers. Flip `PAYPAL_ENV` to `live` and swap test keys for live ones when ready.

### Operating

- Pause the paywall: set `mode` to `off`. Make it free but members-only: `login`.
- Run a promotion: add a plan, mint voucher codes, or set `metered_free_loads`.
- Comp a person: `POST /_pw/admin/grant` with their email after they have signed in once.
- Comp an institution: add its domain to `free_email_domains`.
- Watch logs with `npx wrangler tail`; payments appear on the admin page and in the `payments` table.

### Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| A sign-in button is missing | Provider not in `auth_providers`, or its client id or secret is unset |
| A payment button is missing | Provider not configured, or the shown currency is not in its `provider_currencies` |
| "Sign-in was started in another browser" | Cookies blocked, or the callback host differs from the login host; set `PUBLIC_ORIGIN` |
| Paid but still walled | Webhook not reaching the Worker; check the provider's delivery log and the webhook secret |
| Webhook returns 400 | Wrong signing secret, or clock skew over 5 minutes (Stripe) or 10 minutes (Mercado Pago) |
| Config change not visible | Up to 10 seconds of per-isolate caching; reload |

### License

MIT. Provider names are trademarks of their owners; this project is not affiliated with any of them.

## Sources

- [Python Workers are now generally available](https://blog.cloudflare.com/python-workers-ga/), Cloudflare blog, 21 Sep 2026
- [Python Workers](https://developers.cloudflare.com/workers/languages/python/), [The Basics](https://developers.cloudflare.com/workers/languages/python/basics/) and [FFI](https://developers.cloudflare.com/workers/languages/python/ffi/), Cloudflare docs
- [Static assets: configuration and bindings](https://developers.cloudflare.com/workers/static-assets/binding/), Cloudflare docs
- [The most popular payment methods in Europe](https://frisbii.com/blog/the-most-popular-payment-methods-in-europe/), Frisbii, summarising the Worldpay Global Payments Report 2026
- [iDEAL to Wero migration](https://support.stripe.com/questions/ideal-to-wero-migration), Stripe support
- [UPI accounts for 85.5% of digital payment volume](https://www.chronicleindia.in/current-affairs/16845-upi-accounts-for-85-5-of-digital-payment-volume), Chronicle India, on the RBI Payment Systems Report
- [Pix chega a 42% dos pagamentos on-line no Brasil](https://www.poder360.com.br/poder-economia/pix-chega-a-42-dos-pagamentos-on-line-no-brasil/), Poder360, citing the Worldpay Global Payments Report 2026
