Paywall Worker: Python on Cloudflare
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 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). 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). |
| India | UPI | Razorpay Payment Links | UPI was 85.5% of India's digital payment volume in H2 2025 per the RBI (report summary) |
| 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) |
| 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 justindex.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-IPCountryheader 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 has every file in full.
Step 1: Scaffold a Python Worker
Install uv and Node.js, then create the project with Cloudflare's pywrangler CLI (docs):
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). not_found_handling keeps client-side routes working by falling back to index.html.
{
"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.
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.
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.
# 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:
"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:
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.bodyinStripe-Signature, 5-minute tolerance;checkout.session.completedwithpayment_status: paid, orasync_payment_succeededfor 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-signatureoverid:...;request-id:...;ts:...;before fetching the payment to read its status.
Step 9: Run it
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 |
|---|---|---|---|
openid email profile |
Yes | When email_verified is true |
|
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-cacheandVary: Cookie, so a shared cache never serves paid content to someone else. - OAuth
stateis single-use, expires in 10 minutes, and is bound to the browser that started the flow; post-loginnextmust 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
Noneinto JavaScriptundefined, which D1'sbind()rejects, so the schema uses''and0instead of NULL. - KV
putoptions must arrive as a plain JavaScript object;store._js_opts()converts withObject.fromEntriessoexpirationTtlis not silently lost as aMap. KV's minimum TTL is 60 seconds. - Webhook signatures are over raw bytes, so
entry.pyreadsarrayBuffer()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
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 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
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:
(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
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
- Create storage and paste the printed ids into
wrangler.jsonc:
npx wrangler d1 create paywall
npx wrangler kv namespace create KV
npx wrangler d1 execute paywall --remote --file=schema.sql
- Add secrets (repeat for each provider you enable):
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
- Deploy, then attach a custom domain in the dashboard (Workers & Pages, your Worker, Settings, Domains & Routes):
uv run pywrangler deploy
- Point each provider at the deployed host:
| Provider | Where | Value |
|---|---|---|
| Cloud Console, APIs & Services, Credentials, OAuth client (Web) | Redirect URI https://HOST/_pw/callback/google |
|
| 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 |
- Sign in at
https://HOST/_pw/wallwith anADMIN_EMAILSaddress, open/_pw/admin, and set prices, plans and providers. FlipPAYPAL_ENVtoliveand swap test keys for live ones when ready.
Operating
- Pause the paywall: set
modetooff. 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/grantwith 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 thepaymentstable.
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, Cloudflare blog, 21 Sep 2026
- Python Workers, The Basics and FFI, Cloudflare docs
- Static assets: configuration and bindings, Cloudflare docs
- The most popular payment methods in Europe, Frisbii, summarising the Worldpay Global Payments Report 2026
- iDEAL to Wero migration, Stripe support
- 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, Poder360, citing the Worldpay Global Payments Report 2026