Gareth Wilson Gareth Wilson

Guide to Zoho Sign Webhooks: Features and Best Practices

Published


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_key)). 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

FeatureDetails
ConfigurationSettings > Developer Settings > Webhooks, by a Zoho Sign admin
PlansEnterprise, API, Zoho One and People Plus
LimitTwo webhooks per account
Event identitynotifications.operation_type in the body
Signature headerX-ZS-WEBHOOK-SIGNATURE, only when HMAC is enabled
SchemeHMAC-SHA256, base64
Signed contentThe raw payload
SecretEntered by you or generated by Zoho. Can't be retrieved afterwards
Replay windowNone. No timestamp is sent or signed
Event IDNone
Response, timeout, retriesNot 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_type, one of eight constants:

operation_typeUI eventFires when
RequestSubmittedSentA document is submitted for signature
RequestViewedViewedA recipient views a submitted document
RequestSigningSuccessSigned by a recipientOne signer completes signing
RequestCompletedCompleted by allAll signers or approvers finish
RequestRejectedDeclinedA recipient declines the request
RequestForwardedReassignedA signer forwards the request to someone else
RequestRecalledRecalledThe sender recalls the request
RequestExpiredExpiresThe 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

  1. As a Zoho Sign admin, go to Settings > Developer Settings > Webhooks and click Create webhook.
  2. Enter a name and your HTTPS callback URL, and select the events.
  3. Tick the HMAC signature checkbox and enter a secret key, or click Generate to create one.
  4. 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.
  5. 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-SIGNATURE header, 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) and notifications.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_type, 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.