# Tutorial: a store with Stripe or Razorpay
Source: https://sovaria.in/docs/tutorial-store-payments

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.

> **How the webhook is verified:** Sovaria functions receive the webhook's JSON body but not its HTTP headers or raw bytes, so the `Stripe-Signature` / `X-Razorpay-Signature` header can't be checked inside the function today. The webhook function below doesn't trust the body at all. It takes only the event or payment id, fetches that object back from Stripe or Razorpay with your secret key, and acts on the provider's copy. A forged call can't make the provider confirm a payment that didn't happen.

## 1. Products and orders

SQL:
```sql
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.
```bash
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](https://sovaria.in/docs/functions-overview#network)).

> **Tip:** A key with only the access these functions need is safer than the service role key ([Keys, tokens and credentials](https://sovaria.in/docs/credentials)). The service role key passes every rule, which is why the functions below decide what the caller may do before using it.

## 3. A small helper both functions use

db.mjs:
```js
// 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.

create-checkout/index.mjs (Stripe):
```js
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 }
}
```

create-checkout/index.mjs (Razorpay):
```js
import { sql } from './db.mjs'

const basic = 'Basic ' + Buffer.from(`${process.env.RAZORPAY_KEY_ID}:${process.env.RAZORPAY_KEY_SECRET}`).toString('base64')

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 res = await fetch('https://api.razorpay.com/v1/orders', {
    method: 'POST',
    headers: { Authorization: basic, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      amount: product.price_minor,                 // paise
      currency: product.currency.toUpperCase(),    // INR
      receipt: `order-${order.id}`,
      notes: { order_id: String(order.id) },
    }),
  })
  const rzp = await res.json()
  if (!res.ok) return { error: rzp.error?.description ?? 'Razorpay refused the order' }

  await sql('update orders set provider_ref = $1 where id = $2', [rzp.id, order.id])
  // The app opens Razorpay Checkout with these (the key id is public; the secret never leaves the function).
  return { razorpay_order_id: rzp.id, key_id: process.env.RAZORPAY_KEY_ID, amount: rzp.amount, currency: rzp.currency }
}
```

Deploy and lock down:
```bash
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

stripe-webhook/index.mjs:
```js
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 }
}
```

razorpay-webhook/index.mjs:
```js
import { sql } from './db.mjs'

const basic = 'Basic ' + Buffer.from(`${process.env.RAZORPAY_KEY_ID}:${process.env.RAZORPAY_KEY_SECRET}`).toString('base64')

// Don't trust the body: fetch the payment back from Razorpay by id and act on Razorpay's copy.
export async function handler(body) {
  const paymentId = body?.payload?.payment?.entity?.id
  if (typeof paymentId !== 'string' || !paymentId.startsWith('pay_')) return { ignored: true }
  const res = await fetch(`https://api.razorpay.com/v1/payments/${encodeURIComponent(paymentId)}`, {
    headers: { Authorization: basic },
  })
  if (!res.ok) return { ignored: true }
  const payment = await res.json()

  if (payment.status === 'captured') {
    await sql(
      "update orders set status = 'paid', paid_at = now() where provider_ref = $1 and status = 'pending' and amount_minor = $2",
      [payment.order_id, payment.amount],
    )
  }
  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.
```bash
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

Stripe:
```ts
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
```

Razorpay:
```ts
// <script src="https://checkout.razorpay.com/v1/checkout.js"></script>
const o = await sovaria.functions.call<any>('create-checkout', { product_id: 7 })
new (window as any).Razorpay({
  key: o.key_id, order_id: o.razorpay_order_id, amount: o.amount, currency: o.currency, name: 'My shop',
  handler: () => { location.href = '/orders?paid=1' },   // the webhook marks it paid
}).open()
```

My orders:
```ts
// Row rules return only this customer's orders.
const { columns, rows } = await sovaria.data.sql('select id, status, amount_minor, currency, paid_at from orders order by id desc') as any
```

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](https://sovaria.in/docs/realtime-database-changes)).

## 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`.
