Skip to content

Production credentials & environment setup ​

Owner-facing runbook, 2026-08-16. Everything the Ganapati MVP needs wired before the money loop can run against real infrastructure: the new production Supabase project, the api.qrsetu.com custom domain, Cloudflare R2, and Razorpay Route.

NO CREDENTIAL VALUE EVER APPEARS IN THIS FILE, IN CHAT, OR IN A COMMIT

Every secret below is set by the owner directly into Supabase (dashboard or supabase secrets set). Nothing here asks you to paste a key anywhere it could be logged. The one thing to send over a private channel is apps/mobile/.env.production, and that file is git-ignored by design.


0 · What changed, and the one access fact that blocks deployment ​

Productionikkwqowfnbhdasfejojg, named qr-setu-prod — probed 2026-08-16, alive (/auth/v1/health returns 401, i.e. reachable and asking for a key). Separate Supabase account; ⚠ in the same org as Nefoxx-Prod.
Retiredygmqxyrbnemhwkiyoboc, renamed qr-setu-legacy-bkp (Sydney) — ⚠ it was called qr-setu-prod until 2026-08-16, so both projects have held that name and only the REF is unambiguous. A backup, never a deploy target.
api.qrsetu.comprobed 2026-08-16, NOT live yet — returns 403 from Cloudflare, so the hostname resolves but no Supabase custom domain is attached

⚠ The new project is in a DIFFERENT Supabase account, so the tooling in this repo cannot reach it.list_projects returns only qr-setu-dev and the old qr-setu-prod. Nothing can be deployed to ikkwqowfnbhdasfejojg — no migrations, no Edge Functions, no secrets — until the CLI is authenticated against that account:

bash
# OWNER ACTION. Generate a Personal Access Token in the NEW account:
#   Supabase dashboard -> (top-right avatar) -> Account -> Access Tokens -> Generate new token
supabase login --token <PAT-from-the-new-account>
supabase projects list      # must now list ikkwqowfnbhdasfejojg

This stays an owner action and never enters a script (the same rule npm run deploy:reminders already follows). Until it is done, the production cutover cannot start.


1 · Supabase: api.qrsetu.com as the primary endpoint ​

The short answer: yes, this works, and the app already has the right shape for it ​

The Supabase URL is already a single environment variable at every layer, and nothing anywhere hardcodes a project URL — initSupabaseClient() throws on an empty URL rather than falling back to a real project, deliberately, so a misconfigured build fails loudly instead of silently reaching the wrong backend. So "switch the endpoint without touching code" is satisfied today.

⚠ But "no code change" and "no rebuild" are different claims, and the difference decides your timing ​

SurfaceHow it reads the URLSwitching to api.qrsetu.com costs
apps/web (public Setu Card, SSR)process.env.SUPABASE_URL, read at runtime in the route loader (apps/web/src/app/env.server.ts:23)Config only. Change the Cloudflare Pages variable, redeploy. No code, no rebuild.
apps/mobile (the merchant app, all three surfaces)EXPO_PUBLIC_SUPABASE_URL, inlined into the JS bundle at build timeA new build, and redistribution to every device that already has one.
Edge FunctionsSUPABASE_URL, auto-injected by SupabaseNothing — and it must stay that way (below).

EXPO_PUBLIC_* is a build-time inline, not a runtime lookup. That is Expo's design, not a repo choice, and it is why the timing below matters more than the configuration.

The recommendation, and it is a scheduling one ​

Enable the custom domain BEFORE the first production mobile build, and bake api.qrsetu.com into build #1.

Right now nothing has shipped — no store presence, no vendor has an APK. That is a free window in which the endpoint can be chosen at no cost. It closes the moment the first vendor installs the app. After that, moving the endpoint means a new APK pushed to twelve vendors by hand today, and a store update later once DUNS lands.

So:

  • If api.qrsetu.com is live before the production build → bake it in, and the default ikkwqowfnbhdasfejojg.supabase.co is never referenced by a shipped client at all.
  • If it is not live in time → ship with the default, and accept a rebuild + re-distribution when you switch. Say so explicitly rather than discovering it later; it is a real cost, not a formality.

⚠ Three things that are NOT "just update the domain in Supabase" ​

The API plane switches cleanly. Auth does not, and these are outside Supabase's dashboard:

  1. Google OAuth redirect URI. Google Cloud Console → APIs & Services → Credentials → your OAuth client → Authorised redirect URIs must gain https://api.qrsetu.com/auth/v1/callback. Add it alongside the existing one, never replace it — during the switchover both hosts must work, and a replaced URI breaks sign-in instantly for anyone mid-flow. The same applies to the Supabase dashboard's Authentication → URL Configuration redirect allow-list.
  2. JWT issuer. A token minted through api.qrsetu.com carries iss: https://api.qrsetu.com/auth/v1; one minted earlier carries the project host. Both verify against the same signing key, so existing sessions keep working — but if anything ever validates iss by string comparison, it will see two values. Nothing in this repo does today. Stated so it is not discovered later.
  3. The default endpoint keeps working, and that is the fallback — Supabase does not retire <ref>.supabase.co when a custom domain is attached. So the fallback you asked for exists at the server. It is not a client-side failover: the app is built with exactly one URL.

