Docs

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.

Updated Oct 24, 2026

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

  1. 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=…
  2. Allow the hosts

    Functions can only reach hosts you list (each function's settings): your project host, plus api.stripe.com or api.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 }
}
  1. 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}'
  2. Point the provider at it

    Stripe: Developers › Webhooks › Add endpoint, URL https://<project-ref>.sovarias.in/functions/v1/stripe-webhook, event checkout.session.completed. Razorpay: Settings › Webhooks, the razorpay-webhook URL, event payment.captured.

  • The where … status = 'pending' and amount_minor = $2 makes 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=1

The 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?

PlatformWhat this is called there
FirebaseStripe with Cloud Functions for Firebase
SupabaseSupabase Stripe webhooks
SovariaTutorial: a store with Stripe or Razorpay

Moving an existing app? Migrate from Supabase or Firebase imports users (with their passwords), data, files and rules.