---
title: "WordPress Zoho CRM Integration: OAuth, Upsert, Limits"
description: "A WordPress Zoho CRM integration on the v8 API: Self Client OAuth, the refresh-token cap, per-datacentre hosts, and the upsert that never duplicates a lead."
url: "https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/"
date: "2026-09-05"
---

# WordPress Zoho CRM Integration: OAuth, Upsert, Limits

**TL;DR:** A WordPress Zoho CRM integration is one OAuth exchange you do by hand, one refresh token you guard, and one upsert call that never creates a duplicate.

-   Use a **Self Client** in the Zoho API Console. The grant code it issues lives 3 to 10 minutes; exchange it once and keep the **refresh token**, which never expires until revoked.
-   Access tokens last **one hour**, and you may mint at most **10 per refresh token in 10 minutes**. Refreshing on every request breaks after ten requests.
-   Call the `api_domain` the token response gives you. EU, India, Australia, Japan, China and Canada accounts live on different hosts.
-   Write with `/crm/v8/Leads/upsert`. It checks `Email` by default and tells you whether it inserted or updated.
-   Every ten records cost **one API credit**. The Free edition has 5,000 a day.

/ Official

## Is there an official Zoho CRM WordPress plugin?

Yes, and it covers exactly one case well. Zoho publishes the [Zoho CRM Lead Magnet](https://wordpress.org/plugins/zoho-crm-forms/) plugin on WordPress.org. It embeds Zoho CRM webforms, or maps a Contact Form 7 form, and captures the submission straight into the Leads module. If all you need is a contact form that produces leads, install it and stop reading.

It watches forms and nothing else. A WooCommerce order, a membership renewal, a user registration, a custom post type going live, a support ticket closing: none of those are a form, and the plugin has no way to see them. For any event that is not a form submission, you are talking to the Zoho CRM REST API directly, and that means dealing with Zoho's OAuth model, which is where most integrations go wrong before they send a single record.

/ Auth

## How do you authenticate a WordPress site to Zoho CRM?

With OAuth 2.0 through a **Self Client**, which is Zoho's name for the server-to-server case where you are both the developer and the account owner.¹ There is no browser redirect. In the Zoho API Console you create a Self Client, open its _Generate Code_ tab, enter the scope you need, pick a duration between 3 and 10 minutes, and Zoho displays a grant code. You then exchange that code, once, from your server:

PHP — the one-time exchange, run from WP-CLI or an admin action

```
$res = wp_remote_post( 'https://accounts.zoho.com/oauth/v2/token', [
    'timeout' => 15,
    'body'    => [
        'grant_type'    => 'authorization_code',
        'client_id'     => $client_id,
        'client_secret' => $client_secret,
        'code'          => $grant_code,   // valid for 3-10 minutes, single use
    ],
] );

$body = json_decode( wp_remote_retrieve_body( $res ), true );

// Keep all three. The refresh token is the one you cannot get again.
$access_token  = $body['access_token'];   // expires_in: 3600
$refresh_token = $body['refresh_token'];
$api_domain    = $body['api_domain'];     // e.g. https://www.zohoapis.eu
```

The response is the important half. It carries an `access_token` that expires in `3600` seconds, a `refresh_token` that Zoho's [token validity page](https://www.zoho.com/crm/developer/docs/api/v8/token-validity.html) says does not expire until a user revokes it, and an `api_domain`. Store the refresh token encrypted. Losing it means generating a new grant code by hand, and each Self Client is capped at 20 stored refresh tokens per user, so an integration that mints a fresh one on every deploy eventually gets its oldest revoked.

From then on the site never touches the grant flow again. Every API call carries `Authorization: Zoho-oauthtoken {access_token}`, and when the hour is up you [refresh](https://www.zoho.com/crm/developer/docs/api/v8/refresh.html) with a POST to the same token endpoint using `grant_type=refresh_token`.

/ Refresh

## Why does refreshing the token on every request break the integration?

Because Zoho caps it: you can generate at most **10 access tokens from one refresh token in 10 minutes**. The eleventh attempt is answered with _Access Denied_ and the message "You have made too many requests continuously". A worker that refreshes before every call works in testing, where nothing fires ten times in ten minutes, and fails on the first busy morning in production.

Cache the access token for its lifetime and refresh only when it is about to expire or when a call comes back with `INVALID_TOKEN`. A transient with a 55-minute expiry is enough on a single server; on multi-server hosting the cache has to be shared, or each node mints its own token and the ten-per-ten-minutes cap is spent by the fleet rather than by you.

PHP — token cache with refresh-on-miss

```
function zoho_access_token() {
    $cached = get_transient( 'zoho_crm_access_token' );
    if ( $cached ) {
        return $cached;
    }

    // {accounts-server} is the host the grant response named. Never hard-code .com.
    $res = wp_remote_post( ZOHO_ACCOUNTS . '/oauth/v2/token', [
        'timeout' => 15,
        'body'    => [
            'grant_type'    => 'refresh_token',
            'refresh_token' => zoho_stored_refresh_token(),
            'client_id'     => ZOHO_CLIENT_ID,
            'client_secret' => ZOHO_CLIENT_SECRET,
        ],
    ] );

    $body = json_decode( wp_remote_retrieve_body( $res ), true );
    if ( empty( $body['access_token'] ) ) {
        return new WP_Error( 'zoho_refresh', $body['error'] ?? 'no token' );
    }

    // 3600s lifetime; cache for 55 minutes so a call never starts on a dead token.
    set_transient( 'zoho_crm_access_token', $body['access_token'], 55 * MINUTE_IN_SECONDS );
    return $body['access_token'];
}
```

FIG 01 — One grant, one refresh token, and an upsert that says whether it inserted or updated

/ Datacentre

## Which Zoho host should the site call?

The one the token response names, and only that one. Zoho runs separate datacentres and a customer's account lives in exactly one of them. The accounts host and the API host differ per region:²

| Datacentre | Accounts host | API host |
| --- | --- | --- |
| US | accounts.zoho.com | www.zohoapis.com |
| EU | accounts.zoho.eu | www.zohoapis.eu |
| India | accounts.zoho.in | www.zohoapis.in |
| Australia | accounts.zoho.com.au | www.zohoapis.com.au |
| Japan | accounts.zoho.jp | www.zohoapis.jp |
| China | accounts.zoho.com.cn | www.zohoapis.com.cn |
| Canada | zohocloud.ca | www.zohoapis.ca |

Hard-coding `www.zohoapis.com` is the classic first-integration bug: it works for a US developer's trial account and returns `INVALID_TOKEN` for the European client the integration was built for, because the token was issued by `accounts.zoho.eu` and the US API host has never heard of it. Read `api_domain` from the token response, store it next to the refresh token, and build every URL from it.

/ Records

## Which endpoint creates the lead, and how do you avoid duplicates?

[Upsert](https://www.zoho.com/crm/developer/docs/api/v8/upsert-records.html), not insert. `POST {api_domain}/crm/v8/Leads/upsert` takes the same `data` array as the plain [insert endpoint](https://www.zoho.com/crm/developer/docs/api/v8/insert-records.html), checks each record against `duplicate_check_fields`, and updates the match instead of creating a second one. For Leads and Contacts the default check field is `Email`, so a form that submits twice, or a queue that redelivers after a timeout, updates the lead it made the first time. The response says which happened: `"action": "insert"` or `"action": "update"`, with `duplicate_field` naming the field that matched.

Field names are the module's API names, not the labels in the CRM interface: `Last_Name`, `Company`, `Email`, `Lead_Source`. `Last_Name` is mandatory on Leads, and a record without it is rejected with `MANDATORY_NOT_FOUND`. A custom field gets an API name generated at creation that may not match what you typed.

PHP — upsert one lead

```
$res = wp_remote_post( $api_domain . '/crm/v8/Leads/upsert', [
    'timeout' => 10,
    'headers' => [
        'Authorization' => 'Zoho-oauthtoken ' . zoho_access_token(),
        'Content-Type'  => 'application/json',
    ],
    'body'    => wp_json_encode( [
        'data' => [ [
            'Last_Name'   => $last_name,      // mandatory on Leads
            'First_Name'  => $first_name,
            'Email'       => $email,          // the default duplicate check field
            'Company'     => $company,
            'Lead_Source' => 'Website',
        ] ],
        'duplicate_check_fields' => [ 'Email' ],
        'trigger' => [ 'workflow' ],   // [] to skip CRM automations for this write
    ] ),
] );

$body   = json_decode( wp_remote_retrieve_body( $res ), true );
$result = $body['data'][0] ?? [];

if ( ( $result['code'] ?? '' ) !== 'SUCCESS' ) {
    // MANDATORY_NOT_FOUND / INVALID_DATA will fail identically on retry.
    // INVALID_TOKEN means refresh once and replay.
}
$action = $result['action'] ?? '';   // 'insert' or 'update'
```

The `trigger` array decides whether the CRM's own workflows, approvals and blueprints run for this write. Leaving it out runs them; an empty array skips them. That is a real decision: a lead-assignment workflow that emails a rep on every new lead is usually wanted, and a workflow that re-syncs the record back to your website is usually a loop.

Per-record results come back inside a `200`. A batch of 100 in which 3 failed still answers `200`, with `MANDATORY_NOT_FOUND` or `INVALID_DATA` on the three. Check `data[].code`, not the HTTP status.

> An upsert keyed on email can be delivered twice with no consequence. An insert cannot. That one choice is what makes every retry in the rest of this article safe. — what the upsert buys you

/ Limits

## What are the Zoho CRM API limits, and what does a lead cost?

Zoho meters in **API credits** per 24 hours, set by the edition, plus a **concurrency** cap on simultaneous calls.³ Both exhaust with a `TOO_MANY_REQUESTS` error.

| Edition | Credits per 24h | Concurrent calls |
| --- | --- | --- |
| Free | 5,000 | 5 |
| Standard | 50,000 + 250 per user licence, max 100,000 | 10 |
| Professional | 50,000 + 500 per user licence, max 3,000,000 | 15 |
| Enterprise | 50,000 + 1,000 per user licence, max 5,000,000 | 20 |
| Ultimate | 50,000 + 2,000 per user licence | 25 |

Insert, update and upsert cost **1 credit for every 10 records** in a call, so a call with one record costs one credit, and a call with 100 records, the maximum, costs 10. Token refreshes are not credits. The arithmetic for a single-record integration is therefore simple:

-   Free: 5,000 credits ÷ 1 = **5,000 single-record upserts a day**.
-   Professional with 5 users: 50,000 + (5 × 500) = 52,500 credits = **52,500 a day**.
-   Enterprise with 20 users: 50,000 + (20 × 1,000) = **70,000 a day**.

Batching changes the picture by an order of magnitude. Sending 100 records per call costs 10 credits for the lot, or 0.1 per record, so the same Free edition covers 5,000 ÷ 10 × 100 = **50,000 records a day**. A WooCommerce store syncing every order status change one at a time can approach the Free cap on a busy day; the same store flushing a batch every minute never gets near it.

The concurrency cap is the one that surprises people. Five simultaneous calls on Free means that a burst of six form submissions in the same second, each handled inline, puts the sixth over the limit even though the daily budget is barely touched. That is a rate problem, not a volume problem, and the fix is the same as in the next section.

![Cyberpunk illustration of a young worker with taped forearm wiring reaching in under the lit head of a stamping press, eyes closed, working the bed by feel.](https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/og_image.jpg)

/ Failure

## Should the Zoho call run inside the WordPress request?

No. Whatever created the record in WordPress is a request with a person waiting at the end of it, and an outbound call inside it makes that person wait for Zoho. With a 10-second timeout, a slow hour at the datacentre becomes a 10-second checkout, PHP-FPM workers pile up, and the concurrency cap above turns any traffic spike into a run of `TOO_MANY_REQUESTS`.

Record the intent when the hook fires, return immediately, and let a background worker own the call. The worker is also the only place the token cache above is safe: a synchronous handler that refreshes on a miss under concurrent load is exactly how ten refreshes land inside ten minutes.

Which failures deserve a retry has a precise answer. `TOO_MANY_REQUESTS`, a `5xx` and a connection timeout are all worth repeating later. `INVALID_TOKEN` deserves one refresh and one replay. `MANDATORY_NOT_FOUND`, `INVALID_DATA` and `OAUTH_SCOPE_MISMATCH` will fail identically forever, and retrying them five times only spends credits you are rationing.

| Concern | Hand-rolled wp\_remote\_post | Webhook Actions |
| --- | --- | --- |
| Zoho connector | None — you write the token cache and the upsert | Also none. It delivers a mapped payload to a URL you name; the OAuth refresh still has to live somewhere you control |
| Delivery timing | Inline — the visitor waits for Zoho | Queued — the request returns before the call runs |
| TOO\_MANY\_REQUESTS / 5xx | One attempt, then the lead is gone | Exponential backoff, 5 attempts by default |
| MANDATORY\_NOT\_FOUND | Retries a doomed payload if you loop naively | Marked failed at once — a bad payload is not retried |
| Evidence of what was sent | Whatever you remembered to error\_log() | Per-attempt log with request, response and replay |
| Refresh token and client secret | Constants in wp-config or a plugin option | Not these — the vault stores a finished header, not the exchange that mints one |

The honest read of that table: the right-hand column is not a Zoho integration you can pick off a shelf. There is no connector catalogue, and the refresh-token dance is still yours to own. What it removes is the queue, the backoff curve and the attempt log, which is the part that takes a week and the part people skip.

The credential row is the one people misread, so be exact about it. The plugin's **Credentials Vault** stores a finished authorization header — a bearer token, basic auth, or a raw value for a header you name — encrypted at rest, referenced from a webhook by id, and injected only at dispatch, where it is also redacted out of the delivery log. _Write-only_ means the secret goes in and only a masked hint comes back. What it does not do is run an OAuth exchange, so Zoho's refresh token and client secret stay with whatever code mints the access token. The vault is the right home for an API that authenticates with a static token instead, [Pipedrive for instance](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/).

try\_it

Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.

[Try the live preview →](https://playground.wordpress.net/?blueprint-url=https://wpwebhooks.org/blueprint.json) [Install plugin](https://downloads.wordpress.org/plugin/flowsystems-webhook-actions.zip)

/ Exposure

## What does Zoho CRM not protect you from?

Three things, and none of them show up in a successful test.

**The refresh token is a permanent credential.** It does not expire, it can be used from anywhere, and it grants everything the scope allowed. Scope it narrowly (`ZohoCRM.modules.leads.CREATE` and `UPDATE` rather than `ZohoCRM.modules.ALL`), store it encrypted, and revoke it from the API Console the day the integration is retired. A refresh token in a theme file that ends up in a public repository is a CRM export waiting to happen.

**A public form is an unauthenticated write path into the CRM.** Anything that turns a submission into a lead has handed the internet a way to create leads, and each one costs a credit from a budget the sales team shares. Spam does not just create junk records; at volume it exhausts the daily allowance and every other integration on the account starts failing. Gate the form before the queue, not after.

**The trigger array can start a loop.** A CRM workflow that pushes lead changes back to your website, combined with an upsert that fires on every change to the WordPress record, is two systems updating each other forever, one credit at a time. Decide which direction is authoritative and pass `"trigger": []` on the writes that should stay silent.

If the destination is a different CRM, the shape is the same and the traps differ: [Salesforce meters calls per org on a rolling day](https://wpwebhooks.org/blog/wordpress-salesforce-integration/), [Pipedrive meters tokens per operation with a burst window](https://wpwebhooks.org/blog/wordpress-pipedrive-integration/), and [HubSpot limits requests per ten seconds](https://wpwebhooks.org/blog/gravity-forms-hubspot-integration/). What they share is the advice above: never make the call where the visitor is waiting.

/Footnotes

¹ Self Client authorisation and the token request, [Zoho CRM API v8 documentation](https://www.zoho.com/crm/developer/docs/api/v8/auth-request.html); token lifetimes and the ten-per-ten-minutes cap from the [token validity page](https://www.zoho.com/crm/developer/docs/api/v8/token-validity.html).

² Multi-datacentre hosts, [Zoho CRM API v8 documentation](https://www.zoho.com/crm/developer/docs/api/v8/multi-dc.html).

³ API credits, per-operation costs and concurrency, [Zoho CRM API limits](https://www.zoho.com/crm/developer/docs/api/v8/api-limits.html). Read 2026-09-10; editions change.

⁴ Scope strings, [Zoho CRM OAuth scopes](https://www.zoho.com/crm/developer/docs/api/v8/scopes.html).

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"WordPress Zoho CRM Integration: OAuth, Upsert, Limits","description":"A WordPress Zoho CRM integration on the v8 API: Self Client OAuth, the refresh-token cap, per-datacentre hosts, and the upsert that never duplicates a lead.","datePublished":"2026-09-05","dateModified":"2026-09-05","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/og_image.jpg","width":1200,"height":630,"caption":"Cyberpunk illustration of a young worker with taped forearm wiring reaching in under the lit head of a stamping press, eyes closed, working the bed by feel."},"keywords":["wordpress zoho crm integration","zoho crm wordpress","wordpress zoho","zoho crm api wordpress","zoho crm wordpress plugin"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Zoho CRM Integration: OAuth, Upsert, Limits","item":"https://wpwebhooks.org/blog/wordpress-zoho-crm-integration/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is there an official Zoho CRM plugin for WordPress?","acceptedAnswer":{"@type":"Answer","text":"Yes. Zoho publishes the Zoho CRM Lead Magnet plugin on WordPress.org, which embeds Zoho webforms or maps a Contact Form 7 form and captures submissions into the Leads module. It handles forms only. A WooCommerce order, a user registration or a custom post type event is not a form, so those need the REST API directly."}},{"@type":"Question","name":"How does a WordPress site authenticate to the Zoho CRM API?","acceptedAnswer":{"@type":"Answer","text":"Through OAuth 2.0 with a Self Client created in the Zoho API Console. The console issues a grant code valid for 3 to 10 minutes, which the site exchanges once at the token endpoint for an access token, a refresh token and the api_domain. Every API call then sends Authorization: Zoho-oauthtoken followed by the access token."}},{"@type":"Question","name":"How long do Zoho CRM access and refresh tokens last?","acceptedAnswer":{"@type":"Answer","text":"An access token is valid for one hour. A refresh token does not expire until a user revokes it, and at most 20 refresh tokens can be stored per user per client. You can generate at most 10 access tokens from one refresh token in 10 minutes, so cache the access token rather than refreshing on every request."}},{"@type":"Question","name":"Which Zoho API domain should my integration call?","acceptedAnswer":{"@type":"Answer","text":"The api_domain returned in the token response. Zoho runs separate datacentres, and an EU account issues tokens from accounts.zoho.eu that only www.zohoapis.eu accepts. Hard-coding www.zohoapis.com works for a US trial account and returns INVALID_TOKEN for a customer in any other region."}},{"@type":"Question","name":"What do Zoho CRM API calls cost against the daily limit?","acceptedAnswer":{"@type":"Answer","text":"Zoho meters API credits per 24 hours by edition: 5,000 on Free, and 50,000 plus a per-user allowance on paid editions. Insert, update and upsert cost one credit for every 10 records in a call, so a single-record upsert costs one credit and a 100-record batch costs ten. Concurrent calls are also capped, from 5 on Free to 25 on Ultimate."}}]}

{"@context":"https://schema.org","@type":"ImageObject","contentUrl":"https://wpwebhooks.org/diagrams/wordpress-zoho-crm-integration.png","caption":"FIG 01 — One grant, one refresh token, and an upsert that says whether it inserted or updated","description":"A self client in the Zoho API console issues a grant code that lives only a few minutes. The site exchanges it once for an access token and a refresh token, and the token response also names the accounts server and the API domain for the customer datacentre, which must be used for every later call. Access tokens expire after an hour, so the worker refreshes on a 401 and replays once. Records are written with the upsert endpoint, which checks the email field by default and answers with an action of insert or update, so a repeated delivery never creates a second lead. Every ten records cost one API credit from a daily allowance set by the CRM edition.","encodingFormat":"image/png","creator":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org/"},"copyrightHolder":{"@type":"Organization","name":"Flow Systems","url":"https://flowsystems.pl/"},"copyrightNotice":"© Flow Systems","creditText":"WP Webhooks","license":"https://creativecommons.org/licenses/by/4.0/","acquireLicensePage":"https://wpwebhooks.org/image-license/"}
```
