Kulcare Docs

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:

EnvAPI hostWebhook URL
sandbox (dev)https://sandbox.cashfree.comhttps://<staging-or-local-tunnel>/billing/webhooks/cashfree
productionhttps://api.cashfree.comhttps://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:

URLAPI versionPolicy + EventsActions
https://...2025-01-01Policy 1: DEFAULT + event listTest / 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)'
end

This 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 labeltype value in payloadHandled by
subscription status changedSUBSCRIPTION_STATUS_CHANGEDhandle_cashfree_subscription_status_changed — activates/cancels/expires/pauses a pending subscription
subscription payment successSUBSCRIPTION_PAYMENT_SUCCESShandle_cashfree_subscription_payment_success — creates the subscription invoice
subscription payment failedSUBSCRIPTION_PAYMENT_FAILEDhandle_cashfree_subscription_payment_failed — marks the subscription past_due
subscription payment cancelledSUBSCRIPTION_PAYMENT_CANCELLEDLogged only (no state change)
success paymentPAYMENT_SUCCESS / PAYMENT_SUCCESS_WEBHOOKhandle_cashfree_payment_success — marks Billing::Order paid (care-package, telemedicine, appointment)
failed paymentPAYMENT_FAILED / PAYMENT_FAILED_WEBHOOKhandle_cashfree_payment_failed — marks Billing::Order failed
user dropped paymentPAYMENT_USER_DROPPED_WEBHOOKSame handler as failed payment
success payment tdrPAYMENT_SUCCESS_TDR_WEBHOOKNot 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:doctor

If 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. Only Marketplace::CashfreeWebhooksController reads them (see Section 3.1). The subscription webhook at /billing/webhooks/cashfree reads :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.

ItemValue
Endpointhttps://<env-host>/billing/marketplace/webhooks/cashfree, version 2025-01-01
EventsPayment 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_*)
SignatureBase64(HMAC-SHA256(x-webhook-timestamp + raw body, secret)), with no separator
Timestampx-webhook-timestamp in milliseconds. A delivery more than 25 hours old or more than 1 minute in the future gets 400.
DedupA 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:doctor

Expected 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)

TierMonthlyQuarterlySemi-AnnualAnnual
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: 99900 would create a ₹0.999 plan in the Cashfree dashboard. Use 999.

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_plans

The 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_annual

For each plan set:

FieldValue
plan_typePERIODIC
plan_currencyINR
plan_recurring_amountrupees (see matrix above)
plan_max_amountsame as plan_recurring_amount (no over-charges)
plan_intervals1 (monthly), 3 (quarterly), 6 (semi-annual), 12 (annual)
plan_interval_typeMONTH

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'
# => 12

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.

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, through backend/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_plans

In production, or when only a Cashfree ID is missing, link the IDs only:

bundle exec rake billing:link_cashfree_plan_ids

Expected 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_monthly

On 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:

  1. Create the new plans in the new account's dashboard (Section 4) with new plan_id values.

  2. Update backend/lib/data/billing/cashfree_plan_ids.json with the new IDs.

  3. For each plan where the ID changed, clear the old cashfree key before re-seeding — the seed's blank? 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
  4. 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 webhook shows a 200 response (or your reverse-proxy logs show a 200 from the Rails app).
  • The Rails app log shows a [Webhook] ... line followed by Completed 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::CashfreeWebhook model — the generic WebhooksController writes via Billing::MarketplaceWebhookEvent for marketplace flows and otherwise logs only). Search the logs for [Webhook] to confirm.
  • bundle exec rake cashfree:doctor reports OK (proves in-process round-trip; not a substitute for the dashboard Test button).

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:

  1. Confirm the code fallback is in place. The controller must read :webhook_secret first, 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.
  2. Run bundle exec rake cashfree:doctor. With the Section 1.2 patch applied, the rake task now prints effective webhook secret : set and shows which key it used (webhook_secret or client_secret (fallback)).
  3. Click Test on the webhook row in the Cashfree dashboard. Pick SUBSCRIPTION_STATUS_CHANGED and Send test. The Rails app returns 200 only if the controller's HMAC matches Cashfree's. This is the authoritative signal — cashfree:doctor only proves in-process round-trip, not that Cashfree agrees on the key.
  4. Still 401? Most likely cause: someone set :webhook_secret to a stale value while the controller's fallback is picking up :client_secret. They disagree. Either delete :webhook_secret entirely (so the fallback wins) or set it to exactly the same string as :client_secret.
  5. 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 seed