⚠ Do NOT build a runtime "try custom domain, fall back to default" ​

It sounds like the safe option and it is the opposite:

  • The Supabase client is constructed once, at startup, with one URL. A failover would need a health probe on the coldest path in the app, adding latency to every cold start against QRS-290's measured 130-160 ms RTT floor.
  • It makes "which backend was this session talking to?" non-deterministic — which is precisely the class of bug that cannot be reproduced from an incident report.
  • It would also mean a session minted against one host can be used against the other mid-flight.

The correct safety mechanism is a build-time probe, not a runtime fallback. npm run check:env:prod already asserts the variables exist and prints which project the build will talk to; extending it to actually resolve and hit the configured URL turns "pointed at a domain that is not live yet" into a failed build instead of an app that installs fine and dies at the first sign-in. That is the same failure check:env was written for (QRS-668) and it is a small change — I will make it part of the cutover.

Edge Functions must keep the injected URL ​

SUPABASE_URL inside an Edge Function is injected by the platform and points at the project internally. Do not override it with api.qrsetu.com. A function calling its own project through a public custom domain adds a DNS dependency and an external hop to every internal query, and it would break the moment the domain has a problem — for no benefit, since no browser sees it.

What to set, per surface ​

WhereVariableValue
apps/mobile/.env.production (git-ignored; transfer by AirDrop/USB, never chat)EXPO_PUBLIC_SUPABASE_URLhttps://api.qrsetu.com once live, else https://ikkwqowfnbhdasfejojg.supabase.co
"EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEYthe new project's sb_publishable_… key
"EXPO_PUBLIC_ENVproduction
Cloudflare Pages (apps/web, Production env)SUPABASE_URLsame URL as above
"SUPABASE_PUBLISHABLE_KEYsame publishable key

Where to find the publishable key: new project → Project Settings → API Keys → the sb_publishable_… value. Not the legacy JWT anon key (both exist and both work; this repo uses the publishable one), and never the service_role key in any of the above.


2 · Cloudflare R2 ​

The four buckets are created (qrsetu-media-dev · qrsetu-media-prod · qrsetu-private-dev · qrsetu-private-prod) — that public/private split is the right shape and matches how the code needs to treat them.

Which file holds what ​

None of the R2 credentials belong in any file in this repo. They are Supabase Edge Function secrets, set per project. supabase/functions/_shared/r2.ts:50-53 reads exactly four names:

SecretWhat it isWhere you find it
R2_ACCOUNT_IDCloudflare account idCloudflare dashboard → R2 → right-hand panel, or the hex string in your dashboard URL after /accounts/
R2_ACCESS_KEY_IDR2 API token access keyR2 → Manage R2 API Tokens → Create API token
R2_SECRET_ACCESS_KEYits secretshown once at creation — copy it then
R2_BUCKETthe public media bucket for that environmentqrsetu-media-dev on Dev, qrsetu-media-prod on Prod

Token scope: Object Read & Write, restricted to the two buckets for that environment only. Create two separate tokens — one scoped to the -dev buckets, one to the -prod buckets. A single shared token means a Dev bug can delete production images, and there is no undo.

The one value that IS client-side ​

Images are rendered by a browser and by the app, so the public read base URL is necessarily public. It is not a credential:

WhereVariableValue
apps/mobile/.env.*EXPO_PUBLIC_MEDIA_BASE_URLthe public base for qrsetu-media-*
Cloudflare PagesMEDIA_BASE_URLsame

R2 buckets are private by default, so this needs one decision from you:

  • Recommended for production: attach a custom domain to qrsetu-media-prod — e.g. media.qrsetu.com (R2 → bucket → Settings → Public access → Connect Domain). Stable, cacheable, no rate limit, and it keeps the images on your brand.
  • Fine for Dev: enable the r2.dev public URL (same panel, Allow Access). It is rate-limited and Cloudflare explicitly says not to use it for production traffic.

qrsetu-private-* must stay private, permanently — chat voice notes, photos and documents are served through short-lived presigned GET URLs, never a public path. It needs no base URL at all.

CORS on each bucket, and why this section exists ​

⚠⚠ THIS GUIDE COVERED BUCKETS, CREDENTIALS, PUBLIC ACCESS AND THE PRIVATE-BUCKET RULE, AND SAID NOTHING ABOUT CORS — WHICH IS WHY A BROWSER UPLOAD WAS DEAD ON DEV FOR DAYS (QRS-1245). A cross-origin PUT is always preflighted, so a bucket with no rule for the page's origin can never be written to from a web surface, whatever the signature says. A native build sends no preflight and the same code succeeds — which is exactly why the two surfaces disagreed on identical code and the diagnosis took two wrong turns.

