On this page
Overview
A webhook is an HTTP POST request that ClientGather sends to a URL you control whenever an event you subscribed to occurs in your organization. Instead of checking ClientGather for changes, your integration reacts the moment a request completes, a file is uploaded, or a reviewer approves an item.
Availability
Webhooks are available on the Growth plan and above. Managing endpoints requires thewebhooks.read / webhooks.write permissions in your organization.Manage endpoints directly in the app:
- In the app — go to Developers → Webhooks to register endpoints, pick event subscriptions, send test pings and rotate secrets — and Developers → Recent logs to inspect deliveries, all without writing any code.
How delivery works
- An event occurs in your organization (for example, a recipient completes a request).
- ClientGather queues one delivery per enabled endpoint whose subscriptions match the event type (or that subscribed to
*). - Each delivery is a signed
POSTwith a JSON envelope to your endpoint URL. - Any
2xxresponse acknowledges the delivery. Anything else — or a timeout — schedules a retry with exponential backoff (see Delivery & retries).
Every delivery wraps the event in the same envelope. The data object is event-specific — see the event catalog for each shape.
{
"id": "evt_9f6f2c9a1a7b4c1e",
"type": "request.completed",
"occurred_at": "2026-07-03T09:41:00.000Z",
"organization_id": "org_123",
"data": {
"requestId": "req_123",
"requestCode": "RQ-2041",
"status": "completed",
"workspaceId": "ws_123",
"totalSubmissions": 3,
"completedSubmissions": 3
}
}What leaves ClientGather in a payload
Events are enriched so your automation can act without calling us back. Any event carrying a request also carries its title and reference, the workspace and template names, the client name, and the email addresses of the request owner and anyone assigned to it; events about a submission also carry the recipient's name and email address, and item-level events carry the field label.
That is personal data crossing into a system you control, so treat the receiver as in scope for your own data protection obligations: terminate TLS properly, restrict who can read the logs it writes, and subscribe only to the events you use. Recipient portal links are never included — the token in them is a bearer credential.
Each request carries the following headers:
| Header | Description |
|---|---|
Content-Type | Always application/json. |
X-ClientGather-Delivery-Id | Unique per delivery attempt — use it for idempotency. Test pings are prefixed with ping_. |
X-ClientGather-Event | The event type, e.g. request.completed. |
X-ClientGather-Timestamp | ISO-8601 timestamp of the delivery; part of the signed message. |
X-ClientGather-Signature | Hex-encoded HMAC-SHA256 signature computed with your endpoint's current secret. |
X-ClientGather-Signature-Previous | Only present during a secret-rotation grace window; signed with the previous secret. |
Quick start
1. Create an HTTPS receiver
Your endpoint must be a publicly reachable HTTPS URL that accepts POST requests and responds with a 2xx status quickly. A minimal Express receiver — verifying the signature, because an endpoint that skips that step will act on anything anyone posts to it:
import { createHmac, timingSafeEqual } from "node:crypto"
import express from "express"
const SECRET = process.env.CLIENTGATHER_WEBHOOK_SECRET
const app = express()
// Keep the raw body — you need it for signature verification.
app.post(
"/hooks/clientgather",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8")
const timestamp = req.header("X-ClientGather-Timestamp") ?? ""
const expected = createHmac("sha256", SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex")
const received = Buffer.from(
req.header("X-ClientGather-Signature") ?? "", "hex"
)
const a = Buffer.from(expected, "hex")
if (a.length !== received.length || !timingSafeEqual(a, received)) {
return res.sendStatus(401)
}
const event = JSON.parse(rawBody)
console.log(`Received ${event.type}`, event.data)
res.sendStatus(200)
}
)
app.listen(3000)2. Register the endpoint in ClientGather
Open Developers → Webhooks, add your destination URL, and choose the event subscriptions your receiver needs.
ClientGather generates the signing secret and shows it once. It is stored encrypted at rest, and every later read of the endpoint returns "[redacted]" — there is no way to retrieve it again afterwards.
Copy the secret immediately
Save the plaintext secret in a secure vault right away. If you lose it, rotate the endpoint secret from the same Webhooks page.3. Verify delivery with a test ping
Use the endpoint card's ping action to send a signed test delivery. Confirm your receiver verifies the signature and responds with 2xx.
Once ping succeeds, you are ready for production traffic. Live webhook deliveries use the same signing format and retry behavior.
Verifying signatures
Every delivery is signed so you can prove it came from ClientGather and was not tampered with. The signature is computed as:
signature = hex( HMAC-SHA256( key = endpoint secret,
message = "<X-ClientGather-Timestamp>.<raw request body>" ) )Concatenate the X-ClientGather-Timestamp header value, a literal dot, and the raw request body; compute an HMAC-SHA256 over it with your endpoint secret; and compare the hex digest against X-ClientGather-Signature using a constant-time comparison.
Verify against the raw body
Compute the HMAC over the raw request bytes exactly as received. Parsing the JSON and re-serializing it will change whitespace and key ordering and break the signature. In Express, useexpress.raw() for the webhook route instead of express.json().import { createHmac, timingSafeEqual } from "node:crypto"
import express from "express"
const SECRET = process.env.CLIENTGATHER_WEBHOOK_SECRET
function matchesSignature(rawBody, timestamp, signatureHeader, secret) {
if (!signatureHeader) return false
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex")
const a = Buffer.from(expected, "hex")
const b = Buffer.from(signatureHeader, "hex")
return a.length === b.length && timingSafeEqual(a, b)
}
const app = express()
app.post(
"/hooks/clientgather",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8")
const timestamp = req.header("X-ClientGather-Timestamp") ?? ""
// Reject stale deliveries to guard against replay attacks.
const ageMs = Date.now() - new Date(timestamp).getTime()
if (!timestamp || Number.isNaN(ageMs) || ageMs > 5 * 60 * 1000) {
return res.sendStatus(400)
}
// Accept the current signature, or — during a secret-rotation
// grace window — the previous one.
const valid =
matchesSignature(
rawBody, timestamp, req.header("X-ClientGather-Signature"), SECRET
) ||
matchesSignature(
rawBody, timestamp, req.header("X-ClientGather-Signature-Previous"), SECRET
)
if (!valid) return res.sendStatus(401)
const event = JSON.parse(rawBody)
// ... handle event asynchronously ...
res.sendStatus(200)
}
)
app.listen(3000)import hashlib
import hmac
from datetime import datetime, timedelta, timezone
MAX_AGE = timedelta(minutes=5)
def matches_signature(raw_body: bytes, timestamp: str,
signature: str | None, secret: str) -> bool:
if not signature:
return False
message = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def is_valid_delivery(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-ClientGather-Timestamp", "")
# Reject stale deliveries to guard against replay attacks.
try:
sent_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError:
return False
if datetime.now(timezone.utc) - sent_at > MAX_AGE:
return False
# Accept the current signature, or - during a secret-rotation
# grace window - the previous one.
return matches_signature(
raw_body, timestamp, headers.get("X-ClientGather-Signature"), secret
) or matches_signature(
raw_body, timestamp,
headers.get("X-ClientGather-Signature-Previous"), secret
)During a secret-rotation grace window, deliveries also carry X-ClientGather-Signature-Previous, signed with the previous secret. Accepting either header (as in the Node example above) lets you roll out a new secret with zero missed deliveries — see Secret rotation. As a replay guard, reject deliveries whose timestamp is older than a few minutes.
Event catalog
Endpoints receive only the events they subscribe to. Subscribe to the wildcard * to receive everything — that is also the default when subscribedEvents is omitted at registration.
| Event | Group | Fired when |
|---|---|---|
request.created | Requests | A document request was created (in draft). |
request.sent | Requests | A request was sent to its recipients. |
request.viewed | Requests | A recipient opened a request through their portal link. |
request.reminder_sent | Requests | A reminder was delivered to a recipient. |
request.completed | Requests | All submissions on a request were completed. |
request.overdue | Requests | A request passed its due date without completion. |
request.closed | Requests | A request was manually closed as a terminal state. |
request.cancelled | Requests | A request was cancelled and marked terminal. |
submission.updated | Submissions | A submission's progress or status changed (incl. reopen). |
submission.submitted | Submissions | A submission was explicitly finalized/submitted. |
submission.changes_requested | Submissions | A review batch rejected one or more items. |
file.uploaded | Files | A recipient uploaded a file to a submission item. |
item.approved | Reviews | A reviewer approved a submission item. |
item.rejected | Reviews | A reviewer rejected a submission item. |
comment.created | Comments | A recipient left a comment on a submission item. |
integration.sync.completed | Integrations | An integration sync job finished successfully. |
integration.sync.failed | Integrations | An integration sync job failed. |
template.published | Templates | A template draft was published as a version. |
workspace.created | Workspaces | A workspace was created for the organization. |
user.invited | Users | An internal user invite was dispatched. |
user.joined | Users | An invited internal user completed onboarding. |
webhook.delivery_failed | Webhooks | A delivery reached terminal failure state. |
Example payloads
The envelope is identical for every event; only data changes. Two complete examples:
{
"id": "evt_9f6f2c9a1a7b4c1e",
"type": "request.completed",
"occurred_at": "2026-07-03T09:41:00.000Z",
"organization_id": "org_123",
"data": {
"requestId": "req_123",
"requestCode": "RQ-2041",
"requestTitle": "2026 onboarding documents",
"requestStatus": "completed",
"requestDueAt": "2026-07-05T23:59:59.000Z",
"requestUrl": "https://app.example.com/w/ws_123/requests/req_123",
"status": "completed",
"workspaceId": "ws_123",
"workspaceName": "Client Onboarding",
"clientId": "cli_123",
"clientName": "Acme Holdings Ltd.",
"templateId": "tpl_123",
"templateName": "Client onboarding pack",
"ownerName": "Ada Lovelace",
"ownerEmail": "ada@example.com",
"assignees": [
{ "userId": "usr_123", "name": "Grace Hopper", "email": "grace@example.com" }
],
"totalSubmissions": 3,
"completedSubmissions": 3
}
}{
"id": "evt_5b2d8e4a6c7d9f1a",
"type": "file.uploaded",
"occurred_at": "2026-07-03T09:38:12.000Z",
"organization_id": "org_123",
"data": {
"fileId": "file_123",
"requestId": "req_123",
"requestTitle": "2026 onboarding documents",
"requestUrl": "https://app.example.com/w/ws_123/requests/req_123",
"clientName": "Acme Holdings Ltd.",
"recipientName": "John Carter",
"recipientEmail": "john.carter@acme.example.com",
"fieldLabel": "Passport copy",
"sectionTitle": "Identity documents",
"submissionId": "sub_123",
"submissionItemId": "item_123",
"storageKey": "org_123/req_123/passport.pdf",
"contentType": "application/pdf",
"sizeBytes": 482133
}
}Shared context fields
Events carry the IDs of what changed, and we resolve the names behind those IDs before delivery — so an automation can title a card or name a client without a second lookup. These blocks are added to every matching event, on top of the event-specific fields below.
| Block | Present on | Fields |
|---|---|---|
| Request context | every event with a requestId | workspaceIdworkspaceNamerequestTitlerequestCoderequestStatusrequestDueAtrequestUrlclientIdclientNametemplateIdtemplateNameownerNameownerEmailassignees |
| Recipient context | every event with a submissionId | recipientIdrecipientNamerecipientEmail |
| Field context | every event with a submissionItemId | fieldLabelfieldKeysectionTitle |
data fields by event
| Event | data fields |
|---|---|
request.created | requestIdmodestatus |
request.sent | requestIdstatus |
request.viewed | requestIdsubmissionIdportalLinkId |
request.reminder_sent | requestIdstatuschannelproviderrecipientexternalMessageId |
request.completed | requestIdstatustotalSubmissionscompletedSubmissions |
request.overdue | requestIdstatusdueAt |
request.closed | requestIdstatusclosedAt |
request.cancelled | requestIdstatuscancelledAt |
submission.updated | submissionIdrequestIdprogressPercentstatus |
submission.submitted | submissionIdrequestIdstatusprogressPercentsubmittedAt |
submission.changes_requested | requestIdsubmissionIdrejectedItemCountreviewedItemCountsubmissionStatusprogressPercent |
file.uploaded | fileIdrequestIdsubmissionIdsubmissionItemIdstorageKeycontentTypesizeBytes |
item.approved / item.rejected | reviewDecisionIdrequestIdsubmissionIdsubmissionItemIdstatussubmissionStatusprogressPercent |
comment.created | commentIdrequestIdsubmissionIdsubmissionItemIdauthorType |
integration.sync.completed | connectionIdexternalIdexternalObjectTypeproviderKeysyncJobIdstatusmode |
integration.sync.failed | connectionIdproviderKeysyncJobIdstatuserror |
template.published | templateIdworkspaceIdstatusversionIdversionNumberfieldCountchangeSummaryschemaChecksum |
workspace.created | workspaceIdcodenamestatus |
user.invited | userIdemailfullNamestatusexpiresAtinvitedByUserIdmembershipCount |
user.joined | userIdemailfullNamestatusjoinedAt |
webhook.delivery_failed | deliveryIdeventIdeventTypeendpointIdendpointUrlattemptCountmaxAttemptsresponseCodeerrorreason |
Delivery & retries
A delivery succeeds when your endpoint responds with any 2xx status within the request timeout (5 seconds by default). Every delivery moves through one of three states:
| Status | Meaning |
|---|---|
| queued | Waiting for its first or next attempt. |
| delivered | Your endpoint acknowledged with a 2xx response. |
| failed | All retry attempts were exhausted without a 2xx response. |
Failed attempts are retried with exponential backoff. With the default configuration (5 attempts, 5-second base, doubling per attempt, capped at 1 hour):
| Attempt | Delay after previous failure |
|---|---|
| 1 | immediate (when the event occurs) |
| 2 | +5 seconds |
| 3 | +10 seconds |
| 4 | +20 seconds |
| 5 | +40 seconds |
Inspecting and replaying deliveries
Every attempt is recorded. Open Developers → Recent logs to see them across all of your endpoints — event type, destination, HTTP response, attempt count and the error message on failures. Filter by status to isolate what broke.
If your endpoint was down past the retry window, hit Replay on any failed delivery: the original event payload is re-sent as a fresh delivery, signed with your current secret. Records are retained for 30 days — long enough to debug an integration, short enough that the payloads they hold are not a second copy of your data.
Secret rotation
Rotate an endpoint's signing secret at any time — on a schedule, or immediately if it may have leaked. ClientGather always generates a new high-entropy whsec_… secret for each rotate action.
The secret is shown exactly once
The plaintextsecret is only shown right after rotation. Store it immediately — all subsequent reads show "[redacted]".Rotation is zero-downtime. For a grace window (24 hours by default), every delivery is signed twice: X-ClientGather-Signature with the new secret and X-ClientGather-Signature-Previous with the old one. The endpoint's hasPreviousSecret and previousSecretExpiresAt fields tell you whether a window is active.
A safe rotation runbook:
- Make sure your receiver accepts either signature header (see Verifying signatures).
- Rotate the endpoint from the Webhooks page and copy the newly shown secret.
- Deploy the new secret to your receiver — deliveries keep verifying via the previous signature until you do.
- After the grace window expires, the previous secret stops being used automatically. Nothing else to clean up.
Best practices & FAQ
- Be idempotent. Retries and replays mean you can receive the same event more than once. Deduplicate on
X-ClientGather-Delivery-Id(or the envelopeidfor event-level dedupe). - Acknowledge fast, process async. Return
2xxwithin the 5-second timeout and hand the payload to a queue or background job — don't do heavy work inline. - Verify before you parse. Check the signature against the raw body and reject stale timestamps before acting on any payload.
- Don't assume ordering. Deliveries are queued and retried independently, so events can arrive out of order. Use
occurred_atwhen sequence matters. - Protect your secret. Store it in a secret manager, never in code, and rotate it on a schedule.
- Subscribe narrowly. Prefer explicit event subscriptions over
*. Payloads carry client names and the email addresses of owners, assignees and recipients, so every event you subscribe to is personal data you have taken on responsibility for.
Which plan includes webhooks?
Webhooks are available from the Growth plan up. On lower plans, the Developers page shows an upgrade prompt.
Can I test without writing code?
Yes — register an endpoint in the app and use the built-in ping action, or point it at a request-inspection service while you develop. For end-to-end checks, use the in-app "Send test event" action.
My endpoint was down. Did I lose events?
Deliveries are retried automatically (5 attempts by default). If the outage outlasted the retry window, list the failed deliveries and replay them — the original payloads are stored and re-sent as fresh deliveries. Delivery records are kept for 30 days, so replay what you need inside that window.
How do I know a request really came from ClientGather?
Verify the X-ClientGather-Signature HMAC against the raw body with your endpoint secret, and reject deliveries whose X-ClientGather-Timestamp is more than a few minutes old. Unsigned or mis-signed requests should be dropped with a 401.