type: decision
status: active
timestamp: 2026-06-22
tags: [decision, billing, webhook, razorpay, paddle, play-billing, ms-store, cf-pages-function]

Billing webhook architecture: CF Pages Function → Firestore

Razorpay (INR) + Paddle (ROW) + Play Billing + MS Store \ webhook handlers all land on a single CF Pages Function endpoint per provider\ \ (4 endpoints total). The function (1) verifies the provider's webhook signature,\ \ (2) writes user subscription state to Firestore, (3) returns 200. Zero CF Workers\ \ in the hot path of payments. Each provider's pricing page button is a direct platform\ \ link \u2014 no proxy through our infra. ~1 Pages Function call per purchase."

Billing webhook architecture

Decision

4 webhook endpoints (one per payment provider), each implemented as a Cloudflare Pages Function:

ProviderWebhook URLAuth check
Razorpay (INR)oriz.in/api/billing-webhook/razorpayHMAC-SHA256 via X-Razorpay-Signature header + shared secret
Paddle (ROW)oriz.in/api/billing-webhook/paddleHMAC-SHA256 via Paddle-Signature header + shared secret
Play Billing (Android)oriz.in/api/billing-webhook/playService-account JWT verification via Authorization: Bearer
Microsoft Store (Windows)oriz.in/api/billing-webhook/ms-storeOAuth2 service principal

Why CF Pages Functions (not Workers)

Pages Function is the simplest no-card option. 1 function call per purchase ? far below quota.

Webhook payload contract

Each handler normalizes the provider’s payload to a single internal shape before writing to Firestore:

type SubscriptionUpdate = {
  userId: string;          // looked up from provider's customer.email
  tier: 'ad-free' | 'pro';
  status: 'active' | 'expired' | 'cancelled';
  expiresAt: number;       // unix ms
  source: 'razorpay' | 'paddle' | 'play' | 'ms-store';
  externalRef: string;     // provider's subscription/order ID
};

// Write path:
firestore.doc(`users/${userId}/subscriptions/${source}`).set(update)

User lookup: provider passes customer email at checkout. Pages Function queries Firestore users collection by email to find uid. If user doesn’t exist yet (first purchase), the Pages Function creates a pending users/{tempId} doc; account-link happens on first login via Firebase Auth.

Firestore reads in apps

Each app’s BaseLayout pulls users/{uid}/subscriptions/* on auth state change (Firestore client SDK, real-time listener). On change:

Cross-app SSO via Firebase Auth means a single subscription unlocks all apps.

Pricing page architecture

Per single-pricing-page-package.md, the /pricing route is shipped from @chirag127/astro-billing and mounted on every app. Buttons on the page are direct links to the provider’s hosted checkout (Razorpay Payment Page, Paddle Checkout Page, Play Billing flow, MS Store IAP).

On purchase: provider’s hosted checkout completes ? posts webhook ? CF Pages Function writes Firestore ? apps see the change live via Firestore listener.

Webhook secrets

Stored at chirag127 GH org level (per github-org-level-secrets.md):

Added to templates/.env.example as DOCUMENTED env vars (with comments explaining each provider’s setup URL).

Cross-refs


Edit on GitHub · Back to index