Integrate Bolt.new with Zoho Books by creating a Zoho API client in the Zoho Developer Console, implementing OAuth 2.0 (requires deployment for the callback URL), then calling Zoho Books' REST API from Next.js API routes. Zoho Books has a free plan for businesses under $50K annual revenue and integrates natively with 45+ other Zoho apps. Store credentials in .env, never in client-side code.
Building Accounting Features with Zoho Books' REST API
Zoho Books occupies a unique position in the accounting software landscape: it is part of a 45+ application ecosystem (Zoho One) that spans CRM, email, HR, project management, inventory, and more. For businesses already using Zoho CRM, Zoho Books is the natural accounting companion because data flows automatically between the two systems — CRM contacts become accounting contacts, CRM deals become Books invoices, and payment status syncs back to the CRM. If your Bolt app serves businesses in the Zoho ecosystem, Zoho Books integration unlocks this full data pipeline.
Zoho Books also has a genuinely free tier: businesses with annual revenue under $50,000 USD can use the core accounting features at no cost. This is unique among major accounting platforms and makes Zoho Books the accessible starting point for very small business tools without requiring your users to pay for accounting software. The free tier includes the full API, so you can integrate without your users needing a paid Zoho subscription.
The Zoho Books REST API is comprehensive and follows standard REST conventions — resource-based URLs with HTTP verbs, JSON bodies, and clear error codes. Authentication uses OAuth 2.0, consistent with the rest of the Zoho platform, so if you are already integrating with Zoho CRM or Zoho Projects, the same OAuth client works for Zoho Books. The main Zoho-specific considerations are the organization ID (similar to Xero's tenant ID) and Zoho's multi-region data centers — API endpoints differ between US, EU, IN, AU, and JP regions.
Integration method
Bolt generates the Zoho Books integration code — OAuth 2.0 authorization routes, API handlers for invoices and contacts, and dashboard components — through conversation with the AI. Zoho Books uses OAuth 2.0, requiring users to authorize your app through Zoho's consent screen. The OAuth callback needs a public HTTPS URL, so you must deploy before testing the full auth flow. All API calls go through server-side Next.js routes to protect credentials.
Prerequisites
- A Zoho account (free at zoho.com — no credit card required for the free Zoho Books tier)
- A Zoho API client created in the Zoho Developer Console (accounts.zoho.com/developerconsole) with OAuth 2.0 credentials
- Your Zoho Books organization ID (visible in Zoho Books Settings → Organization Profile, or from the API after auth)
- A deployed Bolt.new app on Netlify or Bolt Cloud (required for the OAuth redirect URI)
- A Next.js project in Bolt — Zoho Books has no official Node.js SDK so you will use the REST API directly
Step-by-step guide
Create a Zoho API client and obtain OAuth 2.0 credentials
Create a Zoho API client and obtain OAuth 2.0 credentials
Zoho uses a centralized developer console for all Zoho APIs — the same client credentials work for Zoho CRM, Zoho Books, Zoho Mail, and all other Zoho products. Go to accounts.zoho.com/developerconsole (you must be logged into your Zoho account). Click Add Client and select Web Based as the client type. Fill in the form: client name (shown to users during authorization), homepage URL (your app's URL), and authorized redirect URIs. Add your deployed URL (e.g., https://your-app.netlify.app/api/zoho/callback) and http://localhost:3000/api/zoho/callback for local testing. Click Create. After creation, you see the Client ID and Client Secret. The Client Secret must only be used server-side. Also note the Zoho region for your account — Zoho has separate data centers for US (accounts.zoho.com), EU (accounts.zoho.eu), India (accounts.zoho.in), Australia (accounts.zoho.com.au), and Japan (accounts.zoho.jp). Your OAuth endpoints and API URLs must match the region where your Zoho account is hosted. For Zoho Books API calls, the base URL is region-specific: https://books.zoho.com/api/v3 for US, https://books.zoho.eu/api/v3 for EU, and so on. Use the same region as your Zoho account. Store the region in your .env file as ZOHO_REGION so you can construct the correct API URLs. To find your Zoho Books organization ID: after completing OAuth and connecting your Zoho Books account, call the /organizations endpoint to list all organizations associated with the token. The organization ID is required for every Zoho Books API call as a query parameter.
Set up Zoho Books OAuth 2.0 in my Next.js app. Create lib/zoho-auth.ts exporting a function to generate the Zoho auth URL using ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REDIRECT_URI, and ZOHO_REGION from .env. The auth URL should request scopes: ZohoBooks.invoices.READ, ZohoBooks.invoices.CREATE, ZohoBooks.contacts.READ, ZohoBooks.expenses.READ, ZohoBooks.expenses.CREATE. Create /api/zoho/authorize that redirects to the auth URL.
Paste this in Bolt.new chat
1// .env.local2ZOHO_CLIENT_ID=your_zoho_client_id3ZOHO_CLIENT_SECRET=your_zoho_client_secret4ZOHO_REDIRECT_URI=https://your-app.netlify.app/api/zoho/callback5ZOHO_REGION=com # com, eu, in, com.au, jp6ZOHO_BOOKS_ORG_ID=your_organization_id # Set after first OAuth78// lib/zoho-auth.ts9export function getZohoRegion(): string {10 return process.env.ZOHO_REGION ?? 'com';11}1213export function getZohoAuthBaseUrl(): string {14 return `https://accounts.zoho.${getZohoRegion()}`;15}1617export function getZohoBooksBaseUrl(): string {18 return `https://books.zoho.${getZohoRegion()}/api/v3`;19}2021export function buildAuthUrl(state: string): string {22 const baseUrl = getZohoAuthBaseUrl();23 const params = new URLSearchParams({24 response_type: 'code',25 client_id: process.env.ZOHO_CLIENT_ID!,26 scope: [27 'ZohoBooks.invoices.READ',28 'ZohoBooks.invoices.CREATE',29 'ZohoBooks.contacts.READ',30 'ZohoBooks.contacts.CREATE',31 'ZohoBooks.expenses.READ',32 'ZohoBooks.expenses.CREATE',33 ].join(','),34 redirect_uri: process.env.ZOHO_REDIRECT_URI!,35 access_type: 'offline',36 state,37 });38 return `${baseUrl}/oauth/v2/auth?${params.toString()}`;39}4041// app/api/zoho/authorize/route.ts42import { NextResponse } from 'next/server';43import { buildAuthUrl } from '@/lib/zoho-auth';4445export async function GET() {46 const state = Math.random().toString(36).substring(2, 15);47 const authUrl = buildAuthUrl(state);48 const response = NextResponse.redirect(authUrl);49 response.cookies.set('zoho_oauth_state', state, { httpOnly: true, maxAge: 600 });50 return response;51}Pro tip: Zoho uses scope names in the format ZohoBooks.{resource}.{permission} — for example ZohoBooks.invoices.READ and ZohoBooks.invoices.CREATE are separate scopes. Request the minimum scopes your app needs. The full scope list is at zoho.com/books/api/v3/introduction/scopes.
Expected result: Visiting /api/zoho/authorize redirects to Zoho's login and consent screen. After logging in, users see your app name and the requested Zoho Books permissions before clicking Allow.
Handle the OAuth callback and exchange the code for tokens
Handle the OAuth callback and exchange the code for tokens
After the user authorizes your app, Zoho redirects to your callback URL with an authorization code. Your callback route must exchange this code for access and refresh tokens by calling Zoho's token endpoint. Zoho's token endpoint is at https://accounts.zoho.{region}/oauth/v2/token. Send a POST request with the authorization code, client credentials, redirect URI, and grant_type: 'authorization_code'. Zoho returns an access_token (expires in 1 hour), a refresh_token (long-lived), and the token_type and expires_in fields. After obtaining tokens, call the Zoho Books organizations endpoint to retrieve the organization ID. Every Zoho Books API call requires the organization_id as a query parameter — there is no default. If the user has multiple Zoho Books organizations (common for accountants), list them and either pick the first or implement an organization selector. Store the access token, refresh token, token expiry, and organization ID in server-side cookies or a database. The access token expires in 3600 seconds (1 hour). When making API calls, check if the token is within 5 minutes of expiry and refresh it using the refresh_token grant type before proceeding. Critical: Zoho OAuth callbacks require a deployed URL. The WebContainer cannot receive incoming redirects from Zoho's servers. Deploy to Netlify or Bolt Cloud first, register the deployed URL in the Zoho Developer Console, and test auth on the deployed app.
Create the Zoho OAuth callback at /api/zoho/callback. Exchange the code for tokens by POST to the Zoho token endpoint with client credentials. After getting tokens, call the Zoho Books /organizations endpoint with the access token to get the organization ID. Store access_token, refresh_token, expiry timestamp (Date.now() + expires_in * 1000), and organization_id in HTTP-only cookies. Redirect to /dashboard on success. Create lib/zoho-api.ts with a zohoBooksCall helper that reads tokens from cookies, refreshes if expired, and calls the Zoho Books API.
Paste this in Bolt.new chat
1// app/api/zoho/callback/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { getZohoAuthBaseUrl, getZohoBooksBaseUrl } from '@/lib/zoho-auth';45export async function GET(request: NextRequest) {6 const { searchParams } = new URL(request.url);7 const code = searchParams.get('code');8 const error = searchParams.get('error');910 if (error || !code) {11 return NextResponse.redirect(new URL(`/error?message=${error ?? 'no_code'}`, request.url));12 }1314 try {15 // Exchange code for tokens16 const tokenResponse = await fetch(`${getZohoAuthBaseUrl()}/oauth/v2/token`, {17 method: 'POST',18 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },19 body: new URLSearchParams({20 code,21 client_id: process.env.ZOHO_CLIENT_ID!,22 client_secret: process.env.ZOHO_CLIENT_SECRET!,23 redirect_uri: process.env.ZOHO_REDIRECT_URI!,24 grant_type: 'authorization_code',25 }).toString(),26 });2728 const tokens = await tokenResponse.json() as {29 access_token: string;30 refresh_token: string;31 expires_in: number;32 token_type: string;33 };3435 if (!tokens.access_token) {36 throw new Error('No access token in response');37 }3839 // Fetch organization ID40 const orgsResponse = await fetch(`${getZohoBooksBaseUrl()}/organizations`, {41 headers: { Authorization: `Zoho-oauthtoken ${tokens.access_token}` },42 });43 const orgsData = await orgsResponse.json() as { organizations: Array<{ organization_id: string; name: string }> };44 const orgId = orgsData.organizations[0]?.organization_id;4546 if (!orgId) throw new Error('No Zoho Books organization found');4748 const response = NextResponse.redirect(new URL('/dashboard', request.url));49 const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 60 * 60 * 24 * 30, path: '/' };50 response.cookies.set('zoho_access_token', tokens.access_token, { ...cookieOpts, maxAge: tokens.expires_in });51 response.cookies.set('zoho_refresh_token', tokens.refresh_token, cookieOpts);52 response.cookies.set('zoho_token_expiry', String(Date.now() + tokens.expires_in * 1000), cookieOpts);53 response.cookies.set('zoho_org_id', orgId, cookieOpts);5455 return response;56 } catch (err) {57 console.error('Zoho OAuth error:', err);58 return NextResponse.redirect(new URL('/error?message=zoho_auth_failed', request.url));59 }60}Pro tip: Zoho refresh tokens do not expire as long as you use them at least once every 3 months. Unlike some other platforms, Zoho does not issue a new refresh token when you use it to get a new access token — the same refresh token remains valid indefinitely with regular use.
Expected result: After authorizing on Zoho's consent screen, you are redirected to /dashboard with zoho_access_token, zoho_refresh_token, zoho_token_expiry, and zoho_org_id cookies set. The organization ID is stored and ready for API calls.
Fetch invoices and contacts from the Zoho Books API
Fetch invoices and contacts from the Zoho Books API
Zoho Books REST API follows standard REST conventions with clear resource paths. The base URL is https://books.zoho.{region}/api/v3. Every request requires the organization_id as a query parameter and the Authorization header as Zoho-oauthtoken {access_token}. Create a reusable API helper that handles authentication (reading tokens from cookies, refreshing if expired) and constructs the correct URL with organization_id automatically appended. This reduces boilerplate in your individual API routes. For invoices, GET /invoices returns a paginated list with filtering support. Useful filter parameters: status (draft, sent, overdue, paid, void), customer_id, date_start, date_end, and sort_column (date, invoice_number, total). The response includes invoice_id, invoice_number, customer_name, date, due_date, total, balance (outstanding amount), and status. For contacts (customers and suppliers), GET /contacts returns the full contact list. Filter by contact_type=customer for invoice customers only. Contact records include contact_id, contact_name, email, phone, billing_address, and outstanding_receivable_amount (the total unpaid balance across all invoices for that customer). Zoho Books API rate limits: 100 requests per minute for paid plans, 60 requests per minute for the free plan. Use caching for data that does not change frequently, and avoid making API calls on every component render.
Create a lib/zoho-api.ts utility with a zohoBooksCall function that reads zoho_access_token, zoho_token_expiry, zoho_refresh_token, and zoho_org_id from cookies, refreshes the token if within 5 minutes of expiry, and calls the Zoho Books API with the correct Authorization header and organization_id query parameter. Then create /api/zoho-books/invoices route that calls GET /invoices with status=overdue,sent as the default filter and returns normalized invoice objects with id, invoiceNumber, customerName, date, dueDate, total, balance, status, and daysOverdue.
Paste this in Bolt.new chat
1// lib/zoho-api.ts2import { cookies } from 'next/headers';3import { getZohoAuthBaseUrl, getZohoBooksBaseUrl } from './zoho-auth';45async function refreshAccessToken(refreshToken: string): Promise<{ access_token: string; expires_in: number }> {6 const response = await fetch(`${getZohoAuthBaseUrl()}/oauth/v2/token`, {7 method: 'POST',8 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },9 body: new URLSearchParams({10 refresh_token: refreshToken,11 client_id: process.env.ZOHO_CLIENT_ID!,12 client_secret: process.env.ZOHO_CLIENT_SECRET!,13 grant_type: 'refresh_token',14 }).toString(),15 });16 return response.json();17}1819export async function zohoBooksCall<T>(20 path: string,21 options: RequestInit = {}22): Promise<T> {23 const cookieStore = cookies();24 let accessToken = cookieStore.get('zoho_access_token')?.value;25 const refreshToken = cookieStore.get('zoho_refresh_token')?.value;26 const tokenExpiry = parseInt(cookieStore.get('zoho_token_expiry')?.value ?? '0', 10);27 const orgId = cookieStore.get('zoho_org_id')?.value;2829 if (!accessToken || !orgId) throw new Error('Not authenticated with Zoho Books');3031 // Refresh if within 5 minutes of expiry32 if (refreshToken && tokenExpiry - Date.now() < 5 * 60 * 1000) {33 const fresh = await refreshAccessToken(refreshToken);34 accessToken = fresh.access_token;35 }3637 const url = new URL(`${getZohoBooksBaseUrl()}${path}`);38 url.searchParams.set('organization_id', orgId);3940 const response = await fetch(url.toString(), {41 ...options,42 headers: {43 Authorization: `Zoho-oauthtoken ${accessToken}`,44 'Content-Type': 'application/json',45 ...(options.headers ?? {}),46 },47 });4849 if (!response.ok) {50 throw new Error(`Zoho Books API error: ${response.status}`);51 }5253 return response.json() as Promise<T>;54}5556// app/api/zoho-books/invoices/route.ts57import { NextRequest, NextResponse } from 'next/server';58import { zohoBooksCall } from '@/lib/zoho-api';5960interface ZohoInvoice {61 invoice_id: string;62 invoice_number: string;63 customer_name: string;64 date: string;65 due_date: string;66 total: number;67 balance: number;68 status: string;69}7071export async function GET(request: NextRequest) {72 const { searchParams } = new URL(request.url);73 const status = searchParams.get('status') ?? 'sent,overdue';7475 try {76 const data = await zohoBooksCall<{ invoices: ZohoInvoice[]; page_context: Record<string, unknown> }>(77 `/invoices?status=${encodeURIComponent(status)}&sort_column=date&sort_order=D&per_page=200`78 );7980 const today = new Date();81 const invoices = data.invoices.map((inv) => {82 const dueDate = inv.due_date ? new Date(inv.due_date) : null;83 const daysOverdue = dueDate && dueDate < today && inv.balance > 084 ? Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))85 : 0;86 return {87 id: inv.invoice_id,88 invoiceNumber: inv.invoice_number,89 customerName: inv.customer_name,90 date: inv.date,91 dueDate: inv.due_date,92 total: inv.total,93 balance: inv.balance,94 status: inv.status,95 daysOverdue,96 };97 });9899 return NextResponse.json({ invoices, total: invoices.length });100 } catch (error) {101 const message = error instanceof Error ? error.message : 'Failed to fetch invoices';102 return NextResponse.json({ error: message }, { status: 500 });103 }104}Pro tip: Zoho Books API pagination uses page and per_page parameters (max 200 per page). For a dashboard showing recent invoices, per_page=200 is usually sufficient. For comprehensive reports across all historical invoices, implement pagination by checking the page_context.has_more_page field and incrementing the page number.
Expected result: Calling /api/zoho-books/invoices returns unpaid invoices from your Zoho Books account with balance amounts, customer names, due dates, and days overdue calculated.
Create invoices and manage expenses via the API
Create invoices and manage expenses via the API
Zoho Books API supports creating and updating invoices programmatically. The POST /invoices endpoint accepts the complete invoice data: customer_id, invoice_number (optional, auto-assigned if omitted), date, due_date, line_items (description, quantity, rate, account_id), notes, terms, and more. To create an invoice for a customer, you need their customer_id. Fetch the customer list from GET /contacts?contact_type=customer and either match by email address or let users select from a dropdown. The customer_id is a string UUID assigned by Zoho Books. Each line item requires at minimum: description, rate (unit price), and quantity. The account_id determines which chart of accounts category the revenue posts to — fetch your available income accounts from GET /chartofaccounts?account_type=income to get valid IDs. If you omit account_id, Zoho uses the default income account. For expenses, POST /expenses with vendor_id (or just the vendor_name for non-recurring expenses), date, amount, account_id (the expense account category), and optionally a description and reference_number. Expense accounts can be fetched from GET /chartofaccounts?account_type=expense. Both invoice creation and expense creation work as outbound HTTP calls from your Next.js API routes, so they function correctly in Bolt's WebContainer preview for testing. The exception is PDF invoice delivery — sending an invoice email to a customer should be tested with a test email address in development to avoid sending premature invoices to real customers.
Create a /api/zoho-books/invoices/create route that accepts POST with customerId, lineItems (array of description, quantity, rate), dueDate, and optional notes. Call Zoho Books POST /invoices with the invoice data. Return the created invoice_id, invoice_number, total, and a link to view the invoice in Zoho Books. Also create /api/zoho-books/expenses/create that accepts POST with vendor_name, amount, expenseAccountId, date, and description. Call Zoho Books POST /expenses and return the created expense_id.
Paste this in Bolt.new chat
1// app/api/zoho-books/invoices/create/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { zohoBooksCall } from '@/lib/zoho-api';45export async function POST(request: NextRequest) {6 const body = await request.json() as {7 customerId: string;8 lineItems: Array<{ description: string; quantity: number; rate: number }>;9 dueDate: string;10 notes?: string;11 sendEmail?: boolean;12 };1314 try {15 const invoicePayload = {16 customer_id: body.customerId,17 date: new Date().toISOString().split('T')[0],18 due_date: body.dueDate,19 notes: body.notes ?? '',20 line_items: body.lineItems.map((item) => ({21 description: item.description,22 quantity: item.quantity,23 rate: item.rate,24 })),25 };2627 const data = await zohoBooksCall<{28 invoice: {29 invoice_id: string;30 invoice_number: string;31 total: number;32 status: string;33 };34 }>('/invoices', {35 method: 'POST',36 body: JSON.stringify(invoicePayload),37 });3839 const inv = data.invoice;4041 // Optionally send the invoice email42 if (body.sendEmail) {43 await zohoBooksCall(`/invoices/${inv.invoice_id}/email`, { method: 'POST' });44 }4546 return NextResponse.json({47 invoiceId: inv.invoice_id,48 invoiceNumber: inv.invoice_number,49 total: inv.total,50 status: inv.status,51 viewUrl: `https://books.zoho.${process.env.ZOHO_REGION ?? 'com'}/app#/invoices/${inv.invoice_id}`,52 });53 } catch (error) {54 const message = error instanceof Error ? error.message : 'Invoice creation failed';55 return NextResponse.json({ error: message }, { status: 500 });56 }57}Pro tip: Zoho Books uses ISO date format (YYYY-MM-DD) for all dates. Always format dates as strings in this format before sending to the API. JavaScript's new Date().toISOString().split('T')[0] gives you today's date in the correct format.
Expected result: POST /api/zoho-books/invoices/create creates a real invoice in Zoho Books and returns the invoice ID, number, and total. The invoice appears in the Zoho Books interface and can be sent to the customer.
Common use cases
Invoice management dashboard with Zoho CRM customer sync
Build a combined dashboard that shows open invoices from Zoho Books alongside the customer records from Zoho CRM. Sales reps see invoice status for their deals without needing to switch between two Zoho apps. Clicking an invoice links to the full Zoho Books record.
Create a Next.js app integrating Zoho Books and optionally Zoho CRM. Build an invoice dashboard that fetches all unpaid invoices from the Zoho Books API with customer name, amount, due date, and status. Display them as cards sorted by due date. For each customer, fetch their email and phone from Zoho Books contacts. Add a Create Invoice button that opens a form to create a new invoice with customer selection from the contacts list.
Copy this prompt to try it in Bolt.new
Expense tracking and approval workflow
Create an expense submission tool where team members submit expense reports, which are automatically created as expenses in Zoho Books pending approval. Managers see a pending expenses list and can approve or reject, updating the status in Zoho Books via the API.
Build an expense tracking app connected to Zoho Books. Create an expense submission form with vendor name, amount, category, date, and receipt upload. When submitted, create an expense in Zoho Books using the expenses API endpoint. Build a manager view showing pending expenses fetched from Zoho Books. Add approve and reject actions that update the expense status. Use ZOHO_BOOKS_ORG_ID and ZOHO_CLIENT credentials from .env.
Copy this prompt to try it in Bolt.new
Revenue and cashflow monitoring widget
Embed a financial summary widget in an internal operations dashboard showing total invoiced this month, total collected, outstanding receivables, and a 6-month revenue trend chart — all from Zoho Books data. Finance teams see the financial picture without a separate Zoho login.
Build a ZohoBooksFinancialWidget React component that calls a /api/zoho-books/summary route. The summary route should fetch invoices from Zoho Books for the last 6 months and calculate: total invoiced per month, total collected per month, current outstanding balance, and overdue amount. Display monthly invoiced vs collected as a bar chart using Recharts. Show summary cards for outstanding and overdue amounts.
Copy this prompt to try it in Bolt.new
Troubleshooting
Zoho OAuth redirect shows 'Invalid client_id' error on Zoho's consent screen
Cause: The ZOHO_CLIENT_ID in your .env does not match the Client ID from the Zoho Developer Console, or the OAuth client was created in a different Zoho region than the one your account uses.
Solution: Log into accounts.zoho.{your-region}/developerconsole and verify the exact Client ID. Ensure your ZOHO_REGION environment variable matches the region where both your Zoho account and developer console are hosted. US accounts use zoho.com; European accounts use zoho.eu.
API calls return { code: 14, message: 'Invalid value passed for organizationid' }
Cause: The organization_id is missing from the API call query parameters, is incorrect, or does not belong to the authenticated user's account.
Solution: Verify the zoho_org_id cookie is set correctly after OAuth. Test by calling /api/zoho-books/organizations to list all organizations the token has access to. Log the organization_id being passed to confirm it is the correct format (a string of digits, not a UUID).
1// Fetch all organizations to verify:2const orgs = await zohoBooksCall('/organizations');3console.log('Available orgs:', JSON.stringify(orgs));Token refresh fails with { error: 'invalid_code', error_description: 'Invalid refresh token' }
Cause: The refresh token was stored incorrectly, the Zoho API client was deleted or regenerated in the developer console, or the refresh token was revoked by the user in Zoho's Connected Apps settings.
Solution: Clear the stored cookies and prompt the user to re-authorize through the OAuth flow. Verify the Client ID and Client Secret are still valid in the Zoho Developer Console — regenerating credentials invalidates all existing tokens. Implement a fallback redirect to /api/zoho/authorize when a token refresh fails.
1// In zohoBooksCall, handle refresh failures:2try {3 const fresh = await refreshAccessToken(refreshToken);4 accessToken = fresh.access_token;5} catch {6 throw new Error('ZOHO_TOKEN_EXPIRED'); // Caller redirects to re-auth7}Zoho Books API returns status 400 with 'Required field is missing' on invoice creation
Cause: A required field is missing in the invoice POST body. Zoho Books requires at minimum: customer_id, date, and at least one line_item with description and rate.
Solution: Check that customerId maps to a valid Zoho Books customer_id (not a CRM contact ID). Ensure every line item has at least description, quantity, and rate fields. Log the exact request body being sent to confirm all required fields are present.
1// Minimum valid invoice payload:2{3 customer_id: '123456789', // Must be a valid Zoho Books customer ID4 date: '2026-04-22',5 line_items: [{6 description: 'Consulting services',7 quantity: 1,8 rate: 150.009 }]10}Best practices
- Always deploy before testing OAuth — Zoho's redirect URI enforcement is strict and the WebContainer cannot receive incoming redirects from Zoho's authorization servers.
- Use the region-appropriate API URLs and always store ZOHO_REGION in your .env. Using the wrong regional endpoint returns authentication errors even with a valid token.
- Request only the scopes your app needs — Zoho's consent screen is detailed and shows users each permission. Fewer scopes improve trust and conversion.
- Cache invoice and contact data that does not change frequently. Zoho Books free plan has a 60 requests/minute limit — a page that makes 5+ API calls on load can exhaust this quickly.
- Always include organization_id in every API call. The zohoBooksCall helper automates this, but if you make direct fetch calls, a missing org ID causes silent failures rather than auth errors.
- Handle the multi-organization case gracefully — accountants with Zoho Books access to multiple organizations should see an organization selector rather than always defaulting to the first organization.
- Test with Zoho Books' test organization (create a separate Zoho account with sample data) before integrating live data. Invoice sending and financial record creation should be tested in isolation.
Alternatives
Xero has a larger accountant community and stronger bank reconciliation features, and is more widely adopted in the UK, Australia, and New Zealand than Zoho Books.
QuickBooks dominates the North American market with more integrations and a larger ecosystem — choose it for US and Canadian businesses already on QuickBooks.
FreshBooks is simpler than Zoho Books and has better time tracking, making it the right choice for freelancers and service businesses without Zoho ecosystem dependencies.
Wave is completely free with no revenue threshold and uses a GraphQL API — better for very small businesses that are not already in the Zoho ecosystem.
Frequently asked questions
Is Zoho Books free to use?
Zoho Books has a free plan for businesses with annual revenue under $50,000 USD, limited to one user and one accountant. The free plan includes invoicing, expense tracking, and the full REST API. Paid plans start at $20/month for more users, multi-currency, and advanced features. The API access tier matches the plan — API calls are available on all plans including free.
Can I test the Zoho Books integration in Bolt's preview?
Partially. Outbound API calls to fetch and create Zoho Books data work in the WebContainer once you have valid tokens. The OAuth authorization flow requires deployment since it relies on an incoming redirect from Zoho. Complete auth on your deployed app, then use those tokens for testing API features in development.
How does Zoho Books integrate with Zoho CRM?
Zoho Books and Zoho CRM share contact records — a Zoho Books customer is the same entity as a Zoho CRM contact in most configurations. You can use the same OAuth client for both APIs by adding ZohoCRM scopes alongside ZohoBooks scopes. Zoho's native sync (configured in Zoho Books Settings → Integrations → Zoho CRM) automatically creates Books customers from CRM contacts and vice versa, without custom API code.
What are the Zoho Books API rate limits?
Zoho Books API limits are 60 requests per minute on the free plan and 100 requests per minute on paid plans, per organization. There is also a daily limit of 2,500 API calls on free plans and 5,000 on paid plans. For dashboards that make multiple simultaneous API calls, use caching and Promise.all to stay within these limits.
How do I handle Zoho Books data for multiple countries?
Zoho Books is region-specific — each Zoho data center (US, EU, IN, AU, JP) hosts separate instances. Your API endpoint and OAuth endpoint must match the region of your Zoho account. If you build an app serving customers in multiple regions, each user's tokens point to their regional endpoint, and you must store the region alongside their credentials to construct the correct API URLs.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation