Skip to Content

Cashfree integration

Cashfree is Active Reach’s primary payment gateway for India. This page covers what you need to wire up the integration correctly — including the parts that aren’t in the public Cashfree docs.

For the operator-side configuration UI, see Payment processors.

Capabilities

CapabilityNotes
Payment Links (one-time)POST /pg/links — used for chat checkout, cart recovery
Orders + SessionsPOST /pg/orders — used for direct storefront checkout
Subscriptions (recurring)UPI AutoPay, NACH, card-on-file mandates
Easy SplitCharge splits across multiple receiving accounts (marketplaces, multi-outlet)
RefundsFull + partial, with idempotency key = refund ID
WebhooksInbound event stream — orders, payments, subscriptions, refunds

Credentials

Cashfree connection needs the following fields, all set per-workspace:

FieldWhere it goesNotes
app_idWorkspace settingsPublic, identifies your merchant account
secret_keyWorkspace settings (encrypted)Used as the bearer secret for API calls
webhook_secretWorkspace settings (encrypted)Primary — signs most webhook events
webhook_secret_secondaryWorkspace settings (encrypted)Secondary — signs abandoned-checkout events only
modesandbox | productionEndpoint base URL differs

The two webhook secrets are a Cashfree quirk: most webhook events sign with the primary client_secret, but the payment_link.abandoned family signs with a separate secret you generate at the abandoned-checkout dashboard. Setting only one breaks the other event family silently.

Webhook signature verification

Critical: Cashfree signs the byte-for-byte raw body, not a parsed/re-serialised JSON dict.

The verification formula:

expected = base64(HMAC-SHA256( key = client_secret, data = x-webhook-timestamp_header + raw_request_body_bytes )) reject if expected != x-webhook-signature_header

This is the Stripe pattern, not the Razorpay pattern. If you parse the body as JSON and then JSON.stringify it before computing the HMAC, you will compute over a different byte sequence (different whitespace, different key ordering) and every webhook will be rejected as a forgery.

The Active Reach gateway preserves raw webhook bytes end-to-end, so signatures verify against exactly what Cashfree signed. If you’re forwarding webhooks through another proxy, ensure that proxy doesn’t re-encode the body (no JSON reserialisation, no whitespace normalisation).

Active Reach’s cell-plane handler at /v1/webhooks/payment/cashfree accepts both secrets via a secrets=[primary, secondary] list — it tries each and accepts the first that validates.

Cashfree provides two opaque envelopes that Active Reach uses to round-trip the originating workspace_id (brand-tenant) through the provider so inbound webhooks can be scoped without a database lookup:

  • subscription_tags on subscription create — echoed back on every subscription / mandate / renewal event
  • link_notes on Payment Link create — echoed back on payment_link.* events

The cell-plane handler extracts workspace_id from the echoed envelope and stamps it onto the resulting delivery_events / timeline_cards row. This is the Tier 1 echo mechanism documented in the workspace-scoped delivery substrate — preferred over Tier 3 (DB lookup) because it survives Cashfree’s at-least-once retry semantics without joining against a customer-id mapping table.

Empirically observed quirks:

  • subscription_tags is byte-clean across the round-trip but ordering is preserved as-inserted, not alphabetical — don’t assume canonical ordering on the response
  • link_notes enforces the same sanitisation rules as the notes field (no control bytes, no empty values)

Webhook idempotency

Cashfree retries webhooks until a 2xx is returned. Build idempotency around the event ID:

# pseudocode event_id = body["data"]["payment"]["cf_payment_id"] # or equivalent per event family if already_processed(event_id): return 200 # silently succeed — retry path process(event) mark_processed(event_id) return 200

Cashfree does not include an Idempotency-Key-style header. The natural per-event idempotency key is buried in the body — extract it per event family.

Easy Split — vendor onboarding gotchas

Three quirks caught in production that aren’t in the public docs:

  1. business_type is a fixed enum — “Other” is rejected. Default to Miscellaneous when in doubt.
  2. UPI-only vendors need an explicit “UPI capability”vendor_capabilities: ["UPI"] on creation. Bank-account vendors don’t need this.
  3. Vendor DELETE only works from ACTIVE state — a vendor in KYC_PENDING can be edited but not deleted; you must finish KYC, transition to ACTIVE, then delete.

These are validated by the Cashfree API and surface as 400 Bad Request with terse error messages.

Refunds

The refund ID must equal the idempotency key:

refund_payload = { "refund_id": idempotency_key, # MUST match "refund_amount": amount_in_rupees, "refund_note": sanitize(note), # see below "refund_speed": "STANDARD", # or "INSTANT" }

If you pass a different refund_id, Cashfree treats it as a new refund attempt instead of retrying the original — risking a double refund on retry. Active Reach enforces refund_id == idempotency_key at the adapter layer.

Notes-field sanitisation

POST /pg/links rejects empty notes values and certain characters (newlines, control bytes) with a confusing 400. Sanitise before sending:

  • Strip control characters
  • Replace newlines with spaces
  • If the result is empty, omit the field entirely (don’t send "")

The Active Reach Cashfree adapter does this automatically.

Amount units

Cashfree’s API uses rupees (e.g. 199.50), not paise (e.g. 19950). This differs from Razorpay (paise) and most international gateways (smallest unit). The Active Reach Cashfree adapter handles conversions automatically — if you call the Cashfree API directly, double-check your unit handling.

Subscriptions — mandate setup

A subscription requires a customer-side mandate authorisation before the first charge:

  1. Active Reach creates a subscription_plan (one-time, per pricing tier)
  2. On enrolment, creates a subscription referencing the plan + customer
  3. Cashfree returns a mandate_authorization_url
  4. The customer completes authorisation (UPI AutoPay confirmation, NACH form, etc.)
  5. Cashfree fires a mandate.activated webhook
  6. Subsequent renewals charge automatically via the activated mandate

Renewal failures fire subscription.renewal_failed webhooks. See paid memberships → dunning for how Active Reach handles dunning around these failures.

Managed vs BYOM

Active Reach supports two onboarding modes:

ModeWho holds the Cashfree merchant accountBest for
ManagedActive Reach holds a master account; tenants are sub-vendorsFast onboarding, single platform fee
BYOM (Bring Your Own Merchant)Tenant holds their own Cashfree accountDirect settlement, full control of refund/dispute policy

The webhook contract is identical between the two — only credential management differs.

Environment variables

For self-hosted / sandbox testing, these env vars are read by the cell-plane Cashfree adapter:

  • CASHFREE_WEBHOOK_SECRET — primary
  • CASHFREE_ABANDONED_CHECKOUT_SECRET — secondary
  • CASHFREE_BASE_URL — overrides default (sandbox: https://sandbox.cashfree.com, production: https://api.cashfree.com)

What’s next