dhaga.docs
Self-hosting

Deploying

The three ways Dhaga deploys — Vercel, a single persistent server, and Docker — plus the Postgres role and pooling rules that matter in hosted mode.

Running a single-user deployment?

If your deployment has no Dhaga Cloud features (billing, admin panel, invite-only signup), read Self-hosting first — it's simpler than this whole page and you can skip the "Hosted-mode extras" section below entirely.

Two very different things deploy from this codebase today:

  1. The landing page (/) — static, deploys anywhere, zero config.
  2. The app (/app, /api) — stores data in an embedded database (PGlite) on the server's filesystem. That single fact decides where you can run it.

Read this first

The default storage is an embedded database on the server's filesystem — great on a laptop/VPS, impossible on serverless (read-only, ephemeral, many instances). To run the app on Vercel, set DATABASE_URL to a hosted Postgres (Neon/Supabase free tiers work; the database must support CREATE EXTENSION vector). With it set, the app uses hosted Postgres everywhere; without it, the embedded DB — and on serverless only the landing page + waitlist (email fallback) function.

Option A — Vercel

  1. Create a Vercel project from the licensed source bundle — push it to a private Git repository you control and import that, or run vercel deploy from the unpacked bundle. Either way the framework auto-detects as Next.js. (Dhaga's own repository is private; a self-hosted deployment starts from the bundle that comes with your agreement — see Self-hosting.)
  2. Set Root Directory to apps/web and keep the default build command.
  3. Env vars:
    • RESEND_API_KEY, RESEND_FROM_EMAIL, DHAGA_OWNER_EMAIL — emails + the waitlist's fallback path.
    • DATABASE_URL — hosted Postgres (create a free Neon or Supabase project, copy its connection string). Without this, /app cannot store anything on Vercel; with it, the full app works.
    • BETTER_AUTH_SECRET, BETTER_AUTH_URL — once DATABASE_URL is set.
    • BETTER_AUTH_TRUSTED_ORIGINS — optional; extra origins allowed on sign-in/sign-up beyond BETTER_AUTH_URL (comma-separated or a wildcard), to avoid INVALID_ORIGIN. Vercel preview URLs are auto-trusted, so this is usually unneeded.
    • ANTHROPIC_API_KEY — AI features.
    • DHAGA_EMBEDDINGS=off recommended on Vercel for now: the local embedding model (~100 MB of native runtime) is a poor fit for serverless functions; search falls back to keyword matching.
    • CRON_SECRET — optional, job-change detection + news watchlist. apps/web/vercel.json already declares the nightly cron; Vercel sends Authorization: Bearer $CRON_SECRET to it automatically once the var is set (unset = the route 401s to everyone, including Vercel's own cron — the feature is simply off). The sweep's web search needs no separate provider: it defaults to Anthropic's own server-side web_search tool on the ANTHROPIC_API_KEY above. FIRECRAWL_API_KEY is optional and takes precedence only where you set it. Two caveats — the Anthropic search path has never been run against a live key, and searches are billed $10/1k on top of the input tokens every retrieved page costs.
    • NEXT_PUBLIC_SITE_URL — set once a custom domain is attached (step 4): the canonical origin for the sitemap, robots.txt, OG tags, and llms.txt. Defaults to the Vercel preview URL until then.
  4. Deploy. Add your domain under Settings → Domains.

Hosted-mode extras (Dhaga Cloud only — skip for plain self-hosting)

Add these on top of the above only if you want invite-gated signup, the admin panel, and Stripe billing. See Self-hosting for what each of these actually turns on, and how to create your first admin account once it's live.

  • DHAGA_HOSTED_MODE=true — the master switch; every EE feature stays off without it, regardless of whether the other vars below are set.
  • DHAGA_ADMIN_EMAILS — comma-separated emails that bootstrap into admins on signup (see Self-hosting's "Creating the first admin user").
  • STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and one price id per (tier, cadence): STRIPE_PRICE_PRO_MONTHLY, STRIPE_PRICE_PRO_ANNUAL, STRIPE_PRICE_POWER_MONTHLY, STRIPE_PRICE_POWER_ANNUAL. Any left blank is simply not offered — the picker renders only combinations that have a price. Omit STRIPE_SECRET_KEY entirely to run hosted mode without billing (e.g. a free beta). Reference prices: Pro $5/mo or $48/yr, Power $8.99/mo or $89.99/yr (BRD §8.3 carries the margins these imply — they are materially thinner than the figures the same section quoted before 2026-08-19). Every tier is recurring — there is no one-time purchase.
  • Local currency is Stripe Adaptive Pricing, a Dashboard setting on Checkout — not code and not extra env vars. Turn it on there and a visitor in the UAE is quoted dirhams off the same price id.
  • RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, RAZORPAY_WEBHOOK_SECRET, one Plan id per (tier, cadence) — RAZORPAY_PLAN_PRO_MONTHLY, RAZORPAY_PLAN_PRO_YEARLY, RAZORPAY_PLAN_POWER_MONTHLY, RAZORPAY_PLAN_POWER_YEARLY. Optional and independent of Stripe: either processor alone is enough for the billing UI to render. Reference prices: Pro ₹499/mo or ₹4,799/yr, Power ₹899/mo or ₹8,999/yr — roughly parity with the USD figures, so BRD §8.3's recomputed margins carry over.
  • RAZORPAY_PLAN_PRO_MONTHLY_LEGACY, RAZORPAY_PLAN_PRO_YEARLY_LEGACY, RAZORPAY_PLAN_POWER_MONTHLY_LEGACY, RAZORPAY_PLAN_POWER_YEARLY_LEGACYset these whenever you reprice a standing tier, to the plan id the var above held before the change. A Razorpay Plan is immutable, so repricing means minting a new Plan and repointing the env var, and every subscriber still billing against the old id becomes unrecognisable the moment it leaves the table: the renewal webhook resolves no tier and silently drops a paying customer to free. RAZORPAY_LEGACY_PLAN_ENV (packages/ee/src/billing/catalog/plan-env/tables.ts) is consulted by the reverse lookup onlyisSellableCadence refuses these, so an older, cheaper id can never become purchasable again.
  • RAZORPAY_PLAN_PRO_INTRO_MONTHLY, RAZORPAY_PLAN_PRO_INTRO_YEARLY, RAZORPAY_PLAN_POWER_INTRO_MONTHLY, RAZORPAY_PLAN_POWER_INTRO_YEARLYno longer a purchase path. An introductory price used to be a second Plan at the lower amount; it is now the standing Plan plus a Razorpay Offer (next bullet), so nothing reads these four to sell anything and a fresh deployment can leave all four unset and still sell every introductory price. They are kept in the resolve-only table beside the _LEGACY ids, for the same reason those are kept: nobody ever bought one, but a plan id that could appear on a historical row must still resolve to a tier rather than throw.
  • RAZORPAY_OFFER_PRO_MONTHLY, RAZORPAY_OFFER_PRO_YEARLY, RAZORPAY_OFFER_POWER_MONTHLY, RAZORPAY_OFFER_POWER_YEARLYthis is how an introductory price is sold now. Each holds a Razorpay Offer id, which checkout attaches to subscriptions.create as offer_id on the standing Plan; the offer discounts the first N cycles and Razorpay steps the price up by itself when they run out. Reference prices: Pro ₹199/mo or ₹1,999/yr. There is no Power introductory price any more — since the 2026-08-24 reprice Power's introductory rows and its standing rows are the same ₹899 / ₹8,999, so an offer there would advertise 0% off; no RAZORPAY_OFFER_POWER_* id is configured and getIntroOffers() withholds Power, so nothing sells or renders one. INR/Razorpay only and deliberately so: there is no Stripe equivalent, because a USD checkout would mint a rate at an offer priced only in INR. Offers are Dashboard-created — the API cannot create, list or read one — so an environment variable is the only way an offer id reaches the app, and one id per (tier, cadence) is enough: Razorpay resolves the payment-rail variant itself, so the code never selects by card vs UPI. Each pair is checked on its own (getIntroOffers() / hasIntroOffer()), so an instance may configure two of the four and simply not offer the others. With none of them set, every surface shows the standing price and every button sells the standing plan — that is the correct default, not a degraded one. Checkout fails closed: an introductory cadence that reaches it with no offer id configured is REFUSED, visibly, rather than silently charging the standing amount at a button that advertised the introductory one.
  • Four Dashboard settings on each offer were measured to matter, and no code path can compensate for getting one wrong: redemption type limited number of cycles (forever discounts forever); 12 cycles on a monthly plan and 1 on a yearly plan — a yearly plan bills once a year, so 12 would discount twelve years; Block on payment failure; and a minimum amount below the cheapest plan, or the subscription is refused at creation. The offer must also be enabled, or payment fails with "Payment method used is not eligible for offer".
  • The mandate registers at the STANDING amount, not the discounted one. That is precisely what makes the step-up automatic, and it is why an introductory buyer's bank shows a cap higher than their first charge. Nothing to configure — but it is the line to have ready when a customer asks.
  • The offer is a per-customer 12-month term and carries no closing date at all. A buyer holds their introductory price for their own first twelve months (INTRO_TERM_MONTHS in packages/ee/src/billing/intro/term.ts), counted from their own subscription start, then pays the standing price for that tier and cadence; a 12-cycle monthly offer and a 1-cycle yearly offer both mean "your first year". It is a guarantee, not a lock-in: they may cancel in any month, and a yearly subscriber keeps the year they paid for. It stays first-purchase-only — nothing on the plan ladder moves an existing subscriber onto an introductory price, and changing plan forfeits it, which is what the plan-change forfeit warning says.
  • Whether the introductory prices are still sold is a runtime admin toggle — still not an env var, so you configure nothing here for it. introOfferOpen() / setIntroOfferOpen() (packages/ee/src/billing/intro/availability.ts) read and write the intro_offer_availability key in EE's billing_settings table, from /app/admin/subscriptions. It is a screen rather than a deploy precisely so an owner can close the offer at a moment of their choosing. The default is open — absence of a row means open, so the deploy that introduced the table changed nothing and no operator has to switch anything on. Closing it stops new customers getting introductory prices and changes nothing for anyone already subscribed.
  • New schema, applied by the usual boot-time DDL replay — no migration step and nothing to run by hand: the billing_settings table (packages/ee/src/db/tables-ddl/billing-settings.ts; text key PK, text value, updated_at, no RLS — a control-plane table with no tenant column, exactly like subscriptions and payments beside it), plus three nullable columns on subscriptions: intro_offer_id, which records the Razorpay Offer id a purchase was made with — the subscription itself is on the standing Plan, so this column is the only thing that can tell an introductory purchase from an ordinary one — and intro_price_held_at / intro_price_held_by behind the admin "keep this customer on the introductory price" hold. Every column statement is additive IF NOT EXISTS.
  • There is no nightly step-up sweep any more, and nothing to schedule for it. Razorpay steps the price up itself when the offer's cycles run out, against a mandate that was registered at the standing amount, so no booking is made on our side. runIntroPriceStepUps, packages/ee/src/billing/intro/step-up/ and updateSubscriptionPlan() are deleted, and the daily job route no longer carries an entry for them.
  • What survives on the cron is the warning, re-keyed. /api/jobs/daily still runs the ~30-day term-end notice email (runIntroStepUpNotices, apps/web/src/lib/jobs/intro-step-up/notices.ts, template in apps/web/src/lib/email/intro-step-up.ts), and the final-month in-app banner still ships beside it. Both used to read a plan change booked at the processor; both now derive the term from subscriptions.intro_offer_id plus the deterministic introTermEndsAt() (subscription start + INTRO_TERM_MONTHS). The old "sweep before notice" ordering went with the sweep — there is no booked date to wait for, so the date both channels quote is the computed anniversary. Deleting them alongside the sweep would have been less work and a silent regression: a customer whose price is about to rise deserves the warning whichever mechanism raises it. No new env var and no second cron: it is the same authenticated GET behind the same CRON_SECRET. It reports zeros without packages/ee, and the notice is transactional mail on its own idempotence key, so it sits outside the daily brief's one-email-a-day budget and the two cannot suppress each other.
  • RAZORPAY_PLAN_PRO_FOUNDING_YEARLYretired, but do not unset it. Founding Pro is no longer sold: it is on no public surface, and isSellableCadence refuses the cadence, so this id can no longer mint a subscription. What survives is a reverse lookup only. A Razorpay Plan is immutable, so an existing founding subscriber renews against this exact plan id forever, and the renewal webhook resolves which tier to grant by looking the id up in packages/ee/src/billing/catalog/plan-env/tables.ts. Remove the variable and that renewal resolves no tier, grants nothing, and silently drops a paying customer to free. Leave it set for as long as one founding subscription is live.
  • Processor routing is by x-vercel-ip-country: India leads with Razorpay, everywhere else with Stripe (RAZORPAY_COUNTRIES, apps/web/src/lib/billing/processor.ts). It only reorders the buttons — both stay clickable wherever both are configured, because IP geo is wrong often enough that locking someone out of paying is the worse failure.
  • Register the Razorpay webhook at https://your-domain/api/razorpay/webhook, subscribed to at least subscription.activated, subscription.charged, subscription.halted, subscription.cancelled. Its secret is per-endpoint and not the API key secret. Without it, a customer who closes the tab mid-redirect is charged and never upgraded, and renewals never update the stored status. Razorpay-paid customers also have no billing portal — cancelling is dashboard-side.
  • Register the webhook in Stripe pointing at https://your-domain/api/stripe/webhook, subscribed to at least checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed.
  • DATABASE_URL is mandatory in hosted mode regardless of platform — the multi-tenant isolation is Postgres Row-Level Security, which the embedded PGlite database doesn't support.

⚠ The Postgres role DATABASE_URL connects as matters — a lot

Hosted mode's tenant isolation is entirely Row-Level Security (see packages/ee/src/db/rls-ddl.ts) — every query core code runs is tenant-agnostic by design and relies on RLS policies to filter rows. RLS is enforced per role, not per connection: a role with the BYPASSRLS attribute ignores every RLS policy on every table, silently, regardless of FORCE ROW LEVEL SECURITY. Managed Postgres providers' default admin/owner role commonly has BYPASSRLS out of the box — Supabase's postgres role does. If DATABASE_URL connects as that role, every signed-in user sees every other user's data, with no error anywhere, because nothing is malformed — RLS is just never evaluated for that role. This is exactly what shipped originally on this project's own hosted deployment and leaked one account's contacts into another's.

Before going live in hosted mode on any provider (Supabase, Neon, self-hosted — this isn't Supabase-specific, it's how Postgres RLS works everywhere):

  1. Run packages/ee/scripts/create-app-role.sql against the target database. It creates a dedicated dhaga_app role with NOBYPASSRLS and moves table ownership to it (ownership, not just GRANTs, is required — the app's own boot-time DDL runs ALTER TABLE statements to evolve the schema and enable RLS).
  2. Point DATABASE_URL at dhaga_app, not the provider's default role. Supabase specifically: the pooler (pooler.supabase.com) routes by a <role>.<project_ref> username — e.g. dhaga_app.zgnpoeddgsrgpivqpkdk — not the plain role name; only the connection username changes shape, the role itself is still just dhaga_app.
  3. As of this project's packages/ee/src/db/bootstrap.ts, the app checks this itself at boot — SELECT rolbypassrls, rolsuper FROM pg_roles WHERE rolname = current_user, and refuses to start (loud error naming this doc) if the connecting role has BYPASSRLS or SUPERUSER (a superuser bypasses RLS unconditionally even while rolbypassrls reads false). Don't rely on this alone for a fresh setup, though — check via the script's own verification query before traffic hits it, not after.
  4. To prove the isolation itself rather than just the role's attributes, run packages/ee/src/db/__tests__/rls-isolation.integration.test.ts — it asserts, per tenant table, that a second tenant's scoped read sees nothing of the first's. Its file header carries the run instructions. It writes and then deletes rows, so point it at a disposable Postgres (the compose.yml database is one) and never at production.

Migrating off Supabase (or any hosted Postgres) later

Low-risk by design: the app never uses Supabase-specific APIs (no @supabase/supabase-js, no Supabase Auth/Storage/Realtime) — it's Better Auth + plain pg/Drizzle against a connection string, using only the standard pgvector and pg_trgm Postgres extensions. The schema is self-provisioning (initHosted() in apps/web/src/lib/db/index.ts runs the app's own idempotent DDL on every boot), so this is mostly a data migration, not a code change:

  1. On the new Postgres target: install the pgvector and pg_trgm extensions, then run packages/ee/scripts/create-app-role.sql against it to create dhaga_app there too — it runs unmodified on both, no editing step: the one Supabase-specific statement (GRANT USAGE ON SCHEMA extensions) is guarded and no-ops on a self-hosted Postgres, which keeps extensions in public rather than Supabase's separate extensions schema.
  2. pg_dump --no-owner --no-acl <source> | psql <target> (or pg_restore --no-owner --no-acl if using the custom format) — --no-owner matters because the dump would otherwise try to assign ownership to a dhaga_app role that doesn't exist yet in the target cluster (roles are cluster-level, not part of a database dump).
  3. Re-run just the ownership-reassignment loop from create-app-role.sql (the ALTER TABLE ... OWNER TO dhaga_app loop + grants) against the target — --no-owner left everything owned by whichever role ran the restore.
  4. Point DATABASE_URL at the new target (with dhaga_app's credentials) and redeploy. The boot-time check in packages/ee/src/db/bootstrap.ts will refuse to start if anything about the role is wrong — treat that as the migration's final verification, not just a safety net.

Option B — a single persistent server (the real deployment today)

Any Linux VPS (Hetzner/DigitalOcean/EC2) or PaaS with a volume (Railway / Render / Fly.io). Requirements: Node 20+, a directory that survives restarts, HTTPS in front. (Prefer containers? See Option C below.)

cd dhaga   # the licensed source bundle, unpacked
npm ci
npm run build

Create apps/web/.env.local (or export the vars in your process manager):

BETTER_AUTH_SECRET=<long random string, e.g. `openssl rand -hex 32`>
BETTER_AUTH_URL=https://dhaga.example.com
DHAGA_DATA_DIR=/var/lib/dhaga        # persisted dir OUTSIDE the repo
ANTHROPIC_API_KEY=sk-ant-...         # optional; enables AI features
DHAGA_AI_MONTHLY_CAP=500             # optional; seeds the default AI CREDITS/month until an admin sets one at /app/admin/ai-credits (default 10)
NODE_ENV=production

Run it (systemd example — pm2 start "npm run start" works too):

# /etc/systemd/system/dhaga.service
[Unit]
Description=Dhaga
After=network.target

[Service]
WorkingDirectory=/opt/dhaga
ExecStart=/usr/bin/npm run start
Restart=always
EnvironmentFile=/etc/dhaga.env

[Install]
WantedBy=multi-user.target

Put Caddy or nginx in front for HTTPS — required in production because the session cookie is Secure (login will not stick over plain HTTP).

# Caddyfile — automatic HTTPS
dhaga.example.com {
    reverse_proxy localhost:3000
}

Backups & migration

  • Backup = copy the DHAGA_DATA_DIR directory (stop the service first), plus a periodic curl -H "x-api-key: …" .../api/export/json (create a personal access token from /app/settings).
  • Leaving = the JSON/CSV/vCard export endpoints give you everything; no lock-in is a feature. That is the default and it is not filtered: called plainly, /api/export/json, /api/export/csv and /api/export/vcard each return every contact, whatever its provenance. The CSV and vCard routes also accept an opt-in ?scope=authored used for seeding an address book — a deliberately narrower file for a different job. Omit it and you get everything; nothing about the portability guarantee changed.

Option C — Production deployment with Docker

The repo root has a multi-stage Dockerfile (deps → build → slim runtime; Next.js standalone output on node:22-slim, non-root user, built-in healthcheck against /login) and a compose.yml that runs the app plus a Postgres 16 + pgvector database:

cd dhaga   # the licensed source bundle, unpacked
echo "BETTER_AUTH_SECRET=$(openssl rand -base64 32)" > .env
docker compose up --build -d

Open http://localhost:3000. On first boot the app applies its own idempotent DDL over the DATABASE_URL connection (including CREATE EXTENSION vector / pg_trgm) — there is no migration step, and the same schema self-heal re-runs harmlessly on every upgrade.

  • Contact data lives in the dhaga-db volume — docker compose down keeps it, down -v deletes it. The database is also reachable from the host at localhost:54329 for backups (pg_dump).

  • Set BETTER_AUTH_URL in .env to your public URL and terminate HTTPS in front (same reason as Option B: the session cookie is Secure, so login won't stick over plain HTTP on anything but localhost).

  • Zero-config variant (no Postgres): run the image without DATABASE_URL and it uses the embedded database (PGlite), persisted at /data inside the container — mount a volume there or the data dies with the container:

    docker build -t dhaga .
    docker run -d -p 3000:3000 \
      -e BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
      -v dhaga-data:/data dhaga

    compose.yml carries this as a commented-out variant too. Backup = the dhaga-data volume, exactly like DHAGA_DATA_DIR in Option B.

Hosted mode (Dhaga Cloud) in Docker

The stock compose.yml is the plain self-host path — none of the packages/ee vars are wired in. To run DHAGA_HOSTED_MODE=true multi-tenant in containers, additionally:

  1. Run packages/ee/scripts/create-app-role.sql against the database once, e.g. docker compose exec -T db psql -U dhaga -d dhaga < packages/ee/scripts/create-app-role.sql, and point DATABASE_URL at the resulting non-BYPASSRLS dhaga_app role — see "The Postgres role DATABASE_URL connects as matters" above.
  2. Pooling mode doesn't matter here. Tenant scoping is transaction-local (set_config('app.current_user_id', …, true) inside one BEGIN … COMMIT, which self-clears at COMMIT — packages/ee/src/tenant/scoped-db.ts), so it is correct whether you connect directly (what the compose file does), through a session-mode pooler, or through a transaction-mode pooler (Supabase's port 6543, PgBouncer, Supavisor, Neon's -pooler). There is no pooling-mode boot guard to satisfy — the earlier one, and its DHAGA_ALLOW_TRANSACTION_POOLER override, were removed as obsolete. (Supabase specifically: 5432 vs 6543 is just a DATABASE_URL change — the 6543 path is designed-correct but not yet verified against a live transaction pooler; see docs/SCALING.md §2.)
  3. Add the hosted-mode env vars (DHAGA_HOSTED_MODE, DHAGA_ADMIN_EMAILS, STRIPE_*) to the app service's environment block yourself.

Checklist before going live

  • Random BETTER_AUTH_SECRET, correct BETTER_AUTH_URL
  • DHAGA_DATA_DIR on persistent storage, owned by the service user
  • HTTPS terminating in front of port 3000
  • Backup cron for the data dir / JSON export
  • ANTHROPIC_API_KEY set as a server env var only — never in client code
  • Verified the app with Testing against the deployed URL

What's deliberately not here yet

  • Manual click-through verification of the Stripe checkout/webhook flow — the code is typechecked/linted/built/tested, but nobody has run a real test-mode purchase against a live Stripe account yet.
  • A real subscriber seeing either term-end warning. Both channels are built — the ~30-day notice email and the final-month in-app banner (apps/web/src/components/app/billing/, mounted in apps/web/src/app/app/layout.tsx, reading the same computed anniversary so the two quote one date) — and so are the admin surfaces, the availability toggle on /app/admin/subscriptions and the per-customer hold on /app/admin/users/[id]. What has not happened is anyone receiving one: no purchase has ever been made at an introductory price, so no notice has been sent and no banner has been rendered to a paying customer.
  • The step-up blocker is RESOLVED — but by removing the call, not by making it work. This entry used to open with 🛑 and read "THE STEP-UP IS A CONFIRMED DEFECT — it cannot run". Both halves were tested against Razorpay on 2026-08-20 and both came back against us. (1) The e-mandate max_amount was registered at the plan amount — ₹199, on card and on UPI AutoPay alike — so a ₹499 step-up debit would have been refused at charge time. The ₹99,000 SDK default previously cited belongs to the registration flow, not to Subscriptions; that inference was wrong. (2) PATCH /subscriptions/{id} with {plan_id, schedule_change_at:"cycle_end"} is refused with a 400 on both Indian payment rails — card: "Only offers can be updated for subscriptions when payment mode is domestic card."; UPI: "subscriptions cannot be updated when payment mode is upi". Sandbox result, so no real bank rail was touched, but these are structural payment-mode constraints with explicit error messages that agree across two rails. Neither is a constraint any code path now meets: the subscription is created on the standing Plan with an Offer attached, so the mandate registers at the standing amount and no update call is ever made. updateSubscriptionPlan() and the sweep that was its only caller are deleted. Be precise about how far that goes, though: the offer path was measured end to end in the sandbox, on both rails (₹499 plan + limited-cycle offer → ₹199 invoice paid, mandate max_amount ₹499), and nothing has yet been sold at an introductory price, so no real customer has reached a term end and no price has actually stepped up in the field.
  • Ordinary plan changes on Razorpay had the same cause and are now FIXED (2026-08-21) — but by a different design, not by the offer work above. changePlan no longer makes the refused call at all: a tier or cadence change mints a second Razorpay subscription starting at the current one's renewal date, the customer authorises its mandate in checkout, the old subscription is set to end at that boundary, and the new one takes over when it first charges (an upgrade is granted free in the meantime). Two things an operator needs to know: an account with a change in flight legitimately holds two Razorpay subscriptions, and authorising takes a small refundable debit (₹5 observed) — so no screen may say "nothing is charged today". Built and unit-tested; never exercised against a live Razorpay subscription. The introductory term end was a different call and was fixed differently again, by making no call at all; see the bullet above.

Already done (see Self-hosting and the "Hosted-mode extras" section above)

  • Hosted Postgres (Supabase/Neon)DATABASE_URL switches the driver automatically; see Option A above.
  • Real user accounts, per-user API keys, multi-tenant RLS, billing, admin panel, early-access gating — all built. Self-hosted instances run without any of the hosted-only pieces by default (open registration, no billing UI, no admin nav) — see Self-hosting.

On this page