If {}, 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 be true (bundle exec rake cashfree:doctor reports 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.142

If 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:

MessageMeaning
A payment for this facility is still confirmingThe 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 gatewayCashfree 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 | :unknown

Only :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 host

HttpClient#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:

SituationBehaviour
Server and VITE_APP_ENV agreeUse it
They disagree, VITE_APP_ENV is not productionThrow. A silently-working checkout would leave the deploy config wrong and lose the signal
They disagree, VITE_APP_ENV is productionServer wins, mismatch logged. Breaking a real customer's checkout is worse
Response has no envFall 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

EnvBase
Sandboxhttps://sandbox.cashfree.com
Productionhttps://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/json

Webhook URL

EnvURL
Staginghttps://api-stg.kulcare.com/billing/webhooks/cashfree
Productionhttps://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_monthly

Rake tasks

TaskPurpose
bundle exec rake cashfree:doctorVerify credentials, feature-flag state, HMAC round-trip (in-process only)
bundle exec rake cashfree:create_plansCreate 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_plansSeed Essential and Premium, their placeholder price rows and their Cashfree IDs (staging and development; idempotent)
bundle exec rake billing:link_cashfree_plan_idsWrite only the missing Cashfree IDs on Essential and Premium (any environment, production included)
bundle exec rake new_schema:seed_subscription_plansRetired. 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_signature falls back to :client_secret when :webhook_secret is blank (Section 1.2).
  • cashfree:doctor rake 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 as client_secret for this account configuration), cashfree.env (Section 3).
  • Subscription events + one-time payment events subscribed (Section 2).
  • bundle exec rake cashfree:doctor reports effective webhook secret : set and HMAC sign+verify ... : OK.
  • 12 IN plans created in Cashfree — via bundle exec rake cashfree:create_plans for sandbox, or CASHFREE_ENV=production FORCE_PRODUCTION=1 bundle exec rake cashfree:create_plans for production (Section 4). Output ends with Created: 12 (or Created: <n> + Skipped: <12-n> on a re-run).
  • backend/lib/data/billing/cashfree_plan_ids.json populated (Section 5.1).
  • Essential and Premium are linked to their Cashfree IDs (Section 5.3): billing:seed_go_forward_plans on staging, billing:link_cashfree_plan_ids in production.
  • Essential and Premium carry gateway_plan_ids['cashfree'] (Section 5.4).
  • Dashboard Test button on the webhook row sends a test SUBSCRIPTION_STATUS_CHANGED and 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).

On this page

0. What this guide assumes1. Where the webhook secret actually lives — and the surprise1.1 — What you actually put in credentials1.2 — Code fix: fall back to client_secret when webhook_secret is blank2. Webhook events — what the dashboard calls them, what the code expects3. Configure Rails credentials3.1 — Marketplace endpoint (vendor packages, Easy Split)4. Create subscription plans in CashfreePricing matrix (INR, base units — not paise)Create plans via the rake task (recommended)Create plans via the dashboard (manual)Verify5. Link plans to the database — via the seed task5.1 — Add the plan-ID map5.2 — How the link works5.3 — Run it5.4 — Verify5.5 — Switching to a new Cashfree account (rotation)6. End-to-end test6.1 — Trigger a test webhook (this is the real signature check)6.2 — Trigger a real subscription initiation6.3 — Authorize the mandate in Cashfree's test checkout7. Troubleshooting7.1 — unauthorized (401) on every webhook7.2 — Plan not found at initiation ("This plan is not available for online checkout")7.2a — cashfree:create_plans reports unexpected errors during the probe step7.3 — IN customers still hitting Razorpay7.4 — Webhook URL not reachable7.5 — Events arrive but nothing happens7.6 — "Facility already has a pending subscription", or a blocked re-subscribe8. Rollback8b. Which environment a call reachesThe frontend must not decide this9. Quick referenceCashfree API base URLsRequired headers for API callsWebhook URLWebhook signature headerPlan-ID naming conventionRake tasks10. Checklist