# Zoho Books Integration: OAuth, AR Sync, and Custom Fields

Connect Zoho Books to DualEntry so customers, items, invoices, and customer payments flow into your DualEntry ledger as [AR master data](/accountants/core-financials/accounts-receivable) and transactions. The integration is read-only toward Zoho — DualEntry pulls from the [Zoho Books API v3](https://www.zoho.com/books/api/v3/) and never posts back to Zoho.

## Prerequisites

Confirm the following before connecting:

- Admin access in DualEntry to create integrations and map accounts.
- A Zoho Books user who can authorize OAuth for your organization and the Books organization you want to sync.
- A DualEntry company selected when you create the integration. The integration is bound to one company, but invoice lines can still post to other DualEntry companies when Zoho invoice custom fields match — see [Configure invoice custom fields](#configure-invoice-custom-fields).
- Your [chart of accounts](/accountants/core-financials/general-ledger/chart-of-accounts) configured in DualEntry, including the default income and default expense accounts you'll map for auto-created items and line items not tied to a mapped Zoho item.
- Zoho Books invoice custom fields set up with the exact labels DualEntry reads: `Location`, `Service Line`, and `Company`. Without the right labels, location and service-line dimensions don't populate.
- A classification in DualEntry named `Service Type` whose lines match your Zoho `Service Line` values by name. Location values resolve against a classification named `Location` (DualEntry creates or updates this classification automatically from Zoho values).


## How to connect

Authentication is OAuth 2.0 (authorization code). DualEntry stores your Zoho access token, refresh token, accounts-server URL, and API domain, then refreshes the access token automatically when it expires.

1. In DualEntry, navigate to **Settings → Integrations → Zoho Books**.
2. Choose **Connect**, complete Zoho's consent flow in the popup, and record the **accounts server** Zoho returns during OAuth (for example `https://accounts.zoho.com` or a regional variant). Zoho includes this value as the `accounts-server` parameter on the redirect.
3. Provide the authorization code, the accounts server, and the DualEntry company you want to bind the integration to. DualEntry exchanges the code for tokens, loads the Zoho Books organization list, sets the integration name from the first organization returned, marks the integration **Connected**, and queues an initial sync.
4. Map the default **Expense account** and **Income account** integration rows to your DualEntry GL accounts. The pre-setup phase creates these placeholder rows; until both are mapped, the integration isn't fully set up.
5. Wait for the first sync to complete. Setup completion depends on a successful initial sync (the integration tracks last-successful-sync internally).


To run another pull without waiting for the scheduler, choose **Sync now** in the integration settings. Sync-now is blocked while the integration is paused.

## What syncs

Each sync run pulls Zoho Books data in this order: invoices → payments → customers → items → locations. Each phase uses its own budget for Zoho "detail" API calls so a single sync can't exhaust your daily quota.

| Zoho Books data | DualEntry record | Notes |
|  --- | --- | --- |
| Contacts | `customer` | Bulk list uses `last_modified_time` since integration creation. A detail fetch may run once per contact to fill `country` from the billing address. |
| Items | `item` | Catalog items. New DualEntry items default to **service** type and use your mapped default expense and income accounts. |
| Invoice custom field values labeled `Location` | classification line under `Location` | DualEntry ensures a `Location` classification exists and maps values to lines automatically. |
| Invoice custom field values labeled `Service Line` | `item` rows | Used when an invoice is driven by a service line instead of catalog line items. |
| Invoices | `invoice` | Draft invoices are skipped at detail fetch. Non-draft invoices require the Zoho customer to be mapped first. PDF attachment from Zoho's template PDF endpoint is best-effort — failures are logged and don't block the invoice. |
| Customer payments | `customer_payment` | Requires payment mode on the list payload and a detail fetch for applied invoices — see [Map payment-mode accounts](#map-payment-mode-accounts). |


Customer, invoice, and customer-payment lists use Zoho's `last_modified_time` ordering and stop paging when the oldest row on a page is older than the integration's creation date. Items and locations are fetched in full (paginated) when their sync steps run.

Very large backfills may need multiple sync runs across multiple days because of Zoho's daily API quota. The integration enforces a per-sync cap on detail fetches so one run cannot exhaust the quota; if the cap is hit, the sync ends with a rate-limit error and the next scheduled run picks up where it left off.

## Configure invoice custom fields

DualEntry reads Zoho Books invoice custom fields by **label**. Three labels are recognized today:

- `Company` — value must match a DualEntry company name. When it matches, the invoice posts to that company; otherwise the integration's default company is used.
- `Location` — value must match a classification line name under your DualEntry classification named `Location`. The classification name and the Zoho field label both have to align.
- `Service Line` — when present on an invoice, line construction follows the service-line path. Service-line values are matched to classification lines under a DualEntry classification named `Service Type`.


Custom field labels and DualEntry classification line names must match exactly — including punctuation and case. If your Zoho labels read "Office" instead of "Location", or your DualEntry classification is named "Department" instead of "Service Type", DualEntry doesn't attach those dimensions to the journal entry. This is the most common source of "my dimensions aren't showing up" support tickets.

If your Zoho organization uses different label conventions, rename the Zoho custom fields rather than expecting DualEntry to match alternate labels. The integration matches on literal string equality with no fallbacks.

## Map payment-mode accounts

Each customer payment needs a DualEntry GL account for the Zoho payment mode (Stripe, Check, Cash, Bank Remittance, Bank Transfer, Credit Card, ACH/WIRE). DualEntry creates a placeholder integration record for each (mode, company) pair the first time it sees a payment in that mode, and you map it to the correct cash or undeposited-funds account.

To complete payment-mode mapping:

1. Open the integration's mapping screen after the first sync of customer payments.
2. For each payment-mode row, choose the DualEntry GL account where payments in that mode should post.
3. Resync to apply the mapping to any payments that were captured pending the mapping.


Payments allocate to invoices that are already imported and mapped. If no applied invoices are importable, the payment fails with a "no importable invoices" error. Some Zoho-side errors are treated as permanent — for example, payment amounts that exceed the invoiced balance or fall in a locked period — so DualEntry marks the payment skipped instead of retrying forever.

## Troubleshoot sync errors

When a record fails to sync, the most common causes:

| Symptom | Likely cause | Resolution |
|  --- | --- | --- |
| OAuth or `424` on create | Invalid code, clock skew, wrong `accounts_server`, or a Zoho app misconfiguration. | Retry with a fresh authorization code; verify Zoho client ID/secret/redirect on the server; confirm Books organization access. |
| Invoice never imports | The invoice is in **Draft** status in Zoho. | Finalize or send the invoice in Zoho, then resync. |
| Invoice error: customer missing | The Zoho customer isn't yet mapped to a DualEntry customer. | Let the customer sync run, map any unmapped customers, and resync. |
| Payment fails: account not found | The payment-mode row isn't mapped to a GL account. | Map the placeholder `payment_account` record for that company and mode. |
| Payment skipped permanently | Zoho returned a business-rule error (over-application, locked period, and similar). | Fix the data in Zoho or accept the skip — DualEntry doesn't keep failing the same record. |
| Location or service line missing on JE | Custom field labels or DualEntry classification line names don't match. | Align Zoho custom field labels (`Location`, `Service Line`, `Company`) and DualEntry classification line names (`Location`, `Service Type`). |
| PDF missing on invoice | Detail budget exhausted, Zoho PDF error, or transient network issue. | The invoice still posts; review logs or rerun sync. |
| Stale token / random `401` | The access token expired. | The next API call refreshes the token automatically; if the refresh fails, reconnect via OAuth. |


For Zoho-wide outages, see the [Zoho status page](https://status.zoho.com/).

## FAQ

### Does DualEntry write back to Zoho Books?

No. The connector only pulls — Zoho remains the system of record for AR operations.

### Which Zoho product is supported?

Zoho Books (API paths under `/books/v3/`). Zoho Invoice and other Zoho suites aren't supported unless they expose the same Books endpoints your deployment targets.

### Why does setup require income and expense accounts?

New `item` records created from Zoho lines fall back to the default income and expense accounts when Zoho doesn't supply a mapped catalog item. Without those defaults, line creation fails.

### Can I use a different classification name than "Service Type"?

No. The integration matches `Service Line` custom field values to lines under a DualEntry classification literally named `Service Type`. Renaming the classification breaks the mapping.

## For maintainers

The details below describe deployment and connector configuration, not user-facing features.

- OAuth app config (server-side environment): `ZOHO_CLIENT_ID`, `ZOHO_CLIENT_SECRET`, `ZOHO_REDIRECT_URI`. The redirect URL registered in the Zoho API console must match the deployment's redirect URI.
- Internal create endpoint: `POST /api/integrations/zoho/` accepts `authorization_code`, `accounts_server` (URL-encoded when required by your client), `company_id`, and an optional `integration_id` for updates.
- Internal trigger-sync endpoint: `POST /api/integrations/zoho/{integration_id}/trigger-sync/` (blocked when the integration is paused).
- Internal field plumbing: the integration sets `remote_id` and `name` from the first Zoho organization returned, queues `sync_integration` on connect, and tracks `last_sync_at` for setup-completion flags.
- Incremental sync details: customer/invoice/payment lists page using `last_modified_time` and stop when the oldest row is older than the integration's `created_at`, with a five-day delta window on invoice and payment upserts to allow for late-arriving Zoho updates.
- API quota: Zoho publishes a 10,000 requests per day limit for typical Books API usage. The Zoho SDK respects `429` responses with `Retry-After`-style backoff and retries a few times before surfacing the failure.


## Result

After OAuth, default-account mapping, payment-mode mapping, and the first successful sync, Zoho Books AR activity and master data land in DualEntry with optional company, location, and service-type dimensions driven by Zoho invoice custom fields, plus PDF attachments on invoices when Zoho and API limits allow. To audit the resulting entries, see [Customer payments](/accountants/core-financials/accounts-receivable/customer-payments) and [Journal Entries](/accountants/core-financials/general-ledger/journal-entries). To compare other OAuth-based integrations, see [Stripe](/accountants/integrations/stripe). To connect additional systems, return to [Integrations](/accountants/integrations).