Payment Providers
Get paid the way that works where you and your members are. Every payment method is an optional, independent switch — turn on only what you need. Use just bank transfer if that’s all you want; add cards later; mix and match freely.
How it works: open your app’s Payments tab, flip on a method, and (for Stripe) paste your key. However a member pays, a successful payment unlocks the same paid access — so the rest of your app doesn’t care which provider they used.
Built-in methods
Stripe — cards & subscriptions
Visa, Mastercard and recurring subscriptions worldwide. Paste your Stripe secret key and you’re live; we finish the webhook setup for you so payments unlock access automatically.
Bank transfer / cash — manual
No processor at all. Show your bank details or a QR, the member pays you directly, and you approve it — access unlocks on approval. Perfect when card processors aren’t available or you just want to keep it simple.
Test vs Live
Stripe has its own Test / Live button. Test mode takes fake card payments so you can try the whole checkout-to-access flow safely; Live takes real money. Switching Stripe to Live doesn’t touch anything else.
Bank transfer / cash has no processor to sandbox — it’s simply on or off, and you’re the one confirming each payment is real before you approve it.
Need a provider that isn’t listed?
You are not limited to the built-in methods. Want Razorpay, Khalti, PayPal, Paytm, or a local processor? Serenities can connect any gateway through a small backend function that grants access through the same entitlement ledger Stripe and manual payments use. You don’t have to wait for us to add it as a built-in.
The easiest path: just ask the AI assistant in your app to set it up, for example —
"Add Razorpay as a payment option and unlock the ‘pro’ plan when a payment succeeds."It stores your gateway key securely as a project environment variable (never in page code), opens that gateway’s own checkout, verifies each payment with the gateway’s API, and unlocks paid access — exactly like Stripe.
Prefer to wire it yourself? (for builders)
The AI does exactly what a builder would do by hand. If you’d rather build it, here’s the whole pattern. It rests on one idea: access is entitlement-based, so nothing cares how a payment was collected — a verified payment grants an entitlement, and your access rules check the entitlement, exactly as they would for Stripe or manual payments.
- Put the gateway’s keys in project environment variables (e.g.
RAZORPAY_TEST_KEY_ID/RAZORPAY_TEST_KEY_SECRET, and aRAZORPAY_MODE= test/live toggle) — never hardcode a key in a function. Read them withctx.env.get('NAME'). - On your checkout page, call a backend function to create the order (the secret stays server-side), then open the gateway’s own checkout widget (their JS SDK).
- The gateway’s success callback (or webhook) hits your project’s backend function endpoint — give the function a
publicaccess rule so it’s reachable with no login. Treat its input as untrusted — verify the payment (check the signature, or refetch from the gateway’s API). - On a verified payment, grant one billing period of access. A replay guard makes sure the same payment can’t unlock twice.
crypto.subtle (Web Crypto),btoa, crypto.getRandomValues — not Node’s crypto module.The verification backend function — the security-critical part (real Razorpay pattern, Deno):
// Backend function "verifyRazorpayPayment" — params is UNTRUSTED input
const { razorpay_order_id, razorpay_payment_id, razorpay_signature, appUserId } = params;
if (!razorpay_order_id || !razorpay_payment_id || !razorpay_signature || !appUserId)
throw new Error('Missing required fields');
// Keys come from ENV VARS, never hardcoded. ctx.env.get returns { value }.
const mode = await ctx.env.get('RAZORPAY_MODE') || 'test';
const keySecret = await ctx.env.get(mode === 'live' ? 'RAZORPAY_LIVE_KEY_SECRET' : 'RAZORPAY_TEST_KEY_SECRET');
if (!keySecret) throw new Error('Razorpay ' + mode + ' key secret not configured');
// 1) Verify the signature with Web Crypto (Deno): HMAC-SHA256(order|payment), hex.
const enc = new TextEncoder();
const cryptoKey = await crypto.subtle.importKey('raw', enc.encode(keySecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(razorpay_order_id + '|' + razorpay_payment_id));
const expected = Array.from(new Uint8Array(sigBuf)).map((b) => b.toString(16).padStart(2, '0')).join('');
if (expected !== razorpay_signature) throw new Error('Payment verification failed');
// 2) Replay guard — one grant per gateway payment id.
const key = 'pro';
const got = await ctx.entitlements.get(appUserId, key);
const existing = got && got.entitlement;
if (existing?.source?.paymentId === razorpay_payment_id) return { granted: false, reason: 'already processed' };
// 3) One period per verified charge; a renewal extends from the current expiry.
const PERIOD_MS = 30 * 86400000;
const base = existing?.expiresAt && new Date(existing.expiresAt) > new Date()
? new Date(existing.expiresAt).getTime() : Date.now();
const expiresAt = new Date(base + PERIOD_MS).toISOString();
existing
? await ctx.entitlements.extend(appUserId, key, expiresAt, { source: { provider: 'razorpay', paymentId: razorpay_payment_id } })
: await ctx.entitlements.grant(appUserId, key, { expiresAt, source: { provider: 'razorpay', paymentId: razorpay_payment_id } });
return { granted: true, expiresAt };Why this is safe: access closes automatically when the entitlement lapses, so a missed renewal fails closed even if a callback never arrives. Gate any content with the same rule you’d use for Stripe: { "type": "has_entitlement", "key": "pro" }.
Recurring subscriptions map the same way: each renewal charge → verify → extend one period; a failed payment → suspend with a grace period; a cancellation → revoke. The full, annotated version lives in the in-app AI code guide (payments topic).