npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dipakparmar/zoho-billing

v0.1.1

Published

Zoho Billing (Subscriptions) API client for Node - a Stripe-style SDK

Readme

@dipakparmar/zoho-billing

A Stripe-style Node SDK for the Zoho Billing (formerly Zoho Subscriptions) v1 API.

Zero runtime dependencies. Uses global fetch, so it runs on Node 18+, Bun, Deno, and edge runtimes.

Built from Zoho's official OpenAPI documents, so paths, parameters, and event types match the published spec.

Install

The package is workspace-internal:

{ "dependencies": { "@dipakparmar/zoho-billing": "workspace:*" } }

Quick start

import { ZohoBilling } from "@dipakparmar/zoho-billing"

const zoho = new ZohoBilling({
  organizationId: process.env.ZOHO_ORGANIZATION_ID!,
  region: "us",
  credentials: {
    clientId: process.env.ZOHO_CLIENT_ID!,
    clientSecret: process.env.ZOHO_CLIENT_SECRET!,
    refreshToken: process.env.ZOHO_REFRESH_TOKEN!,
  },
})

const subscription = await zoho.subscriptions.create({
  customer_id: "903000000021976",
  plan: { plan_code: "pro-monthly", quantity: 1 },
  auto_collect: true,
})

Or from environment variables:

import { createZohoBillingFromEnv } from "@dipakparmar/zoho-billing"

const zoho = createZohoBillingFromEnv()

Reads ZOHO_ORGANIZATION_ID, ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, and optionally ZOHO_REGION.

Getting credentials

  1. Create a Self Client at api-console.zoho.com in the same data centre as your Zoho Billing org.
  2. Generate a grant token with scopes such as ZohoSubscriptions.subscriptions.ALL, ZohoSubscriptions.customers.ALL, ZohoSubscriptions.plans.READ, ZohoSubscriptions.invoices.ALL, ZohoSubscriptions.hostedpages.ALL.
  3. Exchange it for a refresh token against your region's accounts host (https://accounts.zoho.com/oauth/v2/token for US).
  4. Find your organization ID:
const [org] = await zoho.organizations.list()
console.log(org?.organization_id)

Regions

A token minted in one data centre is not valid in another, and each has its own hosts. Set region to match where the org actually lives, otherwise you get a confusing 401.

| Region | API host | Accounts host | | ------ | --------------------- | ----------------------- | | us | www.zohoapis.com | accounts.zoho.com | | eu | www.zohoapis.eu | accounts.zoho.eu | | in | www.zohoapis.in | accounts.zoho.in | | au | www.zohoapis.com.au | accounts.zoho.com.au | | jp | www.zohoapis.jp | accounts.zoho.jp | | uk | www.zohoapis.uk | accounts.zoho.uk | | ca | www.zohoapis.ca | accounts.zohocloud.ca | | sa | www.zohoapis.sa | accounts.zoho.sa | | cn | www.zohoapis.com.cn | accounts.zoho.com.cn |

Resources

| Namespace | Covers | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | zoho.subscriptions | All 32 subscription endpoints: create, update, cancel, reactivate, pause, resume, postpone, extend, coupons, cards, charges, addons, notes, scheduled changes, metered billing | | zoho.customers | Customers, contact persons, cards, transactions, reference lookup | | zoho.plans / zoho.addons / zoho.coupons / zoho.products / zoho.pricebooks | Catalogue | | zoho.invoices | Retrieve, list, collect, email, void, write off, usage line items | | zoho.payments / zoho.creditNotes / zoho.refunds | Money movement | | zoho.hostedPages | Zoho-hosted checkout (the Stripe Checkout analogue) | | zoho.events | The webhook events feed | | zoho.organizations | Org discovery |

Anything not wrapped is reachable through the underlying client:

const result = await zoho.client.request<{ taxes: unknown[] }>(
  "/settings/taxes"
)

Subscription lifecycle

// Cancel at the end of the paid term (default) - the subscription becomes
// `non_renewing` and the customer keeps access until `current_term_ends_at`.
await zoho.subscriptions.cancel(id)

// Cancel immediately.
await zoho.subscriptions.cancel(id, { cancel_at_end: false })

await zoho.subscriptions.reactivate(id)
await zoho.subscriptions.pause(id, { resume_date: "2026-09-01" })