Four buckets, four policies. They are committed, one file per bucket:

BucketPolicy fileOrigins
qrsetu-media-devsupabase/storage/r2-cors-media.jsonprod + devv + uatt + localhost
qrsetu-private-devsupabase/storage/r2-cors-private.jsonprod + devv + uatt + localhost + LAN
qrsetu-media-prodsupabase/storage/r2-cors-media-prod.jsonqrsetu.com, www.qrsetu.com
qrsetu-private-prodsupabase/storage/r2-cors-private-prod.jsonqrsetu.com, www.qrsetu.com

Where: R2 → the bucket → Settings → CORS Policy → Add CORS policy → JSON tab.

⚠ THE DASHBOARD DOES NOT ACCEPT THE COMMITTED SHAPE. Its JSON tab takes a bare array with PascalCase keys — AllowedOrigins, AllowedMethods, AllowedHeaders, ExposeHeaders, MaxAgeSeconds — while the files are the Cloudflare API shape (rules[].allowed.origins, camelCase). Convert, never retype: two hand-typed copies of an origins list is how one surface ends up allowed and another silently refused.

⚠ Content-Type in AllowedHeaders is required, not decorative. The presigned PUT signs Content-Type as a query parameter, so a preflight that strips the header gives a 403 SignatureDoesNotMatch — which reads like a credentials problem and is not.

⚠ A public bucket still needs CORS, and this is the part that looks wrong. Reading an image needs none (<img src> is not a CORS request, and reads go through media.qrsetu.com). It is the merchant's browser upload that is preflighted. QRS-831 was exactly this.

⚠ Production is deliberately tighter than Dev, and devv/uatt are absent on purpose. Dev and UAT share the Dev Supabase project, so those pages receive presigned URLs for the -dev buckets and never for the production ones. Listing them on a production bucket would grant nothing while reading as though it did.

Separate Dev and Prod without code changes ​

The mechanism is already in place and needs nothing new: the same four secret names on both projects, with different values. The Dev project's R2_BUCKET is qrsetu-media-dev; the new Prod project's is qrsetu-media-prod. No code reads a bucket name, an environment name, or a branch — it reads R2_BUCKET. Switching environments is switching projects.

bash
# Dev
supabase secrets set --project-ref dyhjofjjuazhyqcvlrkx \
  R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... R2_BUCKET=qrsetu-media-dev

# Production (after `supabase login` against the new account)
supabase secrets set --project-ref ikkwqowfnbhdasfejojg \
  R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... R2_BUCKET=qrsetu-media-prod

⚠ A note on the private bucket: r2Config() currently reads one R2_BUCKET. Chat media (Wave 2) needs the second bucket, and it will read R2_PRIVATE_BUCKET — the name is fixed now so you can set both in one pass and there is no rework later. Set it to qrsetu-private-dev / qrsetu-private-prod respectively. Wave 1 (item photos) only uses R2_BUCKET.


3 · Razorpay ​

3.1 API keys ​

Dashboard → Account & Settings → API Keys (older layouts: Settings → API Keys).

  • The Test / Live toggle is top-left of the dashboard and it changes which keys you see. Generate keys in each mode.
  • Test keys look like rzp_test_…, live keys rzp_live_….
  • The secret is displayed exactly once, at generation. If it was closed without copying, regenerate — there is no way to read it back.
SecretDev projectProduction project
RAZORPAY_KEY_IDrzp_test_…rzp_live_…
RAZORPAY_KEY_SECRETtest secretlive secret

Both stay server-side. Normally key_id is public (Checkout embeds it), but this implementation uses Payment Links created server-side, so the client never needs it — and anything the client does not need should not be in the bundle.

3.2 Route ​

Route must be activated on the account — it is not on by default.

Dashboard → Account & Settings → look for Route (it may appear under Products or require enabling by your Razorpay account manager / support). Confirm it is active before the money path is switched on; a Payment Link carrying a transfers[] block against a non-Route account fails at creation, not silently.

What I need from you per vendor, for the manual onboarding path we agreed:

Dashboard → Route → Linked Accounts → + Create Linked Account. For each of the twelve vendors:

FieldNotes
Business / legal nameas on the bank account
Email + phonephone is 8-15 digits with NO country code
Business typeindividual / proprietorship / partnership …
Bank account number + IFSC + beneficiary namegoes on the product configuration step, not account creation
PANneeded for the s.194-O small-seller TDS exemption — collect it even though Razorpay may not force it

