Zoho Books

Zoho Books API v3: OAuth 2.0 across eight data centres, organization_id on every call, sales orders, invoices, items, credit notes, payments, plus Zoho Inventory stock adjustments.

Zoho Books is the accounting product in Zoho's finance suite, sold worldwide but with unusually deep India and GCC coverage: GST treatment, place of supply, e-invoicing and e-way bills are first-class fields rather than plugins. For a commerce database it is the most approachable of the accounting systems, because Zoho publishes a complete OpenAPI 3.0 specification per module, the API is an ordinary JSON REST API, and every object a seller cares about (sales order, invoice, item, credit note, customer payment, contact, location) is readable and writable. The two things that catch people out are the data centres, because a US-issued token will not work against an Indian organisation, and the split with Zoho Inventory, because stock adjustments live in a different product on a different base path.

At a glance

What it is

Zoho Corporation is a Chennai-headquartered company with a very large SaaS suite. Zoho Books is its double-entry accounting product; Zoho Invoice is a cut-down billing-only version of the same codebase; Zoho Inventory is the stock, warehouse and order-fulfilment product that shares the same contacts, items and sales orders. An organisation can be on Books alone, Inventory alone, or both, and when both are enabled they are two views of one dataset with two API surfaces.

The India relevance is high. Zoho Books is one of the GST Suvidha Provider-adjacent tools used by Indian SMBs, carries gst_no, gst_treatment, place_of_supply, hsn_or_sac and is_reverse_charge_applied as ordinary fields on sales documents, and supports e-invoicing. It is a common destination for D2C brands that have outgrown Tally but do not want a full ERP. It is also a common source: brands running Zoho Books as the book of record will want marketplace orders written into it as sales orders or invoices.

An "organisation" in Zoho's vocabulary is one set of books, roughly one legal entity. A single Zoho login can own or be invited to several, and every API call must name which one.

API access

  1. Register a client at the Zoho API console for the data centre your developer account lives in. Three client types matter: Server-based Applications (the ordinary authorization-code flow, needs a redirect URI), Self Client (no redirect, for a backend job talking to your own organisation), and Client-based Applications for browser-only apps, which is not appropriate here because the client secret would be exposed.
  2. Note the client id and client secret. Client ids look like 1000.0SRSxxxx.
  3. There is no application review, no partner tier and no fee for API access itself. What costs money is the Zoho Books plan, which is what sets the daily call quota.

Versioning is in the path: /books/v3 for Books, /inventory/v1 for Inventory. No deprecation dates were published on the pages read.

Base URLs by data centre. These are the roots that every path in this page hangs off.

Zoho Inventory uses the same hosts with /inventory/v1 in place of /books/v3.

