Tutorial: a store with Stripe or Razorpay
Sell products with Stripe Checkout or Razorpay: prices that clients can't change, a checkout function, a public webhook function that confirms each payment with the provider before marking an order paid, and orders each customer can read.
You will build the payment backend for a small store: a product catalogue anyone can read, orders only their customer can read, a create-checkout function that starts a payment, and a webhook function that marks orders paid. Clients can never set a price or mark an order paid themselves. Code is shown for Stripe and Razorpay; use whichever you sell with.
#1. Products and orders
create table products (
id bigint generated always as identity primary key,
name text not null,
price_minor int not null check (price_minor > 0), -- cents or paise
currency text not null default 'usd' -- 'inr' for Razorpay
);
create table orders (
id bigint generated always as identity primary key,
owner_id uuid not null,
product_id bigint not null references products,
amount_minor int not null,
currency text not null,
status text not null default 'pending' check (status in ('pending', 'paid')),
provider_ref text unique, -- Stripe session id or Razorpay order id
paid_at timestamptz,
created_at timestamptz not null default now()
);
alter table products enable row level security;
alter table products force row level security;
alter table orders enable row level security;
alter table orders force row level security;
create policy "anyone reads products" on products for select using (true);
create policy "customers read own orders" on orders for select using (owner_id = auth.uid());
-- No insert or update policies: only your functions (with a server key) create and change orders.#2. Secrets and outbound hosts
Store the keys as secrets
Never put them in code or in the app.
sov functions secrets set SOVARIA_URL=https://<project-ref>.sovarias.in sov functions secrets set SOVARIA_SERVICE_ROLE_KEY=<service-role-key> # Stripe sov functions secrets set STRIPE_SECRET_KEY=sk_test_… # or Razorpay sov functions secrets set RAZORPAY_KEY_ID=rzp_test_… RAZORPAY_KEY_SECRET=…Allow the hosts
Functions can only reach hosts you list (each function's settings): your project host, plus
api.stripe.comorapi.razorpay.com(Functions).
#3. A small helper both functions use
// Runs SQL with the server key. Values always go in params ($1, $2, …), never in the SQL text.
export async function sql(statement, params = []) {
const res = await fetch(`${process.env.SOVARIA_URL}/api/v1/sql/query`, {
method: 'POST',
headers: { apikey: process.env.SOVARIA_SERVICE_ROLE_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ sql: statement, params }),
})
if (!res.ok) throw new Error(`SQL failed: ${res.status} ${await res.text()}`)
const { columns, rows } = await res.json()
return rows.map((row) => Object.fromEntries(columns.map((c, i) => [c, row[i]])))
}Deploy each function folder with this file next to its entry file.
#4. The checkout function
The app sends only a product id. The function reads the price from the database, creates the order for the signed-in caller, and starts the payment.
import { sql } from './db.mjs'
export async function handler(event, context) {
const userId = context.caller.user_id
if (!userId) return { error: 'sign in first' }
const [product] = await sql('select id, name, price_minor, currency from products where id = $1', [event.product_id])
if (!product) return { error: 'no such product' }
const [order] = await sql(
'insert into orders (owner_id, product_id, amount_minor, currency) values ($1, $2, $3, $4) returning id',
[userId, product.id, product.price_minor, product.currency],
)
const form = new URLSearchParams({
mode: 'payment',
success_url: 'https://shop.example.com/orders?paid=1',
cancel_url: 'https://shop.example.com/cart',
client_reference_id: String(order.id),
'metadata[order_id]': String(order.id),
'line_items[0][quantity]': '1',
'line_items[0][price_data][currency]': product.currency,
'line_items[0][price_data][unit_amount]': String(product.price_minor),
'line_items[0][price_data][product_data][name]': product.name,
})
const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: form,
})
const session = await res.json()
if (!res.ok) return { error: session.error?.message ?? 'Stripe refused the session' }
await sql('update orders set provider_ref = $1 where id = $2', [session.id, order.id])
return { url: session.url }
}sov fn deploy ./create-checkout --name create-checkout
# Signed-in users only, and at most 20 checkouts a minute
sov fn policy-set create-checkout '{"invoke_permission":"authenticated","rate_limit_per_minute":20}'#5. The webhook function
import { sql } from './db.mjs'
// Don't trust the body: fetch the event back from Stripe by id and act on Stripe's copy.
export async function handler(body) {
if (typeof body?.id !== 'string' || !body.id.startsWith('evt_')) return { ignored: true }
const res = await fetch(`https://api.stripe.com/v1/events/${encodeURIComponent(body.id)}`, {
headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
})
if (!res.ok) return { ignored: true } // not a real event of this account
const event = await res.json()
if (event.type === 'checkout.session.completed' && event.data.object.payment_status === 'paid') {
const session = event.data.object
// Idempotent: a repeated event matches no pending order and changes nothing.
await sql(
"update orders set status = 'paid', paid_at = now() where provider_ref = $1 and status = 'pending' and amount_minor = $2",
[session.id, session.amount_total],
)
}
return { received: true }
}Deploy it and make it public
Webhooks arrive without a key. Under Edge Functions → stripe-webhook → Run → Who can call it, choose Public, and cap the rate.
sov fn deploy ./stripe-webhook --name stripe-webhook sov fn policy-set stripe-webhook '{"invoke_permission":"public","rate_limit_per_minute":120,"max_payload_bytes":65536}'Point the provider at it
Stripe: Developers › Webhooks › Add endpoint, URL
https://<project-ref>.sovarias.in/functions/v1/stripe-webhook, eventcheckout.session.completed. Razorpay: Settings › Webhooks, therazorpay-webhookURL, eventpayment.captured.
- The
where … status = 'pending' and amount_minor = $2makes the update idempotent and checks the amount: a repeated, replayed or tampered event changes nothing. - Providers retry a webhook that doesn't answer
2xx. The function answers{ received: true }for every real event, so it isn't sent again. - Refunds and failed payments work the same way: fetch the object, then update the order.
#6. The app
const { url, error } = await sovaria.functions.call<{ url?: string; error?: string }>('create-checkout', { product_id: 7 })
if (url) location.href = url // Stripe Checkout, then back to /orders?paid=1The order page may load before the webhook has arrived. Show "Payment processing" while the status is pending, and re-read it after a few seconds or when a change event arrives (Database change events).
#7. Test it
- Use test keys (
sk_test_…,rzp_test_…) and the providers' test cards. Stripe's CLI can send a real test event to the URL:stripe trigger checkout.session.completed. - Call the webhook with a made-up id (
{"id": "evt_fake"}): it answers{ ignored: true }and changes nothing. - Follow each run in Edge Functions → Logs, or
sov fn logs stripe-webhook.
#Coming from Firebase or Supabase?
| Platform | What this is called there |
|---|---|
| Firebase | Stripe with Cloud Functions for Firebase |
| Supabase | Supabase Stripe webhooks |
| Sovaria | Tutorial: a store with Stripe or Razorpay |
Moving an existing app? Migrate from Supabase or Firebase imports users (with their passwords), data, files and rules.