Cashfree Setup — Staging & Production
This guide configures the Cashfree payment gateway for subscription and one-time payments on Kulcare. It reflects what the dashboard actually shows (event list names, secret discovery) and how the application code reads credentials.
Scope: New / rotated Cashfree merchant account. The plan-to-gateway linking
goes through the seed file — never rails console. The previous guide's
"Part 4 — Rails console linking" is wrong for this project and is not used.
0. What this guide assumes
- A Cashfree merchant account exists (sandbox for dev/staging; production for prod).
- You have access to Rails credentials for the target environment.
- You can reach the Cashfree dashboard at
https://merchant.cashfree.com.
The guide uses these environment-specific values throughout:
| Env | API host | Webhook URL |
|---|---|---|
| sandbox (dev) | https://sandbox.cashfree.com | https://<staging-or-local-tunnel>/billing/webhooks/cashfree |
| production | https://api.cashfree.com | https://api.kulcare.com/billing/webhooks/cashfree |
Staging URL: https://api-stg.kulcare.com/billing/webhooks/cashfree (matches what was configured).
1. Where the webhook secret actually lives — and the surprise
This is the gotcha that bit this project. Cashfree's webhook listing in the current dashboard exposes only these columns per webhook row:
| URL | API version | Policy + Events | Actions |
|---|---|---|---|
https://... | 2025-01-01 | Policy 1: DEFAULT + event list | Test / Edit / Delete |
There is no "Generate Secret" button, no "Show secret" affordance, and no secret column. The HTML for the row (verified live against the staging account) is:
<td>URL</td><td>2025-01-01</td><td>Policy 1: DEFAULT + Events</td>
<td>Test | Edit | Delete</td>That matches Cashfree's own documentation:
"You need your Cashfree PG secret key and the payload to verify the signature." — https://www.cashfree.com/docs/payments/online/webhooks/signature-verification
In other words: Cashfree signs webhooks with the same client_secret you use
for API auth. There is no separate "webhook secret" for this account
configuration. The earlier version of this guide assumed a separate secret
existed, and the codebase in turn expects a separate :webhook_secret
credential key — both are wrong, and the codebase needs a one-line fix
(Section 1.2).
1.1 — What you actually put in credentials
The application reads:
# backend/domains/billing/controllers/webhooks_controller.rb
secret = Rails.application.credentials.dig(:cashfree, :webhook_secret)So :webhook_secret is still the configured key, but its value must equal
your client_secret for accounts in this configuration. In credentials:
cashfree:
client_id: YOUR_CLIENT_ID
client_secret: YOUR_CLIENT_SECRET
webhook_secret: YOUR_CLIENT_SECRET # same as client_secret — not a typo
env: production # or "sandbox"The cashfree:doctor rake task only verifies in-process round-trip; it cannot
prove Cashfree's signing key matches yours. After saving, click the
Test button on the webhook row in the dashboard and pick
SUBSCRIPTION_STATUS_CHANGED. If the application returns 200, the keys match.
If it returns 401, the configured webhook_secret does not match what Cashfree
signed with — Section 7.1 covers diagnosis.
1.2 — Code fix: fall back to client_secret when webhook_secret is blank
The current code is wrong for any Cashfree account in this dashboard
configuration (no separate secret). Add a one-line fallback so the code mirrors
Cashfree's actual signing model. Patch
backend/domains/billing/controllers/webhooks_controller.rb:
def verify_signature(gateway_name, raw_body)
# Cashfree signs webhooks with the PG secret key (= client_secret) on
# accounts that do not expose a separate webhook secret in the dashboard.
# Prefer :webhook_secret when configured (defensive — accounts that DO
# expose a separate secret can still use it), fall back to :client_secret
# so this controller works against both account configurations.
secret = Rails.application.credentials.dig(gateway_name, :webhook_secret).presence ||
Rails.application.credentials.dig(gateway_name, :client_secret)
...Then the webhooks_controller.rb failure-to-find-secret path becomes
unreachable for Cashfree specifically (it still guards Razorpay/Stripe which
DO use a separate secret), but the controller still fails closed if neither
credential is present. No Rails-infra changes needed.
While you're in the file, update the cashfree:doctor rake task to make this
explicit. Patch backend/lib/tasks/cashfree.rake:
# Replace the existing webhook-secret block:
has_wh = creds[:webhook_secret].present?
has_secret = creds[:client_secret].present?
# If no dedicated webhook_secret, Cashfree signs with client_secret.
effective_secret = creds[:webhook_secret].presence || creds[:client_secret]
...
puts "webhook_secret : #{yn.call(has_wh)}#{wh_note}"
puts "client_secret : #{yn.call(has_secret)} # used as webhook secret if dedicated one is unset"
puts "effective webhook secret : #{effective_secret.present? ? 'set' : 'NOT SET'}"
...
# And the round-trip section:
secret = effective_secret
if secret.present?
ts = Time.now.to_i.to_s
body = '{"type":"ping"}'
sig = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, "#{ts}#{body}"))
ok = Billing::Gateways::CashfreeGateway.new.verify_webhook_signature(
body: body, signature: sig, secret: secret, timestamp: ts
)
puts " HMAC sign+verify with #{has_wh ? 'webhook_secret' : 'client_secret (fallback)'}: #{ok ? 'OK' : 'FAILED'}"
else
puts ' skipped (no webhook_secret or client_secret configured)'
endThis makes the fallback visible in the rake output — anyone running
cashfree:doctor after the fix sees exactly which key the controller will use.
2. Webhook events — what the dashboard calls them, what the code expects
The dashboard shows human-friendly event names. The application matches on the
machine event type sent in the JSON body's "type" field. Both forms are
listed below for the events the application actually handles.
| Dashboard label | type value in payload | Handled by |
|---|---|---|
| subscription status changed | SUBSCRIPTION_STATUS_CHANGED | handle_cashfree_subscription_status_changed — activates/cancels/expires/pauses a pending subscription |
| subscription payment success | SUBSCRIPTION_PAYMENT_SUCCESS | handle_cashfree_subscription_payment_success — creates the subscription invoice |
| subscription payment failed | SUBSCRIPTION_PAYMENT_FAILED | handle_cashfree_subscription_payment_failed — marks the subscription past_due |
| subscription payment cancelled | SUBSCRIPTION_PAYMENT_CANCELLED | Logged only (no state change) |
| success payment | PAYMENT_SUCCESS / PAYMENT_SUCCESS_WEBHOOK | handle_cashfree_payment_success — marks Billing::Order paid (care-package, telemedicine, appointment) |
| failed payment | PAYMENT_FAILED / PAYMENT_FAILED_WEBHOOK | handle_cashfree_payment_failed — marks Billing::Order failed |
| user dropped payment | PAYMENT_USER_DROPPED_WEBHOOK | Same handler as failed payment |
| success payment tdr | PAYMENT_SUCCESS_TDR_WEBHOOK | Not handled — informational. Safe to subscribe to; the controller logs and returns 200. |
The list you set up (success payment tdr, success payment, failed payment,
user dropped payment, subscription payment cancelled, subscription payment failed, subscription payment success, subscription status changed) matches
exactly the events the controller routes. No changes needed here.
The API version header is 2025-01-01 — that is correct.
3. Configure Rails credentials
Open the credentials file for the target environment:
bin/rails credentials:edit --environment staging
# (or --environment production / --environment development)Add (or rotate) the cashfree: block. With the Section 1.2 code fix in place,
:webhook_secret falls back to :client_secret when blank, so the block
below works whether or not your Cashfree account exposes a separate webhook
secret:
cashfree:
client_id: YOUR_CLIENT_ID_HERE
client_secret: YOUR_CLIENT_SECRET_HERE
webhook_secret: YOUR_CLIENT_SECRET_HERE # same value — see Section 1
env: production # or "sandbox" — drives host in cashfree:doctorIf your account later exposes a real dedicated webhook secret (some accounts do),
override webhook_secret: with that value — the controller prefers it over the
client_secret fallback.
⚠️ Do not write
webhook_secret_current:/webhook_secret_previous:for this surface. OnlyMarketplace::CashfreeWebhooksControllerreads them (see Section 3.1). The subscription webhook at/billing/webhooks/cashfreereads:webhook_secret(with the fallback above). Mixing them up causes signature verification to fail closed and every webhook returns 401.
3.1 — Marketplace endpoint (vendor packages, Easy Split)
The marketplace uses the same cashfree: block. It needs no other key.
| Item | Value |
|---|---|
| Endpoint | https://<env-host>/billing/marketplace/webhooks/cashfree, version 2025-01-01 |
| Events | Payment success, failed and user dropped (PAYMENT_SUCCESS_WEBHOOK, PAYMENT_FAILED_WEBHOOK, PAYMENT_USER_DROPPED_WEBHOOK); Refund (REFUND_STATUS_WEBHOOK); Easy Split vendor status (VENDOR_STATUS_UPDATE); vendor settlement (VENDOR_SETTLEMENT_*) |
| Signature | Base64(HMAC-SHA256(x-webhook-timestamp + raw body, secret)), with no separator |
| Timestamp | x-webhook-timestamp in milliseconds. A delivery more than 25 hours old or more than 1 minute in the future gets 400. |
| Dedup | A key from signed body fields: type, payment, refund, vendor or settlement id, status and event_time. The x-idempotency-key header is not signed, so it never sets the key. |
Secret list. The marketplace endpoint accepts a signature made with any of
these keys, in this order: webhook_secret_current, webhook_secret,
client_secret, webhook_secret_previous. Blank keys are left out. Cashfree
signs with the PG secret key (client_secret), so no other key is necessary.
With no key set, every delivery gets 400.
Rotation rule. When you rotate the secret, remove
cashfree.webhook_secret_previous from the credentials when the rotation is
complete. Do this not later than 25 hours after the new secret is live (the past
bound above).
One account, two endpoints. One Cashfree account serves the single-merchant
and the marketplace paths, so each endpoint can get the other path's events.
Each endpoint ignores the other's orders with 200 and no alert. A marketplace
order id starts with MKT-, and a marketplace order sends its own
order_meta.notify_url. The single-merchant endpoint answers an MKT- event
before it writes a razorpay_webhooks row or starts a job, so it leaves no
record there. Make sure the dashboard allows two endpoints for one event.
Vendor statuses. VENDOR_STATUS_UPDATE is stored with no bank, UPI, KYC or
contact field. The handler applies the live vendor status from Cashfree, not the
status in the event. SyncVendorAccountsJob (every 30 minutes) is the backstop
for a lost event. Run it one time after a deploy that changes the status map.
After saving credentials, verify with:
bundle exec rake cashfree:doctorExpected output (secrets redacted):
== Cashfree doctor ==
Rails env : staging
-- Credentials --
client_id : set (…1234)
client_secret : yes
webhook_secret : yes
env : production (api.cashfree.com)
-- Market routing --
BILLING_CASHFREE_ENABLED : yes
IN market default gateway : cashfree
-- Webhook secret round-trip --
HMAC sign+verify with configured webhook_secret: OK
-- Verdict --
Credentials present. A checkout can be created.If HMAC sign+verify ... FAILED, the secret in credentials does not match the
one Cashfree is signing with. Most often this means you copied the client
secret instead of the webhook secret (Section 1) — re-check.
4. Create subscription plans in Cashfree
The application sends 12 IN plans to Cashfree at initiation time (4 tiers × 4 cycles, excluding the Free tier). Free-tier plans need no Cashfree plan ID because the price is ₹0 and the gateway is bypassed for free.
Pricing matrix (INR, base units — not paise)
| Tier | Monthly | Quarterly | Semi-Annual | Annual |
|---|---|---|---|---|
| Starter | ₹999 | ₹2,699 | ₹4,999 | ₹8,999 |
| Professional | ₹4,999 | ₹13,499 | ₹24,999 | ₹44,999 |
| Enterprise | ₹14,999 | ₹40,499 | ₹74,999 | ₹1,34,999 |
Important: Cashfree's PG Plans API takes amounts in rupees, not paise. The Orders / Payments API takes amounts in paise (the smallest unit). The original guide got this wrong —
plan_recurring_amount: 99900would create a ₹0.999 plan in the Cashfree dashboard. Use999.
Create plans via the rake task (recommended)
The cleanest way is the idempotent cashfree:create_plans rake task — it reads
the same lib/data/billing/cashfree_plan_ids.json the seed uses (single source
of truth), probes each plan first, and only creates what's missing. Re-running
is safe and fast.
# Sandbox (default)
bundle exec rake cashfree:create_plans
# Production (e.g. account rotation)
CASHFREE_ENV=production bundle exec rake cashfree:create_plansThe task refuses to run against production unless you explicitly opt in with
FORCE_PRODUCTION=1 (creating 12 plans against production by accident is the
kind of mistake you want to type two extra characters to avoid):
FORCE_PRODUCTION=1 CASHFREE_ENV=production bundle exec rake cashfree:create_plans"Production" here means the resolved Cashfree environment, not Rails.env
(#2122). A production Rails process pointed at sandbox credentials writes to
sandbox, so the guard no longer fires there — it protected nothing and taught
operators to pass FORCE_PRODUCTION from habit. The task prints the resolved
environment before it does anything, and says so explicitly when Rails is in
production while Cashfree is not.
Expected output:
⊙ kulcare_in_starter_monthly already exists — skipping
✓ Created kulcare_in_starter_quarterly (₹2699, every 3 month(s))
✓ Created kulcare_in_starter_semi_annual (₹4999, every 6 month(s))
…
== Summary ==
Created: 11
Skipped (existed): 1
Failed: 0(The task also prints a warning at the end if any non-free IN plan in the
local DB still has no cashfree key in gateway_plan_ids — meaning the seed
in Section 5 hasn't been run yet.)
The rake task lives in backend/lib/tasks/cashfree.rake. It uses
Billing::Gateways::Cashfree::HttpClient for auth and base-URL selection, so
it inherits the same environment resolution and the same
x-api-version: 2025-01-01 headers the live gateway uses. Pricing comes from a matrix inside the task that mirrors
db/seeds/billing/00_subscription_plans.rb — change the seed matrix and the
task matrix together.
Create plans via the dashboard (manual)
If you want to see each plan appear in the Cashfree UI for verification (or you can't run a rake task in your environment), use the dashboard:
Navigate to Subscriptions → Plans → Create Plan and create each plan with
these plan_id values (the IDs are the contract between the dashboard and the
seed file in Section 5):
kulcare_in_starter_monthly
kulcare_in_starter_quarterly
kulcare_in_starter_semi_annual
kulcare_in_starter_annual
kulcare_in_professional_monthly
kulcare_in_professional_quarterly
kulcare_in_professional_semi_annual
kulcare_in_professional_annual
kulcare_in_enterprise_monthly
kulcare_in_enterprise_quarterly
kulcare_in_enterprise_semi_annual
kulcare_in_enterprise_annualFor each plan set:
| Field | Value |
|---|---|
plan_type | PERIODIC |
plan_currency | INR |
plan_recurring_amount | rupees (see matrix above) |
plan_max_amount | same as plan_recurring_amount (no over-charges) |
plan_intervals | 1 (monthly), 3 (quarterly), 6 (semi-annual), 12 (annual) |
plan_interval_type | MONTH |
Verify
Either path lands the same 12 rows. Confirm with:
curl -sS -H "x-client-id:$CID" -H "x-client-secret:$CSEC" \
-H 'x-api-version: 2025-01-01' \
"https://sandbox.cashfree.com/pg/plans" \
| jq '.data | length'
# => 125. Link plans to the database — via the seed task
This replaces the original guide's Part 4. Do not use rails console to set
gateway_plan_ids — this project seeds everything, every other billing row is
seeded, and console edits are not committed, replayed, or reviewable.
5.1 — Add the plan-ID map
Create (or edit) backend/lib/data/billing/cashfree_plan_ids.json:
{
"IN": {
"starter": { "monthly": "kulcare_in_starter_monthly", "quarterly": "kulcare_in_starter_quarterly", "semi_annual": "kulcare_in_starter_semi_annual", "annual": "kulcare_in_starter_annual" },
"professional":{ "monthly": "kulcare_in_professional_monthly","quarterly": "kulcare_in_professional_quarterly","semi_annual": "kulcare_in_professional_semi_annual","annual": "kulcare_in_professional_annual" },
"enterprise": { "monthly": "kulcare_in_enterprise_monthly", "quarterly": "kulcare_in_enterprise_quarterly", "semi_annual": "kulcare_in_enterprise_semi_annual", "annual": "kulcare_in_enterprise_annual" }
}
}US and AE rows are intentionally absent — those regions route to Razorpay
(Billing::Gateways::GatewayConfig::MARKET_GATEWAYS) and need no Cashfree plan
IDs.
5.2 — How the link works
Billing::PriceCatalog::LinkCashfreePlanIds
(backend/domains/billing/services/price_catalog/link_cashfree_plan_ids.rb)
reads the map and writes gateway_plan_ids['cashfree'] on each plan by
region, tier and cycle. It writes only when the plan has no Cashfree ID, and
it keeps the IDs of other gateways (Razorpay) and manual overrides. A re-run
changes nothing.
Kulcare sells two plans, Essential and Premium. They are the IN monthly
professional and enterprise plans, so they link to
kulcare_in_professional_monthly and kulcare_in_enterprise_monthly. The
other entries in the map belong to the old plan matrix, which is no longer
sold.
Three callers run the link:
db:seed, throughbackend/db/seeds/billing/00_subscription_plans.rb(development).rake billing:seed_go_forward_plans(staging and development). It also renames the plans and writes placeholder price rows, so do not use it to link IDs in production.rake billing:link_cashfree_plan_ids(any environment, production included). It writes only the missing Cashfree IDs on Essential and Premium. It changes no name, price or price row. The plans must exist first.
rake new_schema:seed_subscription_plans is retired. It seeded the old
matrix, and now it stops with a pointer to billing:seed_go_forward_plans.
5.3 — Run it
On staging and development, seed the plans and link them in one step:
bundle exec rake billing:seed_go_forward_plansIn production, or when only a Cashfree ID is missing, link the IDs only:
bundle exec rake billing:link_cashfree_plan_idsExpected terminal output (excerpt):
Seeded the go-forward plans and price catalog:
essential <uuid>
premium <uuid>
...
Cashfree plan linked: Essential -> kulcare_in_professional_monthly
Cashfree plan linked: Premium -> kulcare_in_enterprise_monthlyOn a re-run, the two lines read Cashfree plan already:.
billing:link_cashfree_plan_ids prints the same two Cashfree plan lines.
5.4 — Verify
In rails console (read-only check — never write here):
Billing::PriceCatalog::SeedGoForwardPlans.plan(:essential).gateway_plan_ids
# => {"cashfree"=>"kulcare_in_professional_monthly"}
Billing::PriceCatalog::SeedGoForwardPlans.plan(:premium).gateway_plan_ids
# => {"cashfree"=>"kulcare_in_enterprise_monthly"}5.5 — Switching to a new Cashfree account (rotation)
When you rotate to a new Cashfree account whose plan IDs differ:
-
Create the new plans in the new account's dashboard (Section 4) with new
plan_idvalues. -
Update
backend/lib/data/billing/cashfree_plan_ids.jsonwith the new IDs. -
For each plan where the ID changed, clear the old
cashfreekey before re-seeding — the seed'sblank?guard will refuse to overwrite otherwise:# ONE-OFF: in rails console on staging/prod (or via a one-off rake task) Billing::SubscriptionPlan.where(region: 'IN').find_each do |p| ids = p.gateway_plan_ids || {} next unless ids.key?('cashfree') ids.delete('cashfree') p.update_columns(gateway_plan_ids: ids) # skip callbacks end -
Link the new IDs:
bundle exec rake billing:link_cashfree_plan_ids.
In-flight subscriptions on the old account are not affected — the runtime
resolves them by gateway_subscription_id (stored in billing.subscriptions),
not by gateway_plan_ids. Only new subscriptions created against the
rotated account use the new IDs.
6. End-to-end test
6.1 — Trigger a test webhook (this is the real signature check)
From the Cashfree dashboard, on the webhook row for the staging URL, click
Test (the action in the row's rightmost cell) and pick
SUBSCRIPTION_STATUS_CHANGED. This is the authoritative end-to-end
signature check — it actually round-trips a real Cashfree-signed request at
your URL, in a way cashfree:doctor cannot.
Verify:
tail -f log/staging.log | grep -i webhookshows a 200 response (or your reverse-proxy logs show a 200 from the Rails app).- The Rails app log shows a
[Webhook] ...line followed byCompleted 200 OK(signature verification passed). - The latest received event is logged in whatever table your version uses to
record received webhooks (in this codebase there is no per-gateway
Billing::CashfreeWebhookmodel — the genericWebhooksControllerwrites viaBilling::MarketplaceWebhookEventfor marketplace flows and otherwise logs only). Search the logs for[Webhook]to confirm. bundle exec rake cashfree:doctorreportsOK(proves in-process round-trip; not a substitute for the dashboardTestbutton).
If the dashboard Test button returns 401, jump to Section 7.1 — the
configured secret does not match what Cashfree signed with.
6.2 — Trigger a real subscription initiation
curl -X POST 'https://api-stg.kulcare.com/billing/subscriptions/initiate' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_JWT_TOKEN' \
-d '{
"plan_id": "STARTER_MONTHLY_IN_PLAN_UUID",
"facility_id": "YOUR_FACILITY_UUID"
}'(Use the actual Billing::SubscriptionPlan UUID for the IN starter monthly plan;
it is not kulcare_in_starter_monthly — that's the Cashfree-side ID.)
Expected response shape:
{
"data": {
"gateway": "cashfree",
"gateway_subscription_id": "sub_xxx",
"checkout_token": "session_xxx",
"amount": 999.0,
"currency": "INR",
"plan_name": "Starter Monthly - IN"
},
"meta": { "request_id": "xxx" }
}If you get "This plan is not available for online checkout", the plan row has
no cashfree key in gateway_plan_ids — re-run the seed (Section 5.3).
6.3 — Authorize the mandate in Cashfree's test checkout
Cashfree PG Subscriptions use a mandate (not a one-time payment). The test
card form is at https://sandbox.cashfree.com/pg-view/subscription/<token>
when you load checkout_token from the previous response in the browser.
Complete the test mandate with sandbox test card details, then verify:
Billing::Subscription.find_by(gateway_subscription_id: 'sub_xxx').status
# => "active" (set by SUBSCRIPTION_STATUS_CHANGED webhook)
Billing::SubscriptionInvoice.where(subscription_id: subscription.id).count
# => 1 (created by SUBSCRIPTION_PAYMENT_SUCCESS webhook)7. Troubleshooting
7.1 — unauthorized (401) on every webhook
Cause: the value Rails is HMAC'ing with does not match the value Cashfree signed with.
For accounts in this dashboard configuration (no separate webhook secret), Cashfree signs with the client_secret. The diagnostic ladder:
- Confirm the code fallback is in place. The controller must read
:webhook_secretfirst, then fall back to:client_secret(Section 1.2 patch). If the fallback is missing and you have not set:webhook_secret, every webhook returns 401 on staging/prod. - Run
bundle exec rake cashfree:doctor. With the Section 1.2 patch applied, the rake task now printseffective webhook secret : setand shows which key it used (webhook_secretorclient_secret (fallback)). - Click
Teston the webhook row in the Cashfree dashboard. PickSUBSCRIPTION_STATUS_CHANGEDandSend test. The Rails app returns 200 only if the controller's HMAC matches Cashfree's. This is the authoritative signal —cashfree:doctoronly proves in-process round-trip, not that Cashfree agrees on the key. - Still 401? Most likely cause: someone set
:webhook_secretto a stale value while the controller's fallback is picking up:client_secret. They disagree. Either delete:webhook_secretentirely (so the fallback wins) or set it to exactly the same string as:client_secret. - The Rails process loaded the new credentials (restart Puma/Sidekiq after
editing
credentials.yml.enc).
7.2 — Plan not found at initiation ("This plan is not available for online checkout")
Cause: gateway_plan_ids['cashfree'] is empty for the selected plan.
plan = Billing::PriceCatalog::SeedGoForwardPlans.plan(:essential)
plan.gateway_plan_ids
# => {"cashfree" => "kulcare_in_professional_monthly"} # good
# => {} # missing — re-run seedIf {}, run bundle exec rake billing:link_cashfree_plan_ids.
7.2a — cashfree:create_plans reports unexpected errors during the probe step
If the rake task exits non-zero with a GatewayError it couldn't classify as
"plan absent", it means the GET /plans/{plan_id} call hit a real problem —
most often auth (401), rate limit (429), or a network blip. Re-run after fixing
the underlying issue; the task is idempotent and will pick up where it left
off.
Note: the task treats both HTTP 404 ("not found") and HTTP 400 with
{"code":"plan_not_found","message":"Plan does not exist.",...} as
"plan absent". Cashfree's sandbox returns the 400 variant; some accounts
return the 404 variant. Either way the task proceeds to create the plan.
This is verified live against the sandbox.
7.3 — IN customers still hitting Razorpay
Check Billing::Gateways::GatewayConfig:
Identity::FeatureFlags.enabled?(:BILLING_CASHFREE_ENABLED)should betrue(bundle exec rake cashfree:doctorreports it). When false, IN defaults to Razorpay unless a tenant has an explicit override.- The facility's tenant doesn't have an explicit
settings.billing.gateway='razorpay'override; the doctor rake task prints any overrides and the resolved gateway per tenant. An override beats the flag default.
7.4 — Webhook URL not reachable
Cashfree's webhook IPs (allow-list behind a firewall):
52.66.101.190, 3.109.102.144, 18.60.134.245, 18.60.183.142If you are routing through Cloudflare / a proxy, ensure the raw body reaches Rails unchanged — Cashfree HMACs the raw body, so any middleware that re-serializes JSON will break signature verification.
7.5 — Events arrive but nothing happens
The WebhooksController#process_event only acts on a fixed list of event
types (Section 2). If you subscribed to an event the controller doesn't
recognize (e.g. BENEFICIARY_VALIDATION_WEBHOOK or similar), it is logged at
INFO level as [Webhook] Unhandled event: ... and the webhook returns 200.
This is harmless — add a when clause in process_event if you need to act
on it.
7.6 — "Facility already has a pending subscription", or a blocked re-subscribe
InitiateService writes a pending subscription the moment the mandate page
opens, before any payment. That row is what a re-subscribe attempt runs into.
Since #2121 an abandoned mandate no longer blocks: a retry confirms with the
gateway that the earlier mandate was never authorized, expires it, and proceeds.
Billing::StalePendingCleanupJob is now only a backstop for rows nobody retries.
You will still be refused in two cases, both deliberate:
| Message | Meaning |
|---|---|
A payment for this facility is still confirming | The earlier mandate is authorized (ACTIVE or BANK_APPROVAL_PENDING) and its webhook has not landed. Wait for it. |
Could not confirm an earlier payment with the gateway | Cashfree was unreachable. We refuse rather than guess — see below. |
The second is what you get locally with no Cashfree credentials configured.
Never expire a pending row by hand without checking the mandate first.
CashfreeWebhookProcessor activates rows that are pending or expired, but
an authorized mandate whose row you retire for another reason can still leave a
customer paying for nothing. Check with:
Billing::Subscriptions::MandateState.for(subscription)
# => :unauthorized | :authorized | :unknownOnly :unauthorized is safe to retire. The Account Overview "Payment
Incomplete" card's Discard button runs exactly this check.
8. Rollback
Stop routing Indian customers to Cashfree without removing the seeded plan IDs:
Identity::FeatureFlags.disable(:BILLING_CASHFREE_ENABLED)
# IN now falls back to Razorpay for new customers.
# Existing subscriptions on Cashfree continue to flow through the webhook.Re-enable — clear removes the override so the flag returns to its catalog
default (ON); enable writes an explicit ON override. Either works:
Identity::FeatureFlags.clear(:BILLING_CASHFREE_ENABLED)
Identity::FeatureFlags.enable(:BILLING_CASHFREE_ENABLED)The flag is checked with no actor, so these write the flag's single global override row. See Feature Flags.
8b. Which environment a call reaches
One module answers this: Billing::Gateways::Cashfree::Environment (#2122).
Nothing else may re-derive it.
Precedence is credentials, then CASHFREE_ENV, then sandbox. An
unrecognised value resolves to sandbox rather than raising, because sandbox
cannot move real money and a typo must not take checkout down.
Rails.env is deliberately not consulted. Which Rails environment a process
runs in is a different question from which Cashfree account its calls reach.
Billing::Gateways::Cashfree::Environment.resolve # => "sandbox" | "production"
Billing::Gateways::Cashfree::Environment.base_url # => the matching API hostHttpClient#base_url, cashfree:doctor, and the cashfree:create_plans guard
all read it. To see what a process resolves, run bin/rails cashfree:doctor.
The frontend must not decide this
A checkout_token is minted by one Cashfree account and is meaningless to the
other. Before #2122 the backend read CASHFREE_ENV and the frontend read
VITE_APP_ENV, and when the two deploy configs disagreed the mandate page
failed to open with nothing on screen to explain it.
The subscription initiate response now carries env beside checkout_token,
and resolveCashfreeMode(data.env) obeys it. VITE_APP_ENV is kept only as a
local expectation:
| Situation | Behaviour |
|---|---|
Server and VITE_APP_ENV agree | Use it |
They disagree, VITE_APP_ENV is not production | Throw. A silently-working checkout would leave the deploy config wrong and lose the signal |
They disagree, VITE_APP_ENV is production | Server wins, mismatch logged. Breaking a real customer's checkout is worse |
Response has no env | Fall back to VITE_APP_ENV, so a frontend deployed ahead of the backend still works |
So when you set CASHFREE_ENV on a deployment, set VITE_APP_ENV to match.
Outside production a mismatch now fails loudly instead of producing a blank
Cashfree page.
Razorpay needs none of this — rzp_live_ / rzp_test_ key prefixes already
carry the answer, which is the property #2122 rebuilds for Cashfree by hand.
9. Quick reference
Cashfree API base URLs
| Env | Base |
|---|---|
| Sandbox | https://sandbox.cashfree.com |
| Production | https://api.cashfree.com |
Required headers for API calls
x-client-id: YOUR_CLIENT_ID
x-client-secret: YOUR_CLIENT_SECRET
x-api-version: 2025-01-01
Content-Type: application/jsonWebhook URL
| Env | URL |
|---|---|
| Staging | https://api-stg.kulcare.com/billing/webhooks/cashfree |
| Production | https://api.kulcare.com/billing/webhooks/cashfree |
Webhook signature header
X-Webhook-Signature: Base64( HMAC-SHA256( X-Webhook-Timestamp + raw_body, signing_key ) )signing_key is client_secret for accounts in this dashboard configuration
(no separate webhook secret). The code falls back to client_secret when
webhook_secret is blank — Section 1.2.
Plan-ID naming convention
kulcare_<region>_<tier>_<cycle>
e.g. kulcare_in_starter_monthlyRake tasks
| Task | Purpose |
|---|---|
bundle exec rake cashfree:doctor | Verify credentials, feature-flag state, HMAC round-trip (in-process only) |
bundle exec rake cashfree:create_plans | Create the 12 IN subscription plans in Cashfree from cashfree_plan_ids.json (idempotent, sandbox default; CASHFREE_ENV=production + FORCE_PRODUCTION=1 for prod) |
bundle exec rake billing:seed_go_forward_plans | Seed Essential and Premium, their placeholder price rows and their Cashfree IDs (staging and development; idempotent) |
bundle exec rake billing:link_cashfree_plan_ids | Write only the missing Cashfree IDs on Essential and Premium (any environment, production included) |
bundle exec rake new_schema:seed_subscription_plans | Retired. Stops with a pointer to billing:seed_go_forward_plans |
10. Checklist
- Webhook URL registered in dashboard (Section 1) — verified there is no
secret column on the row, only
URL/version/policy+events/Test|Edit|Delete. - Code fix applied:
webhooks_controller.rb#verify_signaturefalls back to:client_secretwhen:webhook_secretis blank (Section 1.2). -
cashfree:doctorrake task updated to print the effective signing key and which credential was used (Section 1.2). - Credentials updated:
cashfree.client_id,cashfree.client_secret,cashfree.webhook_secret(set to the same value asclient_secretfor this account configuration),cashfree.env(Section 3). - Subscription events + one-time payment events subscribed (Section 2).
-
bundle exec rake cashfree:doctorreportseffective webhook secret : setandHMAC sign+verify ... : OK. - 12 IN plans created in Cashfree — via
bundle exec rake cashfree:create_plansfor sandbox, orCASHFREE_ENV=production FORCE_PRODUCTION=1 bundle exec rake cashfree:create_plansfor production (Section 4). Output ends withCreated: 12(orCreated: <n>+Skipped: <12-n>on a re-run). -
backend/lib/data/billing/cashfree_plan_ids.jsonpopulated (Section 5.1). - Essential and Premium are linked to their Cashfree IDs (Section 5.3):
billing:seed_go_forward_planson staging,billing:link_cashfree_plan_idsin production. - Essential and Premium carry
gateway_plan_ids['cashfree'](Section 5.4). - Dashboard
Testbutton on the webhook row sends a testSUBSCRIPTION_STATUS_CHANGEDand Rails returns 200 (Section 6.1). - Test subscription initiation returns a
checkout_token(Section 6.2). - Test mandate authorization flips the local subscription to
active(Section 6.3).