# Zoho Books

- Tool: Bubble
- Difficulty: Intermediate
- Time required: 3–4 hours
- Last updated: July 2026

## TL;DR

Connect Bubble to Zoho Books using OAuth 2.0 self-client flow. Two requirements stop most first implementations: the Authorization header uses a non-standard 'Zoho-oauthtoken' prefix (not Bearer), and every single API call to /books/v3/ must include ?organization_id=[id] as a query parameter. Miss the organization_id and you get a 400 error that looks like an auth problem but isn't.

## Zoho Books + Bubble: two non-obvious requirements that block every first implementation

Zoho Books is a solid accounting platform for small businesses, especially popular with Zoho CRM users who want to add invoicing to their existing Zoho stack. The API is well-documented, but two implementation details are not obvious and trip up nearly every Bubble builder on their first attempt.

**Non-standard authorization header.** Most OAuth APIs use `Authorization: Bearer <token>`. Zoho Books uses `Authorization: Zoho-oauthtoken <token>`. This is not Bearer — it is a Zoho-specific prefix. If you type 'Bearer' in the API Connector header value, you will get 401 errors on every request even with a perfectly valid access token. The exact string must be `Zoho-oauthtoken ` (with a space) followed by the token.

**organization_id on every call.** The Zoho Books API groups data by organization. Every endpoint under /books/v3/ requires an `?organization_id=<id>` query parameter. Without it, the API returns HTTP 400 — a status code that looks like a bad request (which it is) but is easily misread as an auth problem. You bootstrap the organization_id by calling GET /books/v3/organizations (the one endpoint that doesn't require this parameter), then storing the returned ID for all subsequent calls.

**The self-client OAuth pattern.** Unlike QuickBooks or Xero, Zoho Books integrations commonly use the 'self-client' flow: you generate a one-time grant code in the Zoho API Console, exchange it for a refresh token in Postman (once, manually), then store the refresh token in Bubble's database. There is no OAuth callback page to build. You just need a Backend Workflow to exchange the refresh token for access tokens every hour.

This tutorial walks through all three points with Bubble-specific implementation steps.

## Before you start

- A Bubble account on the Starter plan or higher (Backend Workflows required for token refresh — free plan cannot automate token management)
- A Zoho Books account (free trial available; paid plans from ~$15/organization/month for production use)
- Access to api-console.zoho.com — free with any Zoho account
- Postman or similar REST client for the one-time grant code exchange
- API Connector plugin installed in your Bubble app (Plugins → Add plugins → search 'API Connector' → Install)

## Step-by-step guide

### 1. Step 1: Create a self-client app in Zoho API Console and generate the grant code

Go to api-console.zoho.com and log in with your Zoho account. Click the **+** button (or 'Add Client') to create a new application. Select **Self Client** as the client type. A self-client is designed for server-side integrations where you control both the app and the authorization — perfect for Bubble integrations where you are accessing your own Zoho Books data rather than your end users' Zoho accounts.

Give your self-client a name (e.g. 'Bubble Books Integration') and click **Create**. You will receive a **Client ID** and **Client Secret**. Copy both.

Now click **Generate Code** in the self-client interface. This opens a form where you specify the OAuth scopes you need. Enter the following scopes (comma-separated or on separate lines depending on the Zoho console UI):
- `ZohoBooks.invoices.READ` — read invoices
- `ZohoBooks.invoices.CREATE` — create invoices
- `ZohoBooks.customers.READ` — read contacts/customers
- `ZohoBooks.settings.READ` — required for the /organizations bootstrap call

For the **Time Duration** (grant code validity), select the maximum available — grant codes expire quickly (usually 3 minutes). You must exchange this code for a refresh token before it expires.

Copy the generated grant code immediately — you have only a few minutes to exchange it.

```
// Self-client OAuth scopes for Zoho Books:
// ZohoBooks.fullaccess.all  — full access (simplest, use if acceptable)
// OR more granular:
// ZohoBooks.invoices.READ,ZohoBooks.invoices.CREATE
// ZohoBooks.customers.READ,ZohoBooks.settings.READ

// Zoho API Console path:
// api-console.zoho.com → Add Client → Self Client
//   → Client ID + Client Secret
//   → Generate Code → enter scopes → copy grant code (3 min expiry)

// Region-specific Zoho accounts:
// US: api-console.zoho.com
// EU: api-console.zoho.eu
// India: api-console.zoho.in
// Australia: api-console.zoho.com.au
```

**Expected result:** A Self Client app exists in the Zoho API Console with Client ID and Client Secret. A grant code has been generated with the required ZohoBooks scopes and copied. You have 3 minutes to complete the exchange in Step 2.

### 2. Step 2: Exchange the grant code for a refresh token using Postman

You have 3 minutes from generating the grant code to exchange it. Open Postman (or any REST client) immediately.

Create a new POST request:
- URL: `https://accounts.zoho.com/oauth/v2/token` (adjust to your region: zoho.eu, zoho.in, zoho.com.au)
- Method: POST
- Body type: form-data (or x-www-form-urlencoded)
- Fields:
  - `code`: the grant code you copied from Step 1
  - `client_id`: your self-client Client ID
  - `client_secret`: your self-client Client Secret
  - `redirect_uri`: `urn:ietf:wg:oauth:2.0:oob` (use this exact value for self-client)
  - `grant_type`: `authorization_code`

Send the request. If successful, you receive a response with:
- `access_token`: valid for 1 hour
- `refresh_token`: long-lived, does not expire
- `token_type`: Bearer (confusingly, Zoho calls it Bearer here but requires 'Zoho-oauthtoken' in API headers)
- `expires_in`: 3600 seconds

Copy the `refresh_token` from this response — this is the long-lived credential you will store in Bubble's database. The access_token expires in 1 hour, but the refresh_token doesn't expire and can be used indefinitely to generate new access tokens.

In your Bubble Data tab, add these fields to either the User type or a dedicated 'Zoho Connection' data type:
- `zoho_refresh_token` (text)
- `zoho_access_token` (text)
- `zoho_organization_id` (text)
- `zoho_token_issued_at` (date)

Add Privacy Rules (Data tab → Privacy) restricting these fields to the token owner.

```
// Postman POST — exchange grant code for refresh token
POST https://accounts.zoho.com/oauth/v2/token
Content-Type: application/x-www-form-urlencoded

Body:
code=1000.abc123def456...xyz
client_id=1000.ABCDEF123456789
client_secret=abc123def456ghi789jkl012
redirect_uri=urn:ietf:wg:oauth:2.0:oob
grant_type=authorization_code

// Successful response:
{
  "access_token": "1000.abc123...",  // Expires in 1 hour
  "refresh_token": "1000.xyz789...", // STORE THIS — does not expire
  "token_type": "Bearer",            // Zoho says Bearer here, but use Zoho-oauthtoken in API headers
  "expires_in": 3600,
  "api_domain": "https://www.zohoapis.com"
}

// IMPORTANT: Store refresh_token in Bubble database immediately
// The grant code is now expired — this is the only chance to get the refresh_token
// If you lose the refresh_token, repeat Steps 1-2 from scratch
```

**Expected result:** You have a long-lived Zoho Books refresh_token stored securely. The Bubble data type has zoho_refresh_token, zoho_access_token, zoho_organization_id, and zoho_token_issued_at fields with Privacy Rules.

### 3. Step 3: Build the 'Refresh Zoho Token' Backend Workflow and bootstrap organization_id

With the refresh token stored, build a Backend Workflow that exchanges it for access tokens. This workflow must run before any Zoho Books API call whenever the token is older than 55 minutes.

In Bubble's Backend Workflows section (Settings → API → enable 'This app exposes a Workflow API' — requires a paid plan), create a workflow named 'Refresh Zoho Token'.

Workflow steps:
1. **POST to Zoho token endpoint:**
   - URL: `https://accounts.zoho.com/oauth/v2/token`
   - Method: POST
   - Headers: `Content-Type: application/x-www-form-urlencoded`
   - Body form params: `refresh_token=[zoho_refresh_token]`, `client_id=[client_id]`, `client_secret=[client_secret]`, `grant_type=refresh_token`
2. **Save the new access_token:** 'Make changes to [your user/settings record]': set `zoho_access_token` = response.access_token, set `zoho_token_issued_at` = Current date/time.

Note: Zoho Books refresh tokens do NOT rotate on use. The same refresh_token is valid indefinitely. You only need to save the new access_token, not a new refresh_token.

Now add a one-time initialization workflow: 'Get Zoho Organization ID'. This Backend Workflow calls GET https://www.zohoapis.com/books/v3/organizations with the Authorization header (Zoho-oauthtoken [access_token]) and saves the first result's `organization_id` to `zoho_organization_id` in your data record.

Run this initialization once after the first successful token refresh. Store the organization_id permanently — it never changes for a given Zoho Books account.

```
// Backend Workflow: Refresh Zoho Token
// Trigger: Manual or schedule before API calls

// POST to token endpoint:
{
  "method": "POST",
  "url": "https://accounts.zoho.com/oauth/v2/token",
  "body": "refresh_token=<stored_refresh_token>&client_id=<client_id>&client_secret=<client_secret>&grant_type=refresh_token"
}

// Response:
{
  "access_token": "1000.newtoken...",
  "token_type": "Bearer",
  "expires_in": 3600
  // refresh_token NOT returned (does not rotate)
}

// Save: zoho_access_token = response.access_token
//        zoho_token_issued_at = Current date/time

// GET /organizations — one-time bootstrap:
{
  "method": "GET",
  "url": "https://www.zohoapis.com/books/v3/organizations",
  "headers": { "Authorization": "Zoho-oauthtoken <access_token>" }
}

// Response:
{
  "organizations": [
    {
      "organization_id": "20097458213",
      "name": "My Business",
      "country_code": "US",
      "currency_code": "USD"
    }
  ]
}
```

**Expected result:** A 'Refresh Zoho Token' Backend Workflow exists that exchanges the stored refresh_token for a new access_token and saves it. A separate 'Get Zoho Organization ID' workflow runs once to bootstrap the organization_id. Both values are stored in your Bubble data.

### 4. Step 4: Configure the API Connector with the Zoho-oauthtoken header

In the Bubble editor, go to **Plugins → API Connector**. Click **Add another API** and name it 'Zoho Books'. Set the base URL to `https://www.zohoapis.com/books/v3` (or the regional URL if your Zoho account is in EU: zohoapis.eu, India: zohoapis.in, Australia: zohoapis.com.au).

In the **Shared headers** section, add the authorization header:
- Header name: `Authorization`
- Value: `Zoho-oauthtoken [access_token]` where `[access_token]` is a parameter placeholder
- Check the **Private** checkbox

This is the critical formatting requirement: the prefix is `Zoho-oauthtoken` (with a capital Z and lowercase 'oauthtoken'), not `Bearer`. Even though Zoho's token endpoint response calls it 'Bearer', the API endpoints require the `Zoho-oauthtoken` prefix. A typo here causes 401 errors that look like auth failures but are actually header format errors.

Add a second shared header: `Content-Type: application/json`.

Now add the Initialize Call. Click **Add a call**, name it 'Get Organizations'. Set method to GET, path to `/organizations` (no organization_id needed for this specific endpoint — it is the bootstrap endpoint). Set **Use as: Data**.

In the test parameters, enter your current access_token value. Click **Initialize call**. A successful response returns the organizations array with your organization_id, name, and currency code. Bubble auto-detects these fields.

If you see a 401, check the Authorization header format — make sure it reads exactly `Zoho-oauthtoken [token]` with no extra spaces or characters. If you see 'Zoho-oauthtoken Bearer', you accidentally put 'Bearer' in the value instead of the access token.

```
// API Connector — Zoho Books group configuration
{
  "api_name": "Zoho Books",
  "base_url": "https://www.zohoapis.com/books/v3",
  "shared_headers": [
    {
      "name": "Authorization",
      "value": "Zoho-oauthtoken <access_token>",  // NOT Bearer
      "private": true
    },
    {
      "name": "Content-Type",
      "value": "application/json"
    }
  ]
}

// Regional base URLs (must match your Zoho account region):
// US: https://www.zohoapis.com/books/v3
// EU: https://www.zohoapis.eu/books/v3
// India: https://www.zohoapis.in/books/v3
// AU: https://www.zohoapis.com.au/books/v3

// Authorization header format comparison:
// CORRECT: Zoho-oauthtoken 1000.abc123...
// WRONG: Bearer 1000.abc123...
// WRONG: Zoho-oauthtoken Bearer 1000.abc123...
// WRONG: zoho-oauthtoken 1000.abc123... (lowercase Z)
```

**Expected result:** The Zoho Books API Connector group is configured with a Private Authorization header using 'Zoho-oauthtoken' prefix. The 'Get Organizations' Initialize Call returns a 200 with organization name and organization_id. The call is initialized.

### 5. Step 5: Add invoice and customer API calls with mandatory organization_id parameter

Every Zoho Books API call (except /organizations) requires the `organization_id` query parameter. Without it, the API returns HTTP 400 — a response code that looks like a request formatting error but is actually triggered by the missing organization_id.

Add the invoice list call: click **Add a call**, name it 'Get Invoices'. Set method to GET, path to `/invoices`. Add query parameters:
- `organization_id`: set default to your stored organization_id value (will be populated from Bubble data at call time)
- `status`: SENT (filter to sent/outstanding invoices; other values: DRAFT, OVERDUE, PAID, VOID, ALL)
- `page`: 1
- `per_page`: 25 (Zoho Books' default is 200 per page — be conservative given daily rate limits)

The response includes an `invoices` array with fields: `invoice_id`, `invoice_number`, `customer_name`, `total`, `balance`, `due_date`, `status`.

Add a customer/contact call: GET `/contacts` with `organization_id` parameter and `contact_type=customer`. Useful for building a customer selector when creating new invoices.

For creating invoices, add a POST `/invoices` call with `organization_id` as a query parameter and a JSON body containing: `customer_id`, `line_items` (array of item_id, quantity, rate, name), `due_date` (YYYY-MM-DD format — Zoho Books uses standard ISO dates unlike Xero).

**Daily rate limit awareness:** Zoho Books allows 5,000 API calls per day on paid plans (2,500 on free). With a Bubble Repeating Group loading invoices on every page visit, 10 users making 25 page visits each = 250 calls/day on just that one page. Add caching: store invoice data in Bubble's database and refresh only on a 'Refresh' button click or scheduled nightly sync, not on every page load.

```
// GET /invoices — always include organization_id
{
  "method": "GET",
  "path": "/invoices",
  "params": {
    "organization_id": "<zoho_organization_id>",  // MANDATORY on every call
    "status": "SENT",
    "page": "1",
    "per_page": "25"
  },
  "use_as": "Data"
}

// Sample invoice response:
{
  "code": 0,
  "message": "success",
  "invoices": [
    {
      "invoice_id": "1894553000000074001",
      "invoice_number": "INV-000001",
      "customer_id": "1894553000000074003",
      "customer_name": "Acme Corp",
      "total": 1500.00,
      "balance": 1500.00,
      "due_date": "2026-08-01",
      "status": "sent"
    }
  ],
  "page_context": { "page": 1, "per_page": 25, "has_more_page": false }
}

// POST /invoices — create invoice (with organization_id in query)
// URL: /invoices?organization_id=<id>
// Body:
{
  "customer_id": "<customer_id>",
  "line_items": [
    {
      "name": "Consulting Services",
      "quantity": 1,
      "rate": 1500.00
    }
  ],
  "due_date": "2026-08-01"  // ISO format: YYYY-MM-DD
}
```

**Expected result:** API calls for GET /invoices, GET /contacts, and POST /invoices are configured with organization_id as a mandatory parameter. All calls are initialized with auto-detected fields. A Repeating Group displays invoice data from the database cache.

### 6. Step 6: Add token age check and caching strategy to manage rate limits

Zoho Books' 5,000 calls/day limit (on paid plans) sounds generous but can be exhausted quickly by a busy Bubble app without caching. Here is the full operational pattern.

**Token refresh check in every workflow:** At the start of any workflow that calls a Zoho Books endpoint, add a condition: 'Only if Current User's zoho_token_issued_at < Current date/time - 55 minutes'. If this condition is true, run 'Schedule API Workflow → Refresh Zoho Token' and wait for it to complete before continuing. This ensures a fresh access token for every Zoho call.

**Caching invoices in Bubble database:** Create a data type called 'Cached Invoice' with fields mirroring the Zoho Books invoice fields (invoice_number, customer_name, total, balance, due_date, status, zoho_invoice_id). Add a Backend Workflow 'Sync Zoho Invoices' that:
1. Calls GET /invoices with organization_id and status=ALL
2. For each invoice in the response, does a 'Make changes to Thing' if already exists or 'Create a new Cached Invoice'
3. Schedules the next sync for 30 minutes later (or run manually on 'Refresh' button click)

Your Repeating Groups display from 'Cached Invoice' data in Bubble — zero Zoho API calls on page load. The daily budget is spent only on the sync runs, not on every user page view.

**For multi-user apps:** If 10 users each have their own Zoho Books organization connected, each has their own organization_id and refresh_token stored in their User record. Each user's API calls consume from their own Zoho account's daily limit — they don't share one budget. This is ideal for accounting firms building multi-client portals.

RapidDev's team has built Bubble apps with complex accounting API integrations like this. For a free scoping call before you build, visit rapidevelopers.com/contact.

```
// Token age check in workflow:
// Condition: Current User's zoho_token_issued_at < Current date/time - 55 minutes
// Action: Schedule API Workflow → Refresh Zoho Token (and wait)

// Caching strategy — Bubble data type: Cached Invoice
{
  "fields": [
    { "name": "zoho_invoice_id", "type": "text" },
    { "name": "invoice_number", "type": "text" },
    { "name": "customer_name", "type": "text" },
    { "name": "total", "type": "number" },
    { "name": "balance", "type": "number" },
    { "name": "due_date", "type": "date" },
    { "name": "status", "type": "text" },
    { "name": "user", "type": "User" }  // Link to owner for multi-user
  ]
}

// Daily budget calculation example:
// 5,000 calls/day
// Sync every 30 min: 25 pages of 25 invoices = 25 calls per sync
// 48 syncs/day × 25 calls = 1,200 calls/day used by sync
// Remaining: 3,800 calls/day for other operations
// Compared to live-fetching: 10 users × 50 page loads × 1 call = 500 calls/day
// Both approaches are fine for moderate usage
```

**Expected result:** Token age is checked before every Zoho Books API call. Invoice data is cached in a Bubble database table and loaded from there on page load. A 'Refresh' button triggers a manual sync to Zoho Books when users need the latest data.

## Best practices

- Every API call to Zoho Books (except /organizations) must include ?organization_id=[id] — add this as a required parameter in every API Connector call configuration and populate it from the user's stored zoho_organization_id field.
- Use exactly 'Zoho-oauthtoken' (capital Z, one word 'oauthtoken') as the Authorization header prefix — not 'Bearer'; this is the single most common typo that causes 401 errors in Zoho Books integrations.
- Match your API base URL and token endpoint URL to your Zoho account's data center region — mixing regions (e.g. US URL for an EU account) causes 401 errors that are difficult to diagnose without knowing the region requirement.
- Cache Zoho Books data (invoices, contacts, payments) in Bubble's database and use scheduled Backend Workflows for sync rather than live API calls on every page load — the 5,000 calls/day budget requires conservative usage patterns in any multi-user app.
- Zoho Books refresh tokens do not rotate on use (unlike QuickBooks) — storing the refresh token once in Bubble's database and refreshing the access token every 55 minutes is all the credential management needed; no complex rotation logic required.
- Add Privacy Rules for all zoho_ credential fields in your Bubble data type — restrict to 'This User is Current User' to prevent token exposure across users of your app.
- If your Zoho account already uses Zoho CRM, reuse the same self-client app by adding ZohoBooks scopes to your existing CRM grant code — the same refresh token can cover both Zoho CRM and Zoho Books APIs, reducing credential management complexity.
- Test with a Zoho Books trial account before asking users to connect their production Zoho Books data — Zoho's trial includes invoice and contact data suitable for testing all the API calls you need to implement.

## Use cases

### Invoice list and status dashboard

Display all pending invoices from Zoho Books in a Bubble Repeating Group, filtered by status (Draft, Sent, Overdue, Paid). Use GET /books/v3/invoices?organization_id=[id]&status=overdue to surface overdue invoices that need attention. Cache results in Bubble database to stay within the daily rate limit.

### Customer portal showing their invoices

Build a client-facing portal where customers log into your Bubble app and see only their invoices from Zoho Books. Match the logged-in user's email to a Zoho contact and filter invoices by contact_id from GET /books/v3/invoices?contact_id=[zoho_contact_id]&organization_id=[id].

### Auto-create Zoho Books invoices from Bubble CRM

When a deal is marked 'Won' in your Bubble CRM, automatically POST a new invoice to Zoho Books with line items from the deal record. Eliminates double-entry between your operational Bubble app and Zoho Books accounting.

## Troubleshooting

### Every Zoho Books API call returns HTTP 400 even with a valid access token

Cause: The organization_id query parameter is missing. Every endpoint under /books/v3/ (except /organizations) requires ?organization_id=[id]. A 400 response from a missing organization_id is easily confused with an auth error or bad request format, but the cause is almost always this missing parameter.

Solution: Check that your stored zoho_organization_id field in Bubble is not blank. If blank, run the 'Get Zoho Organization ID' Backend Workflow to bootstrap it from GET /organizations. Then ensure every API call in your Connector has organization_id as a parameter, populated from the user's stored zoho_organization_id at call time. Verify by testing the GET /organizations call separately — it is the one endpoint that does not need organization_id and should return your org details if auth is correct.

```
// Verify organization_id is in every call URL:
// CORRECT: GET /invoices?organization_id=20097458213&status=SENT
// WRONG:   GET /invoices?status=SENT  ← returns 400

// In API Connector, add as a parameter:
// Name: organization_id
// Value: <zoho_organization_id>  (populated from Bubble User's field at call time)
```

### All Zoho Books API calls return 401 Unauthorized despite valid credentials

Cause: Two common causes: (1) the Authorization header uses 'Bearer' instead of 'Zoho-oauthtoken', or (2) the API base URL does not match the user's Zoho account region.

Solution: First, verify the Authorization header in your API Connector. Open the Zoho Books API group's shared headers. The value must read exactly: `Zoho-oauthtoken [your_access_token]`. If it reads `Bearer [token]`, change 'Bearer' to 'Zoho-oauthtoken'. Second, verify the base URL matches your Zoho account region. EU accounts must use zohoapis.eu, India accounts use zohoapis.in, AU accounts use zohoapis.com.au. Using zohoapis.com (US) for a non-US account gives 401 even with correct credentials.

```
// Correct Authorization header:
// Authorization: Zoho-oauthtoken 1000.abc123def456...

// WRONG variants:
// Authorization: Bearer 1000.abc123...           ← wrong prefix
// Authorization: Zoho-oauthtoken Bearer 1000...  ← double prefix
// Authorization: zoho-oauthtoken 1000.abc123...  ← lowercase Z (case sensitive)
```

### Daily API call limit exhausted before end of business day

Cause: Zoho Books' 5,000 calls/day limit (paid plans) or 2,500/day (free plans) is a per-organization-per-day budget shared across all API consumers. Bubble Repeating Groups that load live from the API on every page view, or frequent polling, consume this budget quickly in multi-user apps.

Solution: Implement the caching strategy from Step 6: cache Zoho invoice and contact data in Bubble's database and refresh on a schedule (e.g. every 30 minutes via Backend Workflow) or on user-triggered 'Refresh' actions. Have page Repeating Groups load from the Bubble database cache, not live from Zoho. Check current consumption in Zoho's API usage dashboard (api-console.zoho.com → your app → Usage).

### 'Workflow API is not enabled' when creating Backend Workflows for token refresh

Cause: Backend Workflows are a paid Bubble feature not available on the free plan.

Solution: Upgrade to Bubble Starter plan (~$32/month). Without Backend Workflows, there is no way to automate token refresh on Bubble's free plan. Users would need to manually re-authenticate every hour as the access token expires. The free plan cannot complete this integration in any production-ready form.

### There was an issue setting up your call — Initialize Call fails for the invoices endpoint

Cause: The Initialize Call needs a real successful response. The most common causes: expired access_token (Zoho access tokens last 1 hour), missing organization_id in the test parameter, or wrong API base URL for your region.

Solution: Run the 'Refresh Zoho Token' Backend Workflow to get a fresh access_token. Copy the new token value from your Bubble data and paste it as the test value in the Initialize Call's access_token parameter. Also add your organization_id as the test value for the organization_id parameter. Then click Initialize call again. If still failing, test the endpoint in Postman first with the same credentials to confirm the call works before attempting in Bubble.

## Frequently asked questions

### What is the Zoho Books self-client OAuth flow and why does it require Postman?

The self-client flow is designed for server-to-server integrations where you (the developer) control both the Bubble app and the Zoho Books account. Instead of building an OAuth callback page for users to authorize, you generate a grant code manually in the Zoho API Console and exchange it once in Postman to get a long-lived refresh token. That refresh token is stored in Bubble's database and used to generate 1-hour access tokens automatically. It's simpler than building a full OAuth flow, but requires the one-time Postman step during setup.

### Why does Zoho Books use 'Zoho-oauthtoken' instead of 'Bearer' in the Authorization header?

This is a Zoho-specific convention. Despite calling the token 'Bearer' in the token endpoint response, Zoho Books API endpoints require the custom 'Zoho-oauthtoken' prefix in the Authorization header. Zoho designed this to distinguish Zoho OAuth tokens from other token types. The prefix is case-sensitive — 'Zoho-oauthtoken' works, 'zoho-oauthtoken' or 'Bearer' do not.

### Why does Zoho Books return 400 instead of 403 when organization_id is missing?

Zoho Books treats a missing organization_id as a malformed request (HTTP 400 Bad Request) rather than an authorization error (403 Forbidden). From Zoho's perspective, a request without organization_id is incomplete — like calling an endpoint without required parameters. This is confusing because 400 is usually associated with JSON formatting errors or missing required body fields, not missing URL parameters. Every call to /books/v3/ endpoints (except /organizations) requires this parameter.

### Can I connect multiple users' Zoho Books accounts in one Bubble app?

With the self-client approach (this tutorial), no — self-client generates credentials for your own Zoho account, not for other users' accounts. If you are building an app where your end users each connect their own Zoho Books account, you need the full OAuth Authorization Code flow (like QuickBooks or Xero) with a callback page. Contact Zoho developer support to understand the OAuth options for multi-tenant apps. For single-organization use (your own Zoho Books), the self-client flow is the simplest approach.

### What happens if I hit the 5,000 calls/day Zoho Books rate limit?

Requests beyond the daily limit return HTTP 429 (Too Many Requests) and fail until the next day's quota resets (midnight in your Zoho account's timezone). There is no higher-limit tier you can upgrade to — it's a hard API limit per organization. Prevent hitting it by implementing the database caching pattern from Step 6: store invoice and contact data in Bubble's database and sync periodically rather than making live API calls on every user interaction.

### Do I need a paid Bubble plan to connect Zoho Books?

Yes. Token refresh automation requires Backend Workflows, which are a paid Bubble feature (minimum Starter plan, ~$32/month). Without Backend Workflows, access tokens expire every hour and users would need to manually re-authenticate. The initial one-time setup (grant code exchange via Postman) can be done without Backend Workflows, but the ongoing operation of keeping tokens fresh requires them.

---

Source: https://www.rapidevelopers.com/bubble-integrations/zoho-books
© RapidDev — https://www.rapidevelopers.com/bubble-integrations/zoho-books