// Upgrade. Set end_of_term explicitly: false applies now and prorates, true
// defers to renewal. Omitting it falls back to the org's default setting.
await zoho.subscriptions.update(id, {
  plan: { plan_code: "enterprise-monthly" },
  end_of_term: false,
  prorate: true,
})

Pagination

Zoho pages at 200 records max. Three ways to consume a list:

// One page.
const { subscriptions, response } = await zoho.subscriptions.list({
  per_page: 50,
})
response.page_context?.has_more_page

// Every page, buffered.
const all = await zoho.subscriptions.listAll({
  filter_by: "SubscriptionStatus.ACTIVE",
})

// Streamed, for large accounts.
for await (const subscription of zoho.subscriptions.iterate()) {
  console.log(subscription.subscription_id)
}

Errors

Zoho signals failure two ways: a non-2xx status, and a non-zero code in an otherwise-200 body. Both are normalised into one hierarchy.

import {
  ZohoBillingAuthError,
  ZohoBillingNotFoundError,
  ZohoBillingRateLimitError,
} from "@dipakparmar/zoho-billing/errors"

try {
  await zoho.subscriptions.retrieve(id)
} catch (error) {
  if (error instanceof ZohoBillingNotFoundError) return null
  if (error instanceof ZohoBillingRateLimitError) {
    // Already retried internally; this means retries were exhausted.
  }
  throw error
}

ZohoBillingError exposes code (Zoho's numeric code), status, body, and request.

Retries

Rate limits (429), 5xx, and connection failures are retried with exponential backoff and full jitter, honouring Retry-After. Jitter matters because Zoho's limit of 100 requests/minute is per organization: without it, parallel workers throttled together retry in lockstep and re-trip the limit.

A 401 additionally triggers one forced token refresh and retry, covering tokens revoked out from under a warm cache.

Token handling

Access tokens last one hour and are cached in memory, refreshed two minutes before expiry. Concurrent refreshes collapse into a single request.

On serverless, every cold start re-refreshes. Supply a shared store:

const zoho = new ZohoBilling({
  // ...
  tokenStore: {
    async get(key) {
      const raw = await redis.get(key)
      return raw ? JSON.parse(raw) : null
    },
    async set(key, token) {
      await redis.set(key, JSON.stringify(token), { EX: 3600 })
    },
    async delete(key) {
      await redis.del(key)
    },
  },
})

Webhooks

Zoho does not sign webhook payloads. Unlike Stripe's Stripe-Signature HMAC, Zoho only lets you attach Basic auth, custom headers, or query parameters to the webhook URL. Authenticity therefore rests on a shared secret, which proves the caller knows the secret but not that the body is unmodified.

Consequences worth designing around:

  • Serve the endpoint over HTTPS only; the secret travels in clear text.
  • Treat the payload as a notification, not as truth. For anything that grants access or moves money, re-fetch the resource from the API using the ID in the payload.
  • Webhook delivery is best-effort with no replay. Poll zoho.events.list() on a schedule to backfill anything missed while your endpoint was down.
import {
  constructEvent,
  getEventResource,
} from "@dipakparmar/zoho-billing/webhooks"

export async function POST(request: Request) {
  const body = await request.text()

  let event
  try {
    event = constructEvent({
      payload: body,
      providedSecret: request.headers.get("x-zoho-webhook-secret"),
      secret: process.env.ZOHO_WEBHOOK_SECRET!,
    })
  } catch {
    return new Response("unauthorized", { status: 401 })
  }

  switch (event.event_type) {
    case "subscription_cancelled": {
      const id = getEventResource(event, "subscription")?.subscription_id
      // Re-fetch rather than trusting the payload.
      const subscription = await zoho.subscriptions.retrieve(id!)
      await revokeAccess(subscription)
      break
    }
    case "payment_declined":
      await notifyBillingFailure(event)
      break
  }

  return new Response("ok")
}

All 39 documented event types are typed as ZohoEventType.

Backend handler for the React package

@dipakparmar/zoho-billing-react calls your app, never Zoho directly. Mount the handler it expects:

// app/api/billing/[...route]/route.ts
import { createZohoBillingHandler } from "@dipakparmar/zoho-billing/handler"

const handler = createZohoBillingHandler({
  zoho,
  basePath: "/api/billing",
  authorize: async (request) => {
    const session = await auth.api.getSession({ headers: request.headers })
    if (!session?.user.zohoCustomerId) return null
    return { customerId: session.user.zohoCustomerId }
  },
  // Restrict self-serve checkout to what you actually sell.
  allowedPlanCodes: ["starter-monthly", "pro-monthly", "pro-yearly"],
  allowedAddonCodes: ["extra-seats"],
  allowedRedirectOrigins: ["https://app.example.com"],
})

export { handler as GET, handler as POST, handler as PUT }

Security model

Everything past authorize is treated as hostile input.

Tenant isolation. Lists are forced to the session's customer_id; one in the query string is ignored. Single reads and every mutation verify ownership before calling Zoho, so a mismatched caller cannot cancel someone else's subscription. A mismatch returns 404, not 403, so it does not confirm the ID exists.

No body is forwarded to Zoho. This is the part that matters most, and it is specific to Zoho: much of the subscription request body is pricing input, not just resource selection. Per Zoho's own spec, plan.price "will be changed to the given value" if supplied; plan.setup_fee, plan.trial_days, exclude_setup_fee, exclude_trial, and billing_cycles override the catalogue; addons[].price overrides addon pricing; exchange_rate scales the charged amount; and customer_id on update reassigns the subscription to a different customer.

Forwarding a client body therefore lets a caller set their own price, grant themselves an unlimited trial, or move a subscription between accounts. So bodies go through the deny-by-default allowlists in @dipakparmar/zoho-billing/security: only plan_code, addon_code, quantity, coupon_code, and end_of_term survive, and everything that decides what it costs is re-derived from your Zoho catalogue. Fields Zoho adds in future are dropped automatically rather than silently becoming new injection points.

Redirect validation. redirect_url is where Zoho sends the customer after checkout, so an unchecked value is an open redirect wearing your brand. Only absolute https: URLs on an allowed origin pass (http: on loopback for local dev). Defaults to the request's own origin; set allowedRedirectOrigins if checkout returns elsewhere.

Other measures. quantity must be a positive integer under MAX_QUANTITY (a negative one produces a credit). Plan and addon codes can be restricted with allowedPlanCodes / allowedAddonCodes. Responses are Cache-Control: no-store. Zoho's own error prose is hidden by default (exposeUpstreamErrors) since its messages are written for the account holder.

You can use these directly if you build your own endpoint:

import {
  sanitizeSubscriptionUpdate,
  assertCatalogAllowed,
  resolveRedirectUrl,
  isOwnedByCustomer,
} from "@dipakparmar/zoho-billing/security"

Hosted pages (checkout)

const page = await zoho.hostedPages.createSubscriptionPage({
  customer_id: customerId,
  plan: { plan_code: "pro-monthly" },
  redirect_url: "https://app.example.com/billing/return",
})
// Redirect the customer to page.url, then on return:
const result = await zoho.hostedPages.retrieve(hostedPageId)
if (result.status === "success") {
  /* grant access */
}

Always verify status server-side. Landing on the return URL proves nothing; anyone can visit it directly.

API reference

Generated from source by TypeDoc into docs/:

| Module | Covers | | ------------------------------ | --------------------------------------------------------- | | index | ZohoBilling, resource namespaces, client, token manager | | security | Input sanitisation for the browser trust boundary | | handler | createZohoBillingHandler | | webhooks | Event parsing and verification | | errors | Error hierarchy | | types | Every Zoho resource type |

bun run docs        # regenerate
bun run docs:check  # fail if the committed output is stale

Regenerate whenever you change a public signature or a doc comment. The output is committed, so docs:check catches drift in review - generated docs that have silently diverged from the source are worse than none, because they get trusted.

This reference is the what. The narrative sections above are the why, and are hand-written: TypeDoc can tell you sanitizeSubscriptionUpdate takes an unknown, not why it drops customer_id.

Testing

Inject a stub transport:

const zoho = new ZohoBilling({
  organizationId: "org-1",
  credentials: { accessToken: "test" },
  apiBaseUrl: "https://api.test",
  fetch: myStubFetch,
})
bun test

Known gaps

  • Modules outside core billing (projects, tasks, time entries, expenses, custom modules, quotes, items) are not wrapped. Reach them via zoho.client.request().
  • Bulk operations (/subscriptions/bulkcancel, /subscriptions/compute) are not wrapped.
  • ZohoRegion includes uk; confirm availability for your account before using it.