Official SDKs. Zoho publishes client libraries for its platform (Java, Python, PHP, Node.js, C#) under the zoho GitHub organisations, generally per product. Because every module ships an OpenAPI 3.0 document, generating a client from the spec is a reasonable alternative and is what the field names in this page are taken from. Each module page on the developer portal carries a download link of the form https://www.zoho.com/books/api/v3/<module>/<module>.yml, for example invoices/invoices.yml, sales-order/sales-order.yml, items/items.yml.

Note

The module path in the docs URL is not always the API path. Sales orders are documented at /books/api/v3/sales-order/ but the endpoint is /salesorders. Credit notes are at /credit-notes/ but the endpoint is /creditnotes. Take endpoints from the spec, not from the docs URL.

Authentication

OAuth 2.0 authorization code, with the important wrinkle that the data centre is discovered during the flow rather than configured up front.

  1. Send the user to the authorization endpoint on the accounts server for your client's data centre. The accounts servers are https://accounts.zoho.com, .eu, .in, .com.au, .jp, .ca, .sa and .uk.
GET https://accounts.zoho.com/oauth/v2/auth
  ?response_type=code
  &client_id=1000.0SRSxxxx
  &scope=ZohoBooks.salesorders.READ,ZohoBooks.invoices.ALL,ZohoBooks.settings.READ
  &redirect_uri=https://connector.example.com/oauth/zoho
  &access_type=offline
  &prompt=consent

access_type=offline is required to get a refresh token. prompt=consent forces the consent screen, which is how you get a new refresh token when you already have one.

  1. Read the redirect. Zoho sends back code, plus two parameters that tell you where the user actually lives:
https://connector.example.com/oauth/zoho?code=1000.abc...&location=in&accounts-server=https://accounts.zoho.in

The authorization code is valid for two minutes and is single use.

  1. Exchange the code at the accounts server named in accounts-server, not at the one you started from. This is the step that breaks naive implementations: a user whose organisation is in the India data centre must have their token minted at https://accounts.zoho.in/oauth/v2/token.
curl -X POST 'https://accounts.zoho.in/oauth/v2/token' \
  -d 'grant_type=authorization_code' \
  -d 'client_id=1000.0SRSxxxx' \
  -d 'client_secret=<client_secret>' \
  -d 'redirect_uri=https://connector.example.com/oauth/zoho' \
  -d 'code=1000.abc...'

The response carries the access token, the refresh token, the API domain to call, and the token type. Zoho's documentation names those fields as access_token, refresh_token, api_domain and token_type; expires_in carries the lifetime, documented as one hour.

  1. Store api_domain alongside the tokens. It is the base host for every subsequent call for that organisation, for example https://www.zohoapis.in. A refresh token is bound to the data centre that issued it and will not work anywhere else.

  2. Refresh. POST to the same token endpoint with grant_type=refresh_token, client_id, client_secret and refresh_token. Access tokens last one hour; refresh tokens are permanent until revoked by the user or by a call to the revoke endpoint.

  3. Call the API with the Zoho-specific authorization header, and with organization_id as a query parameter on every single request.

curl 'https://www.zohoapis.in/books/v3/invoices?organization_id=10234695&page=1&per_page=200' \
  -H 'Authorization: Zoho-oauthtoken 1000.8cb99dxxxxxxxxxxxxx'

Scopes take the shape ZohoBooks.<module>.<operation>, where module is one of contacts, settings, estimates, invoices, customerpayments, creditnotes, salesorders, purchaseorders, bills, vendorcredits, vendorpayments, expenses, projects, banking, accountants, debitnotes, and operation is CREATE, READ, UPDATE, DELETE or ALL. Zoho Inventory uses its own ZohoInventory.* scope family, so an integration that both reads Books invoices and writes Inventory adjustments must request scopes in both families.

Finding the organisation. After minting a token, call GET /organizations to list the books the user can see and pick the organization_id. GET /organizations/user lists them for a specific user. An organization_id is a long numeric string.

Multi-account. One registered client serves any number of sellers. Per seller you store: the refresh token, the api_domain, and one or more organization_id values. There is no tenant header; the organisation is a query parameter, so it is easy to get wrong in a shared HTTP client. Put it in the client's default params, not at each call site.

Objects we can read

Every list endpoint follows one shape: GET /<module>?organization_id=...&page=1&per_page=200, returning {"code": 0, "message": "success", "<module>": [...], "page_context": {...}}. code: 0 means success; a non-zero code with HTTP 400 means a business error.

Pagination. Lists are paginated to 200 items by default. page selects the page and per_page the size. page_context carries page, per_page, has_more_page, applied_filter, sort_column and sort_order. There is no cursor. Loop while has_more_page is true.

Incremental sync. Most list endpoints accept last_modified_time, and list rows carry created_time and last_modified_time in ISO 8601 with offset, for example 2014-08-25T11:23:26+0530. That pair is the basis of an incremental pull.

Orders

GET /salesorders and GET /salesorders/{salesorder_id}.

Filters verified from the specification, which is unusually generous: status (draft, open, invoiced, partially_invoiced and others), filter_by (Status.All, Status.Open, Status.Draft, Status.OverDue and so on), customer_id, customer_name with _startswith and _contains variants, salesorder_number with the same variants, reference_number, item_id, item_name, item_description, email, total, date with _start / _end / _before / _after variants, created_date with the same variants, shipment_date likewise, salesperson_id, salesorder_ids (comma separated, maximum 200), location_ids, customview_id, last_modified_time, search_text, sort_column, page, per_page.

List response:

{
  "code": 0,
  "message": "success",
  "salesorders": [
    {
      "salesorder_id": "460000000039129",
      "customer_name": "SAF Instruments Inc",
      "customer_id": "460000000017138",
      "status": "open",
      "salesorder_number": "SO-00001",
      "reference_number": "REF-001",
      "date": "2014-07-28",
      "shipment_date": null,
      "shipment_days": 2,
      "currency_id": "460000000000097",
      "currency_code": "USD",
      "total": 12400,
      "sub_total": 11800,
      "bcy_total": 400,
      "created_time": "2014-07-28T08:29:07+0530",
      "last_modified_time": "2014-08-25T11:23:26+0530",
      "is_emailed": false,
      "has_attachment": false
    }
  ],
  "page_context": { "page": 1, "per_page": 200, "has_more_page": false }
}

reference_number is the field to park an upstream marketplace order id in, and it is filterable, which makes it the natural idempotency key.

Order items

Line items come with the single-order read, GET /salesorders/{salesorder_id}, under line_items. The same structure appears on invoices and credit notes. Fields verified from the spec:

{
  "salesorder": {
    "salesorder_number": "SO-00001",
    "customer_id": "460000000017138",
    "place_of_supply": "TN",
    "gst_no": "22AAAAA0000A1Z5",
    "gst_treatment": "business_gst",
    "is_inclusive_tax": false,
    "discount_type": "entity_level",
    "shipping_charge": 2,
    "adjustment": 0.2,
    "delivery_method": "Air",
    "location_id": "460000000038080",
    "location_name": "Chennai",
    "sub_statuses": [
      {
        "status_id": "460000000000097",
        "status_code": "cs_openshi",
        "display_name": "Open Shipped",
        "color_code": "208eff"
      }
    ],
    "line_items": [
      {
        "line_item_id": "460000000039131",
        "sku": null,
        "bcy_rate": 120,
        "tax_id": "460000000017094",
        "tax_percentage": null,
        "is_taxable": false,
        "item_total_inclusive_of_tax": 48,
        "product_type": "goods",
        "hsn_or_sac": 80540,
        "is_invoiced": false,
        "stock_on_hand": null,
        "location_id": "460000000038080",
        "line_item_category": "line_item"
      }
    ]
  }
}

Two useful details. is_invoiced on the line tells you whether that line has been billed, which is how partial fulfilment is represented. sub_statuses is a user-defined status layer on top of status, so a seller's own workflow states arrive as status_code values you will not recognise.

Products and listings

GET /items and GET /items/{item_id} in Books; GET /items on the Inventory base path gives the same object with the stock and barcode fields filled in. GET /itemdetails does a bulk fetch. Zoho also models grouped products: /itemmasters for the parent and /itemvariants for variants, with POST /itemvariants/ungroup to flatten them.

{
  "code": 0,
  "message": "success",
  "item": {
    "item_id": 45667789900,
    "name": "Hard Drive",
    "status": "active",
    "description": "500GB",
    "rate": 120,
    "unit": "100GB",
    "sku": "s12345",
    "product_type": "goods",
    "hsn_or_sac": "123456",
    "tax_id": 982000000037049,
    "tax_name": "Sales Tax",
    "tax_percentage": "70%",
    "item_tax_preferences": [
      { "tax_id": 982000000037049, "tax_specification": "intra" }
    ],
    "locations": [
      {
        "location_id": "460000000038080",
        "location_name": "",
        "status": "active",
        "is_primary": false,
        "location_stock_on_hand": "",
        "location_available_stock": "",
        "location_actual_available_stock": ""
      }
    ]
  }
}

The Zoho Inventory item read adds the identifiers a commerce database wants: upc, ean, isbn, part_number, purchase_rate, pricebook_rate, reorder_level, vendor_name, unit_group_name, attribute_option_name1 for variant axes, and image_id / image_name.

There is no listing object. Zoho Books has no concept of a channel listing with a marketplace id, a URL or a channel status. Price books (/pricelists) give per-customer or per-currency price overrides, which is the closest analogue to a channel-specific price.

Inventory

Stock is a property of the item, not a separate resource. The verified path is locations[] on the item, carrying location_stock_on_hand, location_available_stock and location_actual_available_stock per location. available_stock is stock less committed quantity; actual_available_stock is the physically countable figure. reorder_level is on the item.

For movements rather than balances, Zoho Inventory exposes GET /inventoryadjustments, and the warehouse modules /moveorders (transfers between locations), /inventorycounting (stock counts), /putaways, /picklists and /batches.

Shipments and tracking

Books itself has delivery challans (/delivery-challans) and can mark invoices shipped (POST /invoices/markasshipped), but real shipment objects are in Zoho Inventory: /packages (a package created from a sales order) and shipment orders attached to them, plus /salesreturns and /salesreturnreceives. Package list and retrieve are GET /packages and GET /packages/{package_id} on the Inventory base path. Carrier, tracking number and delivery status live on the shipment order attached to a package rather than on the sales order.

If the seller is on Books only, there is no shipment object and tracking must come from the courier connector.

Returns and cancellations

Returns are credit notes. GET /creditnotes, GET /creditnotes/{creditnote_id}.

{
  "code": 0,
  "message": "success",
  "creditnotes": [
    {
      "creditnote_id": "90300000072369",
      "creditnote_number": "CN-29",
      "status": "draft",
      "reference_number": "INV-384",
      "date": "2016-06-05",
      "total": 450,
      "balance": 10,
      "customer_id": "903000000000099",
      "customer_name": "Bowman Furniture",
      "currency_code": "USD",
      "location_id": "460000000038080",
      "created_time": "2016-06-05T02:30:08-0700",
      "last_modified_time": "2016-06-05T02:30:08-0700"
    }
  ]
}

GET /creditnotes/{creditnote_id}/invoices lists which invoices a credit note was applied against, and GET /invoices/{invoice_id}/creditsapplied does the same from the invoice side. Refunds against a credit note are GET /creditnotes/{creditnote_id}/refunds.

Cancellation is a status transition, not a delete: POST /invoices/{invoice_id}/status/void and POST /salesorders/{salesorder_id}/status/void. Voided documents stay readable with status of void, which matters because deleting is also possible and destroys the record.

Zoho Inventory adds /salesreturns for physical goods coming back, which is a different object from the financial credit note.

Payments and settlements

GET /customerpayments and GET /customerpayments/{payment_id}.

{
  "code": 0,
  "message": "success",
  "customer_payments": [
    {
      "payment_id": "9030000079467",
      "payment_number": "2",
      "invoice_number": "INV-384",
      "date": "2016-06-05",
      "payment_mode": "cash",
      "amount": 450,
      "bcy_amount": 450,
      "location_id": "460000000038080",
      "location_name": null
    }
  ]
}

GET /invoices/{invoice_id}/payments gives the payments applied to one invoice, which is the join you actually want. Refunds of an over-payment are GET /customerpayments/{customer_payment_id}/refunds.

There is no marketplace settlement object. Bank feeds are available (/bank-accounts, /bank-transactions, /bank-rules) and are where a marketplace payout would show up as an uncategorised bank credit.

Customers

GET /contacts and GET /contacts/{contact_id}. contact_type separates customer from vendor.

{
  "code": 0,
  "message": "success",
  "contacts": [
    {
      "contact_id": 460000000026049,
      "contact_name": "Bowman and Co",
      "company_name": "Bowman and Co",
      "contact_type": "customer",
      "status": "active",
      "payment_terms": 15,
      "payment_terms_label": "Net 15",
      "currency_code": "USD",
      "outstanding_receivable_amount": 250,
      "unused_credits_receivable_amount": 1369.66,
      "first_name": "Will",
      "last_name": "Smith",
      "email": "willsmith@bowmanfurniture.com",
      "phone": "+1-925-921-9201",
      "mobile": "+1-4054439562",
      "created_time": "2013-08-05T12:06:10+0530",
      "last_modified_time": "2013-10-07T18:24:51+0530"
    }
  ],
  "page_context": { "page": 1, "per_page": 10, "has_more_page": false }
}

Names, emails, phones and addresses are PII and are returned unmasked. Addresses are separate: GET /contacts/{contact_id}/address for additional addresses, plus billing_address and shipping_address on the single-contact read. Contact persons, the named humans at a customer, are /contact-persons.

Locations

GET /locations, with POST /settings/locations/enable needed first on organisations that have not switched the feature on. Locations carry location_id, location_name, status and an address, and location_id then appears on sales orders, invoices, payments, items and inventory adjustments. POST /locations/{location_id}/markasprimary sets the default.

Writing back: sales orders, invoices and stock adjustments

All three are ordinary POSTs, synchronous, returning the created object with its id.

Sales orders

POST /salesorders?organization_id=.... The body needs customer_id and line_items, each with item_id (or a free-text name and rate for a non-catalogue line), quantity and rate. Useful additions: reference_number to carry your order id, salesorder_number (with ignore_auto_number_generation=true if you want to set it yourself), date, shipment_date, location_id, place_of_supply and gst_treatment for Indian organisations, and custom_fields.

Lifecycle: a sales order is created in draft unless the organisation is configured otherwise. POST /salesorders/{salesorder_id}/status/open opens it, POST /salesorders/{salesorder_id}/status/void voids it, and POST /salesorders/{salesorder_id}/submit then /approve runs it through an approval chain if one is configured. POST /salesorders/{salesorder_id}/substatus/{status_code} sets the seller's own sub-status.

An unusual and genuinely useful capability: PUT /salesorders (no id in the path) updates a sales order located by a custom field's unique value. If you put your channel order id in a custom field marked unique, you get upsert-by-external-id without maintaining your own id map.

Invoices

POST /invoices?organization_id=..., with query flags send=true to email it on creation and ignore_auto_number_generation=true to supply your own invoice_number.

From a sales order there are two shortcuts rather than rebuilding the body: POST /invoices/fromsalesorder creates an invoice from one or more sales orders, and POST /invoices/mapwithorder associates an already-created invoice with a sales order. PUT /invoices/unmap/salesorders reverses the association.

Status transitions are all POSTs: /status/sent, /status/void, /status/draft, /status/cancel, plus /writeoff and /writeoff/cancel, /approve, /reject, /submit. Bulk variants exist for several of these (POST /invoices/approve, POST /invoices/status/void, POST /invoices/status/sent, POST /invoices/submit, POST /invoices/writeoff, POST /invoices/markasshipped).

Payments are applied with POST /invoices/{invoice_id}/credits for credit notes, and by creating a POST /customerpayments with an invoices array naming invoice_id and amount_applied for money.

Line-level edits are possible: DELETE /invoices/{invoice_id}/lineitems/{line_item_id}. Addresses can be patched without touching the rest of the document: PUT /invoices/{invoice_id}/address/shipping.

Stock adjustments

This is the Zoho Inventory API, not Books. POST https://www.zohoapis.<dc>/inventory/v1/inventoryadjustments?organization_id=....

adjustment_type is quantity or value. A quantity adjustment moves stock; a value adjustment restates the money without moving units.

{
  "date": "2026-09-22",
  "reason": "Damaged goods",
  "description": "Marketplace return written off",
  "reference_number": "REF-IA-00001",
  "adjustment_type": "quantity",
  "location_id": "460000000038080",
  "line_items": [
    {
      "item_id": 4815000000044100,
      "name": "Laptop-white/15inch/dell",
      "quantity_adjusted": 10,
      "unit": "qty",
      "adjustment_account_id": 4815000000000388,
      "adjustment_account_name": "Cost of Goods Sold",
      "location_id": "460000000038080",
      "serial_numbers": ["ARMP-0078"],
      "batches": [
        {
          "batch_number": "BTC-TL-089",
          "manufacturer_date": "2026-05-12",
          "expiry_date": "2026-12-16",
          "in_quantity": 2,
          "out_quantity": 2
        }
      ]
    }
  ]
}

The response echoes the created adjustment with inventory_adjustment_id, reason_id, location_name, total and per-line line_item_id.

Warning

quantity_adjusted is a delta, not a target. Zoho Inventory has no endpoint that sets stock to an absolute number through the adjustment route. A connector that receives "stock is now 42" must read location_actual_available_stock, compute the difference, and post that. Read and write are therefore not atomic, and two adjustments racing on the same item will both apply. Serialise stock writes per item.

An adjustment can require approval depending on organisation settings, in which case it is created in a pending state and needs POST /inventoryadjustments/{id}/submit then /approve, with /approve/final for a second approval level and /reject to refuse it. Check the returned status rather than assuming stock moved.

Other write paths for stock: /moveorders to transfer between locations, /inventorycounting for a counted recount, and setting opening stock on an item at creation time.

Listings, price and catalogue

POST /items creates an item, PUT /items/{item_id} updates it, PUT /items updates by a unique custom field value, and POST /items/{item_id}/active / /inactive toggle its status. Price changes are just a PUT of rate. There is no marketplace listing to activate or deactivate, so "listing status" maps onto item status and nothing more.

Idempotency and confirmation

There is no idempotency key header. The mechanisms available are: set reference_number and search for it before creating; or put the external id in a unique custom field and use the update-by-custom-field endpoints, which turns a create-or-update into one call. Responses are synchronous, so confirmation is the HTTP status plus the code field in the body, where 0 means success.

Batch sizes: salesorder_ids and equivalent id lists accept up to 200 comma-separated values on read. No published maximum for line items per document was found; treat the 100-requests-per-minute cap as the real constraint on write volume.

Webhooks and notifications

Zoho Books has webhooks, but they are an automation feature rather than a developer-API subscription. They are configured per organisation, in the UI, at Settings then Workflow Actions then Webhooks, and attached to workflow rules that decide when they fire.

What is configurable:

  • URL and event. The target endpoint, and which module event triggers it. The documentation names sales orders, invoices and contacts as examples; it does not publish an exhaustive module and event list, and the events follow the workflow rule model (created, edited, deleted, field changed, status changed).
  • HTTP method. POST by default, with PUT and DELETE available.
  • Body format. A default JSON body, x-www-form-urlencoded, or raw.
  • Custom parameters and HTTP headers. Arbitrary key-value pairs, which is where an API key for your receiver goes.
  • Secret token. 12 to 50 alphanumeric characters, write-once: it cannot be viewed or edited after creation. This is the verification mechanism. There is no published HMAC signature scheme of the kind Shopify or Stripe use, so verification means checking the shared secret your receiver was configured with.
  • Authentication. Either a general basic or API authorization, or a preconfigured Zoho Connection.

Delivery behaviour, as documented: a 5-second connection timeout and a 10-second read timeout, with 5 automatic retries by default and up to 20 with a custom retry policy using fixed, additive or multiplicative delays. The number of webhooks that can fire per day varies by Zoho Books plan and is not published as a single number.

The consequence for a connector. Because webhooks are per organisation and configured by hand, you cannot programmatically subscribe a new seller on install. Either you give the seller setup instructions, or you poll. Polling is the honest default:

  • GET /invoices?last_modified_time=<iso> and the same on /salesorders, /creditnotes, /customerpayments, /contacts, /items, every 5 to 15 minutes.
  • Because the daily quota is per organisation and can be as low as 1,000 calls, a 5-minute poll across six modules costs 1,728 calls per day before pagination, which already exceeds the Free and Standard plans. Budget the cadence against the seller's plan, and prefer one poll every 15 minutes across modules (576 calls per day) as a safe default.

Zoho Books also has incoming webhooks, which are the opposite direction: a URL Zoho gives you, which a third-party service calls, triggering a Deluge function inside Zoho Books. Authentication is either an OAuth URL or a ZAPI key URL. That is an automation tool, not a connector interface, but it is worth knowing it exists so the two are not confused.

Rate limits and pagination

Published limits, as of September 2026:

All breaches return HTTP 429 with a distinguishing body code:

  • code: 45 with a message naming the plan's maximum call rate: the daily quota is exhausted.
  • code: 44: the per-minute cap for the organisation, or for the account when hit through the web interface.
  • code: 1070: too many in-process requests, meaning the concurrency cap.

No quota headers are documented, so a client cannot see remaining budget and has to count locally. Treat code: 45 as fatal for the day and back off to the next UTC-offset day boundary rather than retrying; treat code: 44 and 1070 as retryable with backoff.

Pagination: page and per_page, default 200 items per page, with page_context.has_more_page as the loop condition. No cursor.

A cadence that fits a Standard plan (2,000 calls per day): a 15-minute incremental poll of six modules using last_modified_time costs about 576 calls, leaving headroom for writes and for the occasional full re-read. On the Free plan, drop to hourly.

Mapping to the unified model

Gaps and open questions

  • No sandbox. No dedicated sandbox environment appeared on the pages read. Development against a free-plan organisation works but shares the 1,000-call daily quota, which is easy to exhaust while iterating.
  • The exact token response JSON. Zoho's docs describe the token response in prose and name access_token, refresh_token, api_domain and token_type, but the Books OAuth page did not render a verbatim response body for the fetcher. Treat the field names as high confidence and the exact shape as worth confirming on first run.
  • Webhook module and event catalogue. The help page does not enumerate which modules and which events can trigger a webhook. Sales orders, invoices and contacts are named as examples. Before designing around webhooks, confirm in a live organisation that the specific event you need exists.
  • Webhook verification. A write-once secret token is the documented mechanism. No signature header scheme was found, which means verification is weaker than on platforms with HMAC-signed bodies. Terminate webhook receivers on a secret-bearing path and do not trust the body without a follow-up read.
  • Books versus Inventory feature split. Which endpoints are available depends on whether the organisation has Zoho Inventory enabled and on its plan. Packages, shipment orders, move orders, putaways, picklists and batches are Inventory features. A connector must detect capability rather than assume it, and the cleanest probe is calling the Inventory endpoint and handling the error.
  • Item-level aggregate stock. Per-location stock is verified from the specification. Whether an aggregate stock_on_hand also appears at item level on every plan was not confirmed from the specification examples read, which showed it only inside locations[] and as a nullable field on sales order lines.
  • Daily webhook limits by plan. Documented as varying by plan, with no numbers published.
  • Purchase-side objects (bills, purchase orders, vendor credits, purchase receives) were not read in detail here. They exist and follow the same conventions, and matter if inbound stock needs to be tracked.

Sources

  • Zoho Books API v3 introduction: data centre base URLs, organization_id, call limits, the 429 body codes 45, 44 and 1070
  • Zoho Books API v3 OAuth: authorization and token endpoints, scopes, self client, token validity
  • Zoho Books API v3 pagination: 200 items per page default, page, per_page, page_context
  • Zoho Accounts multi-DC OAuth: accounts server list, location and accounts-server parameters, api_domain, refresh token binding
  • Zoho Accounts OAuth for web apps: authorization request parameters, two-minute single-use code
  • Zoho Books OpenAPI 3.0 documents, downloaded from the developer portal and read directly: invoices/invoices.yml, sales-order/sales-order.yml, items/items.yml, contacts/contacts.yml, credit-notes/credit-notes.yml, customer-payments/customer-payments.yml, locations/locations.yml, organizations/organizations.yml. Every endpoint, filter and sample JSON above is taken from these
  • Zoho Inventory OpenAPI 3.0 documents, downloaded and read: inventoryadjustments/inventoryadjustments.yml, items/items.yml, packages/packages.yml
  • Zoho Inventory API v1 introduction: module list and base path
  • Webhooks in Zoho Books: configuration, methods, body formats, secret token, timeouts and retry policy
  • Incoming webhooks in Zoho Books: the inbound Deluge trigger mechanism