Settings and branding
Send events to other systems with webhooks
Send requests, uploads and notes to Zapier, Make, n8n or your own code.
Last updated · Facts checked
A webhook tells another system the moment something happens in GarnerDocs. When a request is sent, a file is uploaded or a client leaves a note, GarnerDocs sends the details to a URL you choose. Use it to connect Zapier, Make, n8n or your own systems.
Before you start
Webhooks are on Growth and Pro, and during your free trial. Only owners and admins can set them up.
Set up an endpoint
An endpoint is a URL that receives your events.
-
Open Settings → Webhooks
Select Settings in the sidebar, then Webhooks.
-
Add an endpoint
Select Add endpoint, paste the URL and tick the events you want. You can change them later.
-
Choose whether to include download links
Tick Include download links if the receiving system should fetch the uploaded files. Leave it off if it only needs to know what happened.
-
Send a test event
Open the endpoint and select Send test event. GarnerDocs sends a
pingevent, and you'll see the result under the endpoint's recent deliveries.
A few rules for the URL:
- It must start with
https://. - GarnerDocs refuses private and internal addresses, such as
localhostor192.168.0.10. - Your workspace can have up to 10 endpoints.
If your workspace moves to Starter, your endpoints are paused, not deleted, and nothing is sent until you upgrade.
The events
| Event | When it's sent |
|---|---|
request.sent |
You send a request to your client. |
request.opened |
Your client opens the request for the first time. Later visits don't send it again. |
request.completed |
A request is completed. |
request.cancelled |
A request is cancelled. |
upload.created |
Your client uploads a file. One event per file. |
upload.approved |
You approve a file. One event per file. |
upload.rejected |
You send a file back and ask for a replacement. One event per file, with your note as reason. |
answer.submitted |
Your client saves a short answer. The answer itself is never sent. |
item.unavailable |
Your client says they don't have a checklist item. Their reason is never sent. |
note.created |
Your client leaves a note on their upload page. |
client.created |
A client is added to your workspace. |
ping |
You select Send test event on the endpoint page. |
The payload
Every event is a JSON object with the same envelope. data depends on the event.
{
"id": "evt_6f1c8a52-1d2e-4c55-9f0e-3a3d1c2b9e10",
"type": "upload.created",
"created_at": "2026-10-20T09:15:00Z",
"firm": {"id": 1, "name": "Northstar Accounting"},
"data": {}
}
id stays the same on every retry of an event, so you can use it to ignore an event you've already handled. Times are in UTC.
Requests
request.sent, request.opened, request.completed and request.cancelled send the request:
"data": {
"request": {
"id": 42,
"title": "2025 annual accounts",
"status": "active",
"due_date": "2026-11-03",
"client": {"id": 7, "name": "Harbour Lane Ltd", "email": "[email protected]"},
"url": "https://garnerdocs.com/app/requests/42"
}
}
statusisdraft,active,completedorcancelled.due_dateisnullwhen the request has no due date.urlis the request's page in GarnerDocs. Only your team can open it.
Uploads
upload.created and upload.approved send the request, the checklist item and the file:
"data": {
"request": {"id": 42, "title": "2025 annual accounts", "...": "..."},
"item": {"id": 318, "name": "Bank statements", "status": "under_review"},
"upload": {
"id": 905,
"name": "statements-jan-dec.pdf",
"mime": "application/pdf",
"size": 482113,
"uploaded_at": "2026-10-20T09:14:52Z",
"review_status": "pending",
"download_url": "https://garnerdocs.com/…"
}
}
- The item's
statusisnot_uploaded,under_review,approvedorneeds_replacement. nameis the file's original name, andsizeis in bytes.review_statusispending,acceptedorrejected.download_urlis there only when the endpoint has Include download links on. It's a signed link, valid for 1 hour, that downloads the file. Anyone who has the link can use it in that hour without signing in, so only send download links to systems you trust and keep their logs private. GarnerDocs records the time of every use, but can't tell who used it.
upload.rejected adds reason, the note you wrote when you asked for a replacement:
"data": {
"request": {"...": "..."},
"item": {"id": 318, "name": "Bank statements", "status": "needs_replacement"},
"upload": {"id": 905, "name": "statements-jan-dec.pdf", "review_status": "rejected", "...": "..."},
"reason": "December is missing. Please upload the full year."
}
Short answers
answer.submitted sends the request, the checklist item and when the answer was given. The answer's text is never in a webhook: open the request to read it.
"data": {
"request": {"...": "..."},
"item": {"id": 322, "name": "Your National Insurance number", "status": "under_review"},
"answer": {"id": 41, "submitted_at": "2026-10-20T09:18:02Z", "review_status": "pending"}
}
Items your client doesn't have
item.unavailable sends the request, the checklist item and when your client said they don't have it. Their reason is never in a webhook: open the request to read it.
"data": {
"request": {"...": "..."},
"item": {"id": 318, "name": "Bank statements", "status": "under_review"},
"unavailable": {"id": 12, "submitted_at": "2026-10-20T09:18:02Z", "review_status": "pending"}
}
Notes
note.created sends the request and the client's note:
"data": {
"request": {"...": "..."},
"note": {"id": 77, "body": "The December statement comes next week.", "created_at": "2026-10-20T09:20:11Z"}
}
Clients
client.created sends the client:
"data": {
"client": {"id": 7, "name": "Harbour Lane Ltd", "email": "[email protected]", "contact_name": "Priya Shah", "type": "ltd_company"}
}
type is ltd_company, partnership, sole_trader or individual.
Test events
ping sends:
"data": {"message": "Test event from GarnerDocs"}
What's never sent
Payloads never contain your client's upload link, the request's PIN or the contents of a file.
Headers
Every delivery is a POST with these headers:
| Header | Example |
|---|---|
Content-Type |
application/json |
User-Agent |
GarnerDocs-Webhooks/1.0 |
GarnerDocs-Event |
upload.created |
GarnerDocs-Delivery |
evt_6f1c8a52-1d2e-4c55-9f0e-3a3d1c2b9e10 (the event's id) |
GarnerDocs-Signature |
t=1792487700,v1=5257a8… |
Verify the signature
Anyone can send a request to your URL. The signature proves an event came from GarnerDocs and wasn't changed on the way.
Each endpoint has its own signing secret, starting whsec_. Find it on the endpoint page and select Reveal or Copy. Keep it out of your code and your logs.
In GarnerDocs-Signature, t is when GarnerDocs signed the event (Unix time, in seconds), and v1 is a hex HMAC-SHA256 of {t}.{the raw request body}, made with your signing secret. To check it:
- Read the raw request body. Check the signature before you parse the JSON, because parsing and re-encoding changes the bytes.
- Make the HMAC-SHA256 of
t, a full stop and the raw body, with your secret, as hex. - Compare it with each
v1value using a constant-time comparison. Accept the event if any of them matches. - Reject the event if
tis more than 5 minutes old, so an old request can't be replayed.
PHP
<?php
$secret = getenv('GARNERDOCS_WEBHOOK_SECRET'); // whsec_…
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_GARNERDOCS_SIGNATURE'] ?? '';
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || ! ctype_digit($timestamp) || time() - (int) $timestamp > 300) {
http_response_code(400);
exit;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
$valid = false;
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
$valid = true;
}
}
if (! $valid) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// Handle $event['type'] here, or queue it for later.
http_response_code(200);
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const secret = process.env.GARNERDOCS_WEBHOOK_SECRET; // whsec_…
app.post('/webhooks/garnerdocs', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('GarnerDocs-Signature') || '';
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const index = part.indexOf('=');
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key === 't') timestamp = value;
if (key === 'v1') signatures.push(value);
}
if (!/^\d+$/.test(timestamp || '') || Math.floor(Date.now() / 1000) - Number(timestamp) > 300) {
return res.sendStatus(400);
}
// req.body is a Buffer: the raw body, before any JSON parsing.
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(req.body).digest('hex'),
);
const valid = signatures.some((signature) => {
const received = Buffer.from(signature);
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
if (!valid) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
// Handle event.type here, or queue it for later.
res.sendStatus(200);
});
app.listen(3000);
Roll the secret
If the secret may have leaked, open the endpoint and select Roll secret. GarnerDocs makes a new secret straight away. The old one keeps working for 24 hours, so you have time to update your system. During those 24 hours, each request carries two signatures, t=…,v1=new,v1=old, which is why you accept the event if any v1 matches.
Deliveries and retries
- GarnerDocs waits up to 10 seconds for a response and doesn't follow redirects.
- Any
2xxresponse counts as delivered. Reply quickly, and do slow work after you've replied. - A failed delivery is retried 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours after the previous attempt.
- A
410 Goneresponse is never retried, and turns the endpoint off at once. - An event can arrive more than once, or out of order. Use its
idto ignore repeats.
The endpoint page lists recent deliveries, kept for 30 days, with their status, response code and how long they took. Select Resend to send one again.
When an endpoint is turned off
If an endpoint has failed for 3 days without a single successful delivery, GarnerDocs turns it off. Owners and admins get an email, and Settings → Webhooks shows a notice. Fix the receiving system, then select Resume on the endpoint page.
You can also pause, resume or delete an endpoint yourself on its page.
Connect Zapier, Make or n8n
You don't need to write code to use webhooks. Each of these tools gives you a URL to paste into Add endpoint.
Zapier. Create a Zap with Webhooks by Zapier as the trigger, choose Catch Hook and copy the webhook URL. Add it as an endpoint in GarnerDocs, then select Send test event so Zapier can see a sample.
Make. Create a scenario that starts with the Webhooks app, choose Custom webhook, add a webhook and copy its address. Add it as an endpoint, then select Send test event so Make can learn the data structure.
n8n. Add a Webhook node, set the HTTP method to POST and copy its production URL. Add it as an endpoint, and activate the workflow so the URL keeps listening.
To check signatures in one of these tools, add a code step that follows the steps above. Without one, anyone who knows the URL can send it events, so keep it private.
To save each file you approve to Google Drive, OneDrive or Dropbox, follow Save approved files to Google Drive or Dropbox.
Good to know
- Webhooks send what happened. To act on events inside GarnerDocs, such as telling more of your team about uploads, use workflows.
- A download link stops working once its file is deleted at the end of your retention period. How long files are kept
- The data sent to your endpoints goes to systems you choose and control. Subprocessors
Related articles
- Save approved files to Google Drive or Dropbox
- Workflows that run when something happens
- Download a file
- Roles and permissions
- Plans and limits