Razorpay returns an acc_… linked-account id. That id is what I need — it goes into payout_accounts.provider_account_id, and writing it is what flips that vendor's payments availability grant on. The acc_… ids are not secrets; the bank details never enter this repo at all (Razorpay is the authoritative copy — we store IFSC + last-4 only).

⚠ Only activation_status = 'activated' may take money. The account-level status field is only created/suspended and is not the one that gates transfers — a state machine watching the wrong field reads healthy while payouts are blocked. I check the product's activation_status.

3.3 Webhook ​

Dashboard → Account & Settings → Webhooks → + Add New Webhook.

FieldValue
Webhook URLhttps://ikkwqowfnbhdasfejojg.supabase.co/functions/v1/razorpay-webhook
Secretyou invent it — any long random string. This is not issued by Razorpay.
Active eventspayment_link.paid · payment.captured · payment.failed · order.paid · refund.processed

Then set the same string as a Supabase secret:

bash
supabase secrets set --project-ref <ref> RAZORPAY_WEBHOOK_SECRET=<the-string-you-chose>

⚠ Use the <ref>.supabase.co URL for the webhook, not api.qrsetu.com — at least until the custom domain has been live and stable for a while. Razorpay retries a failing webhook on its own schedule, and a DNS or certificate problem on a brand-new custom domain would turn a payment notification into a retry backlog. The default host has no such dependency.

⚠ This function is verify_jwt = false, necessarily — Razorpay cannot send a Supabase JWT. Its authentication is the HMAC signature, verified against the raw request body before any parsing (_shared/webhook.ts, 13 tests covering tampering, wrong secret, replay window). It will be the only verify_jwt = false function in the project, and config.toml will record why.

⚠ Route event names are UNVERIFIED. The events that fire when a linked account activates are not confirmed against the live API, and I will not guess them into a state machine. I will confirm them in the dashboard's webhook event list when wiring the payment path. Until then, activation is the manual runbook step above — which is what we already agreed for this cohort.

3.4 Commission ​

bash
supabase secrets set --project-ref <ref> PLATFORM_COMMISSION_BP=500

500 basis points = 5%. It lives in configuration rather than in the platform_plans row on purpose: the rate is snapshotted into each payments row at link creation (commission_rate_bp/commission_minor/vendor_minor, CHECK-verified to add up), so a future rate change cannot rewrite the history of money that already moved.


4 · The complete secret list, per project ​

None of these are in the repo. All are set with supabase secrets set or the dashboard's Edge Functions → Secrets panel.

SecretDev (dyhjofjjuazhyqcvlrkx)Prod (ikkwqowfnbhdasfejojg)
R2_ACCOUNT_IDsame accountsame account
R2_ACCESS_KEY_IDdev-scoped tokenprod-scoped token
R2_SECRET_ACCESS_KEYdev-scoped tokenprod-scoped token
R2_BUCKETqrsetu-media-devqrsetu-media-prod
R2_PRIVATE_BUCKETqrsetu-private-devqrsetu-private-prod
RAZORPAY_KEY_IDrzp_test_…rzp_live_…
RAZORPAY_KEY_SECRETtestlive
RAZORPAY_WEBHOOK_SECRETper-project stringdifferent string
PLATFORM_COMMISSION_BP500500

Never set as a secret and never in any client file: the service_role key (Supabase injects it into Edge Functions automatically) and any vendor's full bank account number.

Use a different RAZORPAY_WEBHOOK_SECRET on Dev and Prod. If they match, a test webhook aimed at the wrong host verifies successfully — and a signature check that passes on the wrong environment is worse than none, because it looks like it worked.


5 · Order of operations ​

  1. supabase login --token against the new account — nothing else can start until this is done.
  2. New project: run every migration, then list_migrations as a read-back (QRS-267 — MCP stamps its own version, so the read-back is what catches orphan rows).
  3. Delete the four archived Edge Functions that are still ACTIVE on Dev (manage-profile, manage-settings, create-payment-link, razorpay-webhook — QRS-694) before deploying the v2 payment functions, which reuse two of those names. Never deploy over a stale bundle on the money path.
  4. Set all secrets on both projects.
  5. Google OAuth client + Supabase redirect allow-list: add both hosts.
  6. Attach api.qrsetu.com in the Supabase dashboard, and wait for it to verify.
  7. npm run -w @qrsetu/mobile check:env:prod — confirm it prints the intended project.
  8. Build, then the live ₹1 settlement test through a real linked account.

6 · What is still blocked, stated plainly ​

  • SMTP is broken (QRS-285) — the relay is pointed at Hostinger while the verified sender is ZeptoMail, and SPF authorises neither correctly. Consequence for launch: Google sign-in only. Email OTP will not deliver on the new project either, because this is a DNS/relay problem rather than a per-project setting.
  • Route webhook events unverified (above) — manual activation for this cohort.
  • api.qrsetu.com not yet attached — probed 2026-08-16.