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
| Capability | Notes |
|---|---|
| Payment Links (one-time) | POST /pg/links — used for chat checkout, cart recovery |
| Orders + Sessions | POST /pg/orders — used for direct storefront checkout |
| Subscriptions (recurring) | UPI AutoPay, NACH, card-on-file mandates |
| Easy Split | Charge splits across multiple receiving accounts (marketplaces, multi-outlet) |
| Refunds | Full + partial, with idempotency key = refund ID |
| Webhooks | Inbound event stream — orders, payments, subscriptions, refunds |
Credentials
Cashfree connection needs the following fields, all set per-workspace:
| Field | Where it goes | Notes |
|---|---|---|
app_id | Workspace settings | Public, identifies your merchant account |
secret_key | Workspace settings (encrypted) | Used as the bearer secret for API calls |
webhook_secret | Workspace settings (encrypted) | Primary — signs most webhook events |
webhook_secret_secondary | Workspace settings (encrypted) | Secondary — signs abandoned-checkout events only |
mode | sandbox | production | Endpoint 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_headerThis 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.
Workspace round-trip via subscription_tags / link_notes
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_tagson subscription create — echoed back on every subscription / mandate / renewal eventlink_noteson Payment Link create — echoed back onpayment_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_tagsis byte-clean across the round-trip but ordering is preserved as-inserted, not alphabetical — don’t assume canonical ordering on the responselink_notesenforces the same sanitisation rules as thenotesfield (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 200Cashfree 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:
business_typeis a fixed enum — “Other” is rejected. Default toMiscellaneouswhen in doubt.- UPI-only vendors need an explicit “UPI capability” —
vendor_capabilities: ["UPI"]on creation. Bank-account vendors don’t need this. - Vendor DELETE only works from
ACTIVEstate — a vendor inKYC_PENDINGcan 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:
- Active Reach creates a
subscription_plan(one-time, per pricing tier) - On enrolment, creates a
subscriptionreferencing the plan + customer - Cashfree returns a
mandate_authorization_url - The customer completes authorisation (UPI AutoPay confirmation, NACH form, etc.)
- Cashfree fires a
mandate.activatedwebhook - 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:
| Mode | Who holds the Cashfree merchant account | Best for |
|---|---|---|
| Managed | Active Reach holds a master account; tenants are sub-vendors | Fast onboarding, single platform fee |
| BYOM (Bring Your Own Merchant) | Tenant holds their own Cashfree account | Direct 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— primaryCASHFREE_ABANDONED_CHECKOUT_SECRET— secondaryCASHFREE_BASE_URL— overrides default (sandbox:https://sandbox.cashfree.com, production:https://api.cashfree.com)
What’s next
- Payment processors — operator UI
- Paid memberships — recurring billing use case
- Webhooks → security — webhook signature patterns across providers