ClientGather

Documentation

Webhooks

Growth plan and above

Get notified the moment something happens in ClientGather. Webhooks deliver signed, real-time HTTP callbacks to your systems whenever requests are sent, files are uploaded, reviews are decided, and more — no polling required.

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 the webhooks.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

  1. An event occurs in your organization (for example, a recipient completes a request).
  2. ClientGather queues one delivery per enabled endpoint whose subscriptions match the event type (or that subscribed to *).
  3. Each delivery is a signed POST with a JSON envelope to your endpoint URL.
  4. Any 2xx response 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.

Event envelope
{
  "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:

HeaderDescription
Content-TypeAlways application/json.
X-ClientGather-Delivery-IdUnique per delivery attempt — use it for idempotency. Test pings are prefixed with ping_.
X-ClientGather-EventThe event type, e.g. request.completed.
X-ClientGather-TimestampISO-8601 timestamp of the delivery; part of the signed message.
X-ClientGather-SignatureHex-encoded HMAC-SHA256 signature computed with your endpoint's current secret.
X-ClientGather-Signature-PreviousOnly 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:

server.mjs
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 scheme
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, use express.raw() for the webhook route instead of express.json().
verify.mjs — Node.js
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)
verify.py — Python
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.

EventGroupFired when
request.createdRequestsA document request was created (in draft).
request.sentRequestsA request was sent to its recipients.
request.viewedRequestsA recipient opened a request through their portal link.
request.reminder_sentRequestsA reminder was delivered to a recipient.
request.completedRequestsAll submissions on a request were completed.
request.overdueRequestsA request passed its due date without completion.
request.closedRequestsA request was manually closed as a terminal state.
request.cancelledRequestsA request was cancelled and marked terminal.
submission.updatedSubmissionsA submission's progress or status changed (incl. reopen).
submission.submittedSubmissionsA submission was explicitly finalized/submitted.
submission.changes_requestedSubmissionsA review batch rejected one or more items.
file.uploadedFilesA recipient uploaded a file to a submission item.
item.approvedReviewsA reviewer approved a submission item.
item.rejectedReviewsA reviewer rejected a submission item.
comment.createdCommentsA recipient left a comment on a submission item.
integration.sync.completedIntegrationsAn integration sync job finished successfully.
integration.sync.failedIntegrationsAn integration sync job failed.
template.publishedTemplatesA template draft was published as a version.
workspace.createdWorkspacesA workspace was created for the organization.
user.invitedUsersAn internal user invite was dispatched.
user.joinedUsersAn invited internal user completed onboarding.
webhook.delivery_failedWebhooksA delivery reached terminal failure state.

Example payloads

The envelope is identical for every event; only data changes. Two complete examples:

request.completed
{
  "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
  }
}
file.uploaded
{
  "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.

BlockPresent onFields
Request contextevery event with a requestIdworkspaceIdworkspaceNamerequestTitlerequestCoderequestStatusrequestDueAtrequestUrlclientIdclientNametemplateIdtemplateNameownerNameownerEmailassignees
Recipient contextevery event with a submissionIdrecipientIdrecipientNamerecipientEmail
Field contextevery event with a submissionItemIdfieldLabelfieldKeysectionTitle

data fields by event

Eventdata fields
request.createdrequestIdmodestatus
request.sentrequestIdstatus
request.viewedrequestIdsubmissionIdportalLinkId
request.reminder_sentrequestIdstatuschannelproviderrecipientexternalMessageId
request.completedrequestIdstatustotalSubmissionscompletedSubmissions
request.overduerequestIdstatusdueAt
request.closedrequestIdstatusclosedAt
request.cancelledrequestIdstatuscancelledAt
submission.updatedsubmissionIdrequestIdprogressPercentstatus
submission.submittedsubmissionIdrequestIdstatusprogressPercentsubmittedAt
submission.changes_requestedrequestIdsubmissionIdrejectedItemCountreviewedItemCountsubmissionStatusprogressPercent
file.uploadedfileIdrequestIdsubmissionIdsubmissionItemIdstorageKeycontentTypesizeBytes
item.approved / item.rejectedreviewDecisionIdrequestIdsubmissionIdsubmissionItemIdstatussubmissionStatusprogressPercent
comment.createdcommentIdrequestIdsubmissionIdsubmissionItemIdauthorType
integration.sync.completedconnectionIdexternalIdexternalObjectTypeproviderKeysyncJobIdstatusmode
integration.sync.failedconnectionIdproviderKeysyncJobIdstatuserror
template.publishedtemplateIdworkspaceIdstatusversionIdversionNumberfieldCountchangeSummaryschemaChecksum
workspace.createdworkspaceIdcodenamestatus
user.inviteduserIdemailfullNamestatusexpiresAtinvitedByUserIdmembershipCount
user.joineduserIdemailfullNamestatusjoinedAt
webhook.delivery_faileddeliveryIdeventIdeventTypeendpointIdendpointUrlattemptCountmaxAttemptsresponseCodeerrorreason

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:

StatusMeaning
queuedWaiting for its first or next attempt.
deliveredYour endpoint acknowledged with a 2xx response.
failedAll 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):

AttemptDelay after previous failure
1immediate (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 plaintext secret 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:

  1. Make sure your receiver accepts either signature header (see Verifying signatures).
  2. Rotate the endpoint from the Webhooks page and copy the newly shown secret.
  3. Deploy the new secret to your receiver — deliveries keep verifying via the previous signature until you do.
  4. 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 envelope id for event-level dedupe).
  • Acknowledge fast, process async. Return 2xx within 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_at when 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.