Zoho Payments payment provider for Medusa v2 — India-first, with embedded Checkout Widget, hosted checkout, signed webhooks, refunds, and a browser checkout helper.
Zoho Payments provider for Medusa v2 — India-first, with embedded checkout, hosted checkout, signed webhooks, and refunds.
connects a Medusa v2 store to Zoho Payments in India (, the default) or the United States (). It creates Zoho payment sessions, opens the Zoho Checkout Widget or Hosted Checkout, verifies payments server-side, accepts signed webhooks, and supports full and partial refunds.
This is an independent community integration. It is not affiliated with, endorsed by, or maintained by Zoho Corporation or Medusa.
| Requirement | Details |
|---|---|
| Medusa | ; developed and verified against 2.22.0 |
| Node.js | (this package); Node is the effective floor when running Medusa 2.22 |
| Zoho Payments | An active India or US merchant account |
| Zoho OAuth | An ORG client with the scopes below, created in the same data centre as the account |
| Zoho widget (widget mode) | A publishable API key from Zoho Settings → Developer Space |
| Webhooks | A publicly reachable HTTPS URL for your Medusa backend |
| Database / Redis | Whatever your Medusa deployment already uses; this package adds no storage or cache dependency |
Required Zoho OAuth scopes:
Webhook-management scopes are not required: Zoho webhooks are registered manually in the dashboard.
Install the package in your Medusa application:
npm i medusa-payment-zohoRelease status: is published on npm. Install the latest release with the command above. The repository also contains the full test suite, source, and release verification so you can audit the exact tarball you install.
Using pnpm or yarn works too; the package ships ESM and CommonJS builds with TypeScript declarations.
Create or confirm your Zoho Payments account in the correct data centre: India () or US (). The account, OAuth client, and configured region must all match.
Create an OAuth client in the Zoho API console:
Generate a refresh token with the repository helper: . Configure the account ID, client ID, client secret, region, and redirect URI first, then run:
The helper performs the one-time authorization-code exchange and prints a long-lived refresh token. It uses Node.js built-ins only and reads credentials from the environment. Treat the output as a secret: do not paste it into issues, logs, or commits.
Collect the credentials from Zoho:
Store every secret in environment variables on the Medusa server. See for the variable names.
Only a Zoho Account Owner or Admin can generate an org-level refresh token. A user can hold at most 20 refresh tokens; generating more removes the oldest.
Register the provider under the Payment Module in . The default region is India and the default checkout mode is the embedded widget:
The provider identifier is and the configured id is , so Medusa stores the provider as . Keep that ID stable: it appears in payment sessions, the Admin, and the webhook URL. Use the subpath exactly; the package root is not a module-provider default.
For hosted checkout, set and provide both URLs:
All options, validation rules, and defaults are documented in Configuration.
Register this URL in Zoho Payments under Settings → Developer Space → Webhooks:
https://<your-medusa-backend>/hooks/payment/zoho_zohoEnable at least:
The provider verifies over the exact raw body and parses the event from those same verified bytes. It requires , checks when configured, and validates the payment amount/currency. A missing fails closed.
is emitted per failed attempt. The provider reconciles it against the Zoho session when Zoho supplies a session ID, so a later successful attempt in the same session still completes the order. Medusa 2.22's stock webhook subscriber ignores , , and actions; a terminal failure remains available for reconciliation rather than silently becoming paid. Refund events are acknowledged but map to ; reconcile refunds with or your own job.
Medusa's payment webhook route returns HTTP 200 before the provider finishes handling the event. A rejected signature therefore does not trigger a Zoho retry: monitor and reconcile. See Webhooks for the full setup, retry behaviour, and a signed test.
If Zoho does not appear, confirm the module resolves, the backend restarted without configuration errors, and the region is saved. See Troubleshooting.
The checkout flow is:
The widget result is a hint; is the authoritative step. If Zoho is still processing an asynchronous method, the order may be created with the payment pending until a signed webhook or status poll confirms capture.
The provider stores only publishable checkout data in the Medusa payment session: , , , , , , , , , and (widget mode) or (hosted mode). OAuth secrets and the signing key are never included.
Full guide, session-expiry handling, CSP, and server-side widget signature verification: Storefront widget.
Set and configure and . Then redirect the browser:
On the return URL, verify Zoho's hosted-checkout signature on your server, then call . A redirect is not proof of settlement: ACH can return , and the webhook is the backstop if the customer closes the browser. Success/failure route examples: Storefront hosted checkout.
| Option | Default | Required | Description |
|---|---|---|---|
| — | Yes | Zoho Payments account ID. | |
| — | Yes | OAuth ORG client ID. | |
| — | Yes | OAuth client secret. Server-only. | |
| — | Yes | Long-lived offline refresh token. Server-only. | |
| No | (India) or . Account and OAuth client must match. | ||
| No | or (India only). | ||
| No | or . | ||
| — | Widget mode | Publishable Zoho widget API key. Safe to expose to the browser. | |
| — | Strongly recommended | Developer Space signing key for webhooks, widget, and hosted-return signatures. | |
| — | Hosted mode | Hosted checkout success URL. HTTPS in production. | |
| — | Hosted mode | Hosted checkout failure URL. HTTPS in production. | |
| No | When set, reject webhooks whose differs. | ||
| No | Customer-facing description, max 500 characters. | ||
| No | Displayed in the embedded widget header when set. | ||
| No | Pass-through allow-list, for example . | ||
| No | Display-symbol overrides keyed by uppercase ISO code, e.g. . | ||
| No | Up to 3 extra non-sensitive Zoho metadata entries. | ||
| No | Per-request HTTP timeout, 1–120000 ms. | ||
| No | Retries for safe calls and OAuth, 0–10. | ||
| No | Exponential-backoff base, 0–60000 ms. | ||
| No | Authorization status polling attempts, 0–10. | ||
| No | Delay between authorization polls, 0–10000 ms. | ||
| No | Timestamp tolerance; disables the check. A positive value must cover Zoho's retry window. | ||
| Region default | No | Advanced override of the Zoho Payments API base URL. | |
| Region default | No | Advanced override of the OAuth accounts base URL. | |
| Region default | No | Advanced override of the hosted-checkout base URL. | |
| Global | No | Custom fetch implementation for proxies or instrumentation. | |
| Medusa logger | No | Structured logger with , , and . |
Environment variable names used in the examples:
These variables are read by your , not automatically by the provider. See Configuration for the full reference.
| Capability | Supported | Notes |
|---|---|---|
| Embedded Zoho Checkout Widget | Yes | Default mode; requires . |
| Zoho Hosted Checkout | Yes | Opt-in; requires and . |
| Payment session create/update | Yes | Reuses a session only when amount, currency, mode, and expiry match. |
| Authorization and capture | Yes | Zoho auto-captures; the provider verifies the captured payment. |
| Full and partial refunds | Yes | Against a captured payment; asynchronous Zoho status. |
| Signed webhooks | Yes | , , . |
| India and US data centres | Yes | One provider instance per Zoho account. |
| Zoho Sandbox | Yes | India-only; separate sandbox credentials and . |
| Multi-currency | Yes | Subject to your Zoho account's supported currencies. |
| Saved payment methods | No | Account-holder and saved-method APIs are not implemented in v1. |
| Manual or partial capture | No | Zoho does not offer it. |
| Cancel a pending payment | No | No Zoho API; the session expires (15 minutes by default). |
| Cancel a captured payment | No | Refund instead. |
| Refund webhook → Medusa | No | Acknowledged as ; reconcile with . |
| Payment links, payouts | No | Outside this provider's scope. |
| Automatic webhook creation | No | Register webhooks manually in Zoho. |
| Automatic reconciliation | No | Use the exported in your own job. |
| Multi-account routing | No | One provider instance maps to one Zoho account. |
| Built-in storefront UI | No | Use the documented helpers or hosted checkout. |
A successful Zoho payment is already captured. There is no separate capture step, no partial capture, and no cancel API. re-reads the payment and confirms the capture; it never creates a second charge. Refund settlement can be asynchronous; the provider records Zoho's refund status and logs pending/initiated refunds for reconciliation.
Report vulnerabilities privately via the process in SECURITY.md.
enforces 100% lines, branches, functions, and statements. The suite covers amount formatting, status mapping, all three signature schemes, OAuth caching and retries, HTTP retry policy, both checkout modes, webhook authorization, failed-attempt reconciliation, refunds, and browser-helper behaviour.
MIT © Jayashakthi Vishnu.
Zoho, Zoho Payments, and related marks are trademarks of Zoho Corporation. Medusa is a trademark of Medusa, Inc. This project is an independent community integration and is not affiliated with either company.
1ZohoPay.payments.CREATE2ZohoPay.payments.READ3ZohoPay.refunds.CREATE4ZohoPay.refunds.READnode scripts/get-refresh-token.mjs1import { defineConfig, loadEnv } from "@medusajs/framework/utils"2
3loadEnv(process.env.NODE_ENV || "development", process.cwd())4
5module.exports = defineConfig({6 projectConfig: {7 databaseUrl: process.env.DATABASE_URL,8 http: {9 storeCors: process.env.STORE_CORS!,10 adminCors: process.env.ADMIN_CORS!,11 authCors: process.env.AUTH_CORS!,12 jwtSecret: process.env.JWT_SECRET!,13 cookieSecret: process.env.COOKIE_SECRET!,14 },15 },16 modules: [17 {18 resolve: "@medusajs/medusa/payment",19 options: {20 providers: [21 {22 resolve: "medusa-payment-zoho/providers/zoho",23 id: "zoho",24 options: {25 accountId: process.env.ZOHO_PAYMENTS_ACCOUNT_ID!,26 clientId: process.env.ZOHO_PAYMENTS_CLIENT_ID!,27 clientSecret: process.env.ZOHO_PAYMENTS_CLIENT_SECRET!,28 refreshToken: process.env.ZOHO_PAYMENTS_REFRESH_TOKEN!,29 signingKey: process.env.ZOHO_PAYMENTS_SIGNING_KEY!,30
31 // India is the default; use "us" for the US data centre.32 region: "in",33
34 // "live" (default) or "sandbox". Sandbox is India-only.35 environment: "live",36
37 // "widget" (default) or "hosted".38 checkoutMode: "widget",39
40 // Publishable browser key, required for widget mode.41 apiKey: process.env.ZOHO_PAYMENTS_API_KEY!,42
43 // Reject webhooks whose live_mode does not match this value.44 // Or map ZOHO_PAYMENTS_LIVE_MODE for an explicit test/live switch.45 liveMode: process.env.NODE_ENV === "production",46 },47 },48 ],49 },50 },51 ],52})1checkoutMode: "hosted",2successUrl: process.env.ZOHO_PAYMENTS_SUCCESS_URL!,3failureUrl: process.env.ZOHO_PAYMENTS_FAILURE_URL!,4// apiKey is not required in hosted mode1"use client"2
3import { useState } from "react"4import Medusa from "@medusajs/js-sdk"5import { payWithZohoCheckout } from "medusa-payment-zoho/client"6
7const sdk = new Medusa({8 baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,9 publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!,10})11
12export function ZohoCheckoutButton({ cartId }: { cartId: string }) {13 const [message, setMessage] = useState<string>()14
15 async function handlePay() {16 setMessage("Preparing Zoho checkout…")17
18 const { cart } = await sdk.store.cart.retrieve(cartId)19 await sdk.store.payment.initiatePaymentSession(cart, {20 provider_id: "pp_zoho_zoho",21 })22
23 const { cart: freshCart } = await sdk.store.cart.retrieve(cartId)24 const session = freshCart.payment_collection?.payment_sessions?.find(25 (candidate) => candidate.provider_id === "pp_zoho_zoho"26 )27
28 if (!session) {29 setMessage("Zoho payment session is missing.")30 return31 }32
33 const result = await payWithZohoCheckout({ session: session.data })34
35 if (!result.ok) {36 setMessage(37 result.reason === "closed"38 ? "Payment cancelled."39 : (result.message ?? `Zoho payment failed (${result.reason}).`)40 )41 return42 }43
44 // The provider retrieves the payment from Zoho during completion.45 const completion = await sdk.store.cart.complete(freshCart.id)46
47 if (completion.type === "order") {48 window.location.assign(`/order/confirmed/${completion.order.id}`)49 return50 }51
52 setMessage(completion.error?.message ?? "Could not complete the order.")53 }54
55 return (56 <button onClick={handlePay} disabled={message === "Preparing Zoho checkout…"}>57 Pay with Zoho58 </button>59 )60}1"use client"2
3import Medusa from "@medusajs/js-sdk"4import { redirectToZohoHostedCheckout } from "medusa-payment-zoho/client"5
6const sdk = new Medusa({7 baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,8 publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!,9})10
11export async function startHostedCheckout(cartId: string) {12 const { cart } = await sdk.store.cart.retrieve(cartId)13 await sdk.store.payment.initiatePaymentSession(cart, {14 provider_id: "pp_zoho_zoho",15 })16
17 const { cart: freshCart } = await sdk.store.cart.retrieve(cartId)18 const session = freshCart.payment_collection?.payment_sessions?.find(19 (candidate) => candidate.provider_id === "pp_zoho_zoho"20 )21
22 if (!session) {23 throw new Error("Zoho payment session is missing")24 }25
26 redirectToZohoHostedCheckout(session.data)27}1ZOHO_PAYMENTS_ACCOUNT_ID=2ZOHO_PAYMENTS_CLIENT_ID=3ZOHO_PAYMENTS_CLIENT_SECRET=4ZOHO_PAYMENTS_REFRESH_TOKEN=5ZOHO_PAYMENTS_SIGNING_KEY=6ZOHO_PAYMENTS_API_KEY=7ZOHO_PAYMENTS_REGION=in8ZOHO_PAYMENTS_CHECKOUT_MODE=widget9ZOHO_PAYMENTS_SUCCESS_URL=10ZOHO_PAYMENTS_FAILURE_URL=11ZOHO_PAYMENTS_BUSINESS_NAME=12ZOHO_PAYMENTS_LIVE_MODE=false1pnpm install --frozen-lockfile2pnpm lint3pnpm typecheck4pnpm test:coverage5pnpm build6pnpm check