Skip to content
GarnerDocs

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.

  1. Open Settings → Webhooks

    Select Settings in the sidebar, then Webhooks.

  2. Add an endpoint

    Select Add endpoint, paste the URL and tick the events you want. You can change them later.

  3. 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.

  4. Send a test event

    Open the endpoint and select Send test event. GarnerDocs sends a ping event, 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 localhost or 192.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"
  }
}
  • status is draft, active, completed or cancelled.
  • due_date is null when the request has no due date.
  • url is 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 status is not_uploaded, under_review, approved or needs_replacement.
  • name is the file's original name, and size is in bytes.
  • review_status is pending, accepted or rejected.
  • download_url is 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:

  1. Read the raw request body. Check the signature before you parse the JSON, because parsing and re-encoding changes the bytes.
  2. Make the HMAC-SHA256 of t, a full stop and the raw body, with your secret, as hex.
  3. Compare it with each v1 value using a constant-time comparison. Accept the event if any of them matches.
  4. Reject the event if t is 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 2xx response 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 Gone response is never retried, and turns the endpoint off at once.
  • An event can arrive more than once, or out of order. Use its id to 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
All Settings and branding articles

Spend less time chasing. More time doing the work that matters.

Make document collection the easiest part of working with your clients.

Start your free trial

No credit card required.