Guide to Zoho Sign Webhooks: Features and Best Practices
Zoho Sign webhooks tell your systems what is happening to a signature request: it is sent, viewed, signed by a recipient, completed, declined, forwarded, recalled or expired. If you send documents for signature with Zoho Sign, Zoho's e-signature product, webhooks are how those updates reach your CRM, HR or document systems without polling the API.
This guide covers how Zoho Sign webhooks are registered and secured, how to read the operation types and payloads, what Zoho leaves undocumented, and the practices that keep a Zoho Sign receiver reliable.
What are Zoho Sign webhooks?
A Zoho Sign webhook is a callback URL that a Zoho Sign admin registers for a set of sign request events. When one of those events happens, Zoho Sign sends a JSON payload describing the action and the request it belongs to. Webhooks are available on the Enterprise, API, Zoho One and People Plus plans, and an account can have at most two.
Signing is opt-in. When you create a webhook, you can tick an HMAC signature checkbox and enter a secret key. Zoho Sign then adds an X-ZS-WEBHOOK-SIGNATURE header to each request: a base64 HMAC-SHA256 of the payload, keyed with your secret. Without the checkbox, requests arrive unsigned.
Zoho's documentation describes the signature as base64encode(HMAC SHA-256(payload+secret_. That label is misleading. The secret is the HMAC key, not something appended to the payload, and Zoho's own Java sample and worked example both compute a plain HMAC of the payload.
Zoho Sign webhook features
| Feature | Details |
|---|---|
| Configuration | Settings > Developer Settings > Webhooks, by a Zoho Sign admin |
| Plans | Enterprise, API, Zoho One and People Plus |
| Limit | Two webhooks per account |
| Event identity | notifications.operation_ in the body |
| Signature header | X-ZS-WEBHOOK-SIGNATURE, only when HMAC is enabled |
| Scheme | HMAC-SHA256, base64 |
| Signed content | The raw payload |
| Secret | Entered by you or generated by Zoho. Can't be retrieved afterwards |
| Replay window | None. No timestamp is sent or signed |
| Event ID | None |
| Response, timeout, retries | Not documented |
Zoho publishes no source IP list for Zoho Sign webhooks.
Common events
When you create a webhook, you choose from nine events in the UI. In the payload, the event appears as notifications.operation_, one of eight constants:
operation_type | UI event | Fires when |
|---|---|---|
RequestSubmitted | Sent | A document is submitted for signature |
RequestViewed | Viewed | A recipient views a submitted document |
RequestSigningSuccess | Signed by a recipient | One signer completes signing |
RequestCompleted | Completed by all | All signers or approvers finish |
RequestRejected | Declined | A recipient declines the request |
RequestForwarded | Reassigned | A signer forwards the request to someone else |
RequestRecalled | Recalled | The sender recalls the request |
RequestExpired | Expires | The request passes its signing deadline |
Zoho doesn't publish a mapping between the UI labels and the operation types. The pairs above follow their descriptions. The ninth UI event, Approved, has no operation type of its own in Zoho's list. Zoho describes "Signed by a recipient" as covering documents that are "signed or approved", so approvals may arrive as RequestSigningSuccess, but this isn't documented. Capture one before you rely on it.
Every payload has two objects. notifications describes the action, and requests describes the signature request. Here is Zoho's sample:
{
"notifications": {
"performed_by_email": "testuser@zoho.com",
"performed_at": 1555062604837,
"reason": "reason given if any",
"activity": "Document has been signed",
"operation_type": "RequestSigningSuccess",
"action_id": "1000000000090",
"performed_by_name": "test user",
"ip_address": "192.168.100.100"
},
"requests": {
"request_name": "NDA Document",
"request_id": "1000000000000",
"org_id": "9876543210",
"request_type_id": "10000000011",
"document_ids": [
{ "document_name": "CommonNDA.pdf", "document_id": "100000000000050" }
]
}
}
performed_at is in milliseconds since the epoch. action_id identifies the signer's action, and Zoho includes it for the signer-related types: RequestViewed, RequestSigningSuccess, RequestRejected and RequestForwarded. The documented structure also includes requests.request_status.
Zoho warns that any attribute not in its documented structure "may be removed anytime in future". The sample's org_id and request_type_id are not in that structure, so don't depend on them.
Setting up Zoho Sign webhooks
- As a Zoho Sign admin, go to Settings > Developer Settings > Webhooks and click Create webhook.
- Enter a name and your HTTPS callback URL, and select the events.
- Tick the HMAC signature checkbox and enter a secret key, or click Generate to create one.
- Copy the secret before you save, and store it server-side, for example as
ZOHO_SIGN_WEBHOOK_SECRET. Zoho Sign won't show it again. - Complete the registration, then send a test document to trigger events.
If you use Hookdeck, create a Zoho Sign source, set the same secret as its Webhook Signing Secret, and use the source URL as the callback URL. Verification on the source is optional: if you didn't enable HMAC on the Zoho Sign webhook, leave it off, because unsigned requests can never pass it.
Securing Zoho Sign webhooks
Read the body as raw bytes. Zoho specifically says to read the payload as a string, because parsing it as JSON can reorder keys. Compute an HMAC-SHA256 with your secret as the key, base64-encode the digest, and compare it with X-ZS-WEBHOOK-SIGNATURE in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyZohoSignSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
// The secret is the HMAC key. It is not appended to the payload.
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
const received = Buffer.from(signatureHeader.trim(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/zoho-sign', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.ZOHO_SIGN_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyZohoSignSignature(req.body, req.get('x-zs-webhook-signature'), secret)) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString('utf8'));
// Route on payload.notifications.operation_type. Persist, then process async.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
def verify_zoho_sign_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest)
return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)
# In FastAPI: raw_body = await request.body()
# verify_zoho_sign_signature(raw_body, request.headers.get("x-zs-webhook-signature"),
# os.environ["ZOHO_SIGN_WEBHOOK_SECRET"])
Zoho's worked example makes a good test vector, with one oddity. Its sample payload begins with two opening braces, so it isn't valid JSON, but the published signature is computed over exactly that string. With the secret thisisthesamplekeyfortestingpurposes and this exact payload:
{{"requests":{"request_name":"Test Name"},"notifications":{"operation_type":"RequestSigningSuccess"}}
the signature is:
drbSrM4H816RYKpZiRBLddUa0yHaTrwjtY04sIZFZus=
Remove the extra brace, or append the secret to the payload as the label suggests, and you get a different value.
Make Zoho Sign webhooks production-ready. Hookdeck Event Gateway verifies the
X-ZS-WEBHOOK-SIGNATUREheader, answers Zoho Sign straight away, and durably queues every sign request event before your service sees it.
Zoho Sign webhook limitations and pain points
Signing is off unless you tick the box
The Problem: Requests arrive without X-ZS-WEBHOOK-SIGNATURE, and anyone who learns the URL can post to it.
Why It Happens: HMAC is an opt-in checkbox on each webhook.
Workarounds:
- Enable HMAC on every webhook, and reject unsigned requests.
- Keep the callback URL private and on HTTPS.
How Hookdeck Can Help: Hookdeck's Zoho Sign source verifies the header for you once the secret is set on both sides, and records unsigned or mis-signed requests as failed verification instead of delivering them.
A secret you can't see again
The Problem: A new service needs the secret, and nobody saved it.
Why It Happens: Once you save the webhook, Zoho Sign never shows the secret key again.
Workarounds:
- Copy the key into your secret manager before you save the webhook.
- If it's lost, replace the key on the webhook, or recreate the webhook, and update every verifier at the same time.
How Hookdeck Can Help: With Hookdeck in front, the secret lives in one place, your Hookdeck source, and your services receive events that have already been verified.
Misleading signature documentation
The Problem: A verifier built from the documented formula never matches.
Why It Happens: The docs label the signature as an HMAC of payload+secret_key. Taken literally, that appends the secret to the payload, which is wrong. The worked example's payload also has a stray brace, so it fails if you tidy it up.
Workarounds:
- Use the secret as the HMAC key over the raw payload, as Zoho's Java sample does.
- Test against the worked example exactly as published.
How Hookdeck Can Help: Hookdeck's Zoho Sign source implements the scheme as Zoho actually computes it.
No delivery guarantees, no event ID
The Problem: You don't know whether a failed delivery is retried, and you can't spot duplicates reliably.
Why It Happens: Zoho documents no expected response, timeout or retry policy, and payloads carry no event ID or timestamp header.
Workarounds:
- Build an idempotency key from
requests.request_id,notifications.operation_,type notifications.action_id(where present) andnotifications.performed_.at - Reconcile open requests through the Zoho Sign API, especially after an outage.
How Hookdeck Can Help: Hookdeck accepts each event as soon as Zoho Sign sends it and stores it, retries delivery to your service on a schedule you control, and its deduplication can drop repeats based on the fields you choose.
Best practices
Verify against the raw body
The HMAC covers the exact bytes Zoho Sign sent. In Express, use express.raw({ type: '*/*' }) and parse after verification. In Next.js, read await request.text(). In FastAPI, read await request.body().
Route on operation_type
Switch on notifications.operation_, and log and acknowledge any value you don't recognise, including whatever the Approved event turns out to send.
Use only documented fields
Zoho reserves the right to remove undocumented attributes. Stick to the fields in its documented structure, and fetch anything else from the API.
Plan around the two-webhook limit
With two webhooks per account, you can't give every service its own. Point one webhook at a single receiver, or a Hookdeck source, and fan events out from there.
Acknowledge fast and process asynchronously
Zoho doesn't publish a timeout, so keep the request path short: verify, store the event, return 200. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Zoho Sign sends a notifications and requests payload for each sign request event, and signs it only if you enable HMAC on the webhook. Tick the checkbox and save the secret before you leave the page, verify the base64 HMAC-SHA256 in X-ZS-WEBHOOK-SIGNATURE with the secret as the key, route on operation_type, build your own idempotency key, and reconcile through the API, since Zoho documents no retry policy.
Hookdeck Event Gateway sits in front of your receiver, checks the Zoho Sign signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Zoho Sign webhooks reliably in minutes.