Local webhook tunnel (Cloudflare)
Some flows are webhook-authoritative — the backend only completes them when
the payment gateway POSTs a confirmation back to it. For example, the
care-package purchase (#1086) activates a patient's subscription only when
Cashfree calls POST /billing/webhooks/cashfree after a successful payment. On a
deployed environment the gateway can reach the server directly; on localhost it
can't, so you need a tunnel that gives your machine a public HTTPS hostname.
backend/bin/dev-tunnel sets up a per-developer Cloudflare Tunnel so every
dev can run this in parallel without colliding.
What you get
Everything derives from a per-dev handle (your username by default):
| Value | |
|---|---|
| Tunnel name | kulcare-dev-<handle> |
| Hostname | <handle>-dev.kulcare.com |
| Webhook URL | https://<handle>-dev.kulcare.com/billing/webhooks/cashfree |
| Marketplace webhook URLs | https://<handle>-dev.kulcare.com/billing/marketplace/webhooks/cashfree, .../razorpay, .../stripe/connect, .../stripe/account |
| Rendered config | ~/.cloudflared/kulcare-dev-<handle>.yml |
Tunnel names are unique per Cloudflare account and each dev gets their own DNS record, so there are no collisions.
Path rules
The tunnel serves only the webhook paths. The rendered ingress sends a path that
matches ^/billing/(marketplace/)?webhooks/ to the local backend. Every other
path on the public hostname gets HTTP 404 from Cloudflare. Use the app through
localhost, not through the tunnel.
If your config was rendered before this rule, run bin/dev-tunnel setup again.
Then check it: curl -s -o /dev/null -w '%{http_code}' https://<handle>-dev.kulcare.com/up
must show 404.
Data rule
While the tunnel runs, use the local development database with seed data only. Do not load a copy of staging or production data. A webhook path is public, and the tunnel connects it to your machine.
Prerequisites
cloudflaredinstalled:brew install cloudflared- Access to the Kulcare Cloudflare account (the setup step runs
cloudflared tunnel login; pick the account/zone that ownskulcare.com).
One-time setup
cd backend
bin/dev-tunnel setupThis logs you in (if needed), creates your tunnel, routes the DNS record, and
generates your local config at ~/.cloudflared/kulcare-dev-<handle>.yml. The
config shape is the heredoc inside bin/dev-tunnel (step 4 of setup) — edit it
there to change it for everyone.
Note:
cloudflared's login certificate (~/.cloudflared/cert.pem) is scoped to one Cloudflare account/zone. Ifsetupreports it can't route*.kulcare.com, your login is pointed at a different account — re-runcloudflared tunnel loginand select thekulcare.comzone. (Being a dashboard super-admin is not the same as the cert's scope.)
Running it
# 1. Start the backend on port 4000
cd backend && bin/dev
# 2. In another shell, start the tunnel
bin/dev-tunnel runThen register your webhook URL (bin/dev-tunnel url) in the Cashfree dashboard
under Developers → Webhooks (Test/Sandbox mode), at version 2025-01-01.
Cashfree sends PAYMENT_SUCCESS_WEBHOOK, PAYMENT_FAILED_WEBHOOK and
PAYMENT_USER_DROPPED_WEBHOOK. The bare names PAYMENT_SUCCESS and
PAYMENT_COMPLETED do not occur.
Marketplace webhooks (vendor packages)
The marketplace has its own endpoints, in Billing::Marketplace::*WebhooksController.
| Gateway | URL path | Events to subscribe |
|---|---|---|
| Cashfree | /billing/marketplace/webhooks/cashfree | Payment success, failed, user dropped; Refund; Easy Split vendor status (VENDOR_STATUS_UPDATE); vendor settlement |
| Razorpay (test mode) | /billing/marketplace/webhooks/razorpay | payment.captured, payment.failed, refund.processed, transfer.processed, account.* |
| Stripe (test mode) | /billing/marketplace/webhooks/stripe/connect | Connected-account events (account.updated) |
| Stripe (test mode) | /billing/marketplace/webhooks/stripe/account | Platform events (payment_intent.*, charge.refunded) |
A Cashfree marketplace order carries its own order_meta.notify_url. The backend
makes it from MARKETPLACE_WEBHOOK_BASE_URL, so a marketplace payment webhook
comes to the marketplace endpoint. Production ignores this variable. A plain
http URL gives no notify_url, because Cashfree refuses it.
In development, set MARKETPLACE_WEBHOOK_BASE_URL to your tunnel. If you do not
set it, a local order has no notify_url, and the backend logs this one time.
Cashfree then uses the dashboard URL. The backend never falls back to the route
host, because the development credentials point it at staging: a fallback sends
the signed webhooks of a local order to staging. Staging uses its own API origin
when the variable is not set.
Session variables
Set these in the shell that starts the backend. They live for one session. Do not write a secret into a file, a commit or a log. Do not echo a value.
| Variable | Value |
|---|---|
RAILS_MASTER_KEY | From the main checkout's backend/config/credentials/development.key. Do not copy the key file into a worktree. development.yml.enc is not in git: in a worktree that has none, copy the encrypted file (never the key) from the main checkout. |
MARKETPLACE_WEBHOOK_BASE_URL | https://<handle>-dev.kulcare.com |
RAZORPAY_MARKETPLACE_KEY_ID, RAZORPAY_MARKETPLACE_KEY_SECRET | From the development credentials test_razorpay_key and test_razorpay_secret |
RAZORPAY_MARKETPLACE_WEBHOOK_SECRET_CURRENT | The new secret that you type in the Razorpay test dashboard. It is not the API secret. |
STRIPE_CONNECT_PLATFORM_SECRET_KEY | From the development credentials stripe_api_key (a sk_test_ key) |
STRIPE_CONNECT_WEBHOOK_SECRET_CONNECT_CURRENT, STRIPE_CONNECT_WEBHOOK_SECRET_ACCOUNT_CURRENT | The signing secrets of the two Stripe test endpoints |
Credentials stay first: a variable is read only when the credential is blank. Read a value with a command that prints nothing, for example:
export RAZORPAY_MARKETPLACE_KEY_ID="$(cd <main checkout>/backend && bin/rails runner 'print Rails.application.credentials.test_razorpay_key')"Cashfree needs no variable. The marketplace reads the same cashfree: block as
the single-merchant path, and its webhook check accepts client_secret.
A direct gateway API call (for example a sandbox check) runs in bin/rails runner
with the gateway's HTTP client, which reads the keys inside the process. Do not
use curl with x-client-id or x-client-secret on a command line.
Cashfree credentials (local)
The gateway reads from Rails credentials (config/credentials/development.yml.enc):
cashfree:
client_id: "<sandbox App ID>"
client_secret: "<sandbox Secret Key>"
env: sandbox
# webhook_secret is the SAME value as client_secret (Cashfree PG signs webhooks
# with your Secret Key). Optional locally — dev allows unverified webhooks;
# required on staging/production (they fail closed).
webhook_secret: "<same as client_secret>"To actually route a clinic's checkout through Cashfree (IN defaults to Razorpay
until the BILLING_CASHFREE_ENABLED flag is on), buy a package from a clinic
whose settings.billing.gateway is cashfree — the dev seed
db/seeds/identity/04d_clinic_care_packages.rb does this for City Hospital.
Commands
| Command | What |
|---|---|
bin/dev-tunnel setup | one-time: login + create tunnel + DNS + render config (idempotent) |
bin/dev-tunnel run | start the tunnel |
bin/dev-tunnel url | print your webhook URLs (single-merchant and marketplace) |
bin/dev-tunnel status | show tunnel + config state |
Overrides: DEV_HANDLE=<handle>, BACKEND_PORT=<port>,
KULCARE_TUNNEL_ZONE=<zone>.
Staging / production
No tunnel needed — those are already publicly reachable. Add the cashfree:
block (including webhook_secret) to that environment's credentials and register
https://<env-host>/billing/webhooks/cashfree in the Cashfree dashboard. For the
marketplace, also register https://<env-host>/billing/marketplace/webhooks/cashfree
(see docs/guides/cashfree-setup.md). Do not set MARKETPLACE_WEBHOOK_BASE_URL
in production.