Gareth Wilson Gareth Wilson

Guide to Grist Webhooks: Features and Best Practices

Published


Grist webhooks tell another system when rows change in a Grist document. When someone adds a row to a table, or edits one, Grist can send those rows to a URL you choose, so another service can act on the change without polling the Grist API.

This guide covers how Grist webhooks are configured, what the payload looks like, how to authenticate deliveries with the Header Authorization value, how retries and the delivery queue behave, and the practices that keep a Grist receiver reliable.

What are Grist webhooks?

A Grist webhook belongs to one document and watches one table. You choose whether adding rows, modifying rows or both should trigger it, and when the trigger fires, Grist sends the matching rows to the webhook's URL as a JSON array.

Grist doesn't sign webhooks. The only credential is an optional Header Authorization field, which Grist describes as "a static string" supplied "in the Authorization HTTP header". Whatever you type there, for example Bearer 3f9c..., arrives unchanged as the Authorization header on every delivery, and your receiver compares it with the value it expects.

Grist webhook features

FeatureDetails
ConfigurationPer document: Tools > Settings, then Manage Webhooks in the API section
ScopeOne table per webhook
Event typesAdding rows (add), modifying rows (update), or both
FilteringAn optional list of column IDs, and an optional Ready column
BodyA JSON array of row objects. Several rows can arrive in one request
Event identifierNone in the request
AuthenticationOptional Header Authorization, sent verbatim as Authorization. No signature
RetriesPeriodic, from a batched queue. No published schedule or timeout
MonitoringA read-only Status field on each webhook
Self-hostingOnly domains in ALLOWED_WEBHOOK_DOMAINS can be called
Source IPsNot published

Common events

Grist has two event types, and you pick them on the webhook rather than receiving them in the body:

Event typeFires when
addA row is added to the table, or a row's Ready column turns true
updateAn existing row is modified. With a column filter, only when one of the listed columns changed

Here is Grist's example payload:

[
  {
    "id": 29,
    "manualSort": 29,
    "Title": "Trophy",
    "URL": "https://example.com/buy/Trophy",
    "Price": 60,
    "Purchase_status": "wishlisted",
    "Currency": "USD",
    "Play_status": "Not started",
    "Box_art": null,
    "Price_CAD_": 82.362
  },
  {
    "id": 24,
    "manualSort": 24,
    "Title": "Dataman",
    "URL": "https://example.com/buy/Dataman",
    "Price": 50,
    "Purchase_status": "own digitally",
    "Currency": "EUR",
    "Play_status": "Finished",
    "Box_art": null,
    "Price_CAD_": 74.71
  }
]

Each object is a row: its id, its manualSort position and a key for every column, using the column IDs rather than their labels. There's no envelope, no event name and no timestamp. A webhook set to fire on both adds and updates sends the same shape for both, so the request can't tell you which one happened.

The Ready column changes what "add" means. While a row's Ready toggle is false, changes to it don't trigger the webhook. When the toggle turns true, the row fires add, and later changes fire update. That lets people fill in a row over several edits and send it once it's complete.

Setting up Grist webhooks

  1. Generate a long random value for the credential, for example with openssl rand -hex 32, and decide on the full header value, such as Bearer <value>.
  2. Open the document and click Settings under the Tools menu at the bottom of the left-hand panel.
  3. In the API section of Document Settings, click Manage Webhooks.
  4. Fill in a webhook card: a Name, the Event types, the Table, your HTTPS endpoint as the URL, and the full value from step 1 in Header Authorization. Optionally set Filter for changes in these columns (semicolon-separated column IDs) and a Ready column.
  5. Make sure Enabled is on, then add or edit a row in the table and check the webhook's Status field.

On a self-hosted Grist, the URL's domain must be listed in the ALLOWED_WEBHOOK_DOMAINS environment variable. Grist warns that allowing any domain can expose internal Grist services to manipulation in untrusted environments, and suggests routing webhook calls through GRIST_HTTPS_PROXY with ALLOWED_WEBHOOK_DOMAINS=*, which is how Grist Labs runs its own cloud service.

In Hookdeck. Create a source with the Grist type and use its URL as the webhook URL. In Authorization Header, paste the Header Authorization value exactly as you set it in Grist, including any Bearer or Basic prefix. Hookdeck compares the whole Authorization header with that value, so entering the token without its prefix won't match. The Grist source accepts POST requests. If you leave Header Authorization empty in Grist, there's nothing to check, so leave verification off on the source, though setting a credential is the better choice.

For local development, the Hookdeck CLI gives you a public HTTPS URL that forwards to your machine, with no account required: hookdeck listen 3000 grist --path /webhooks/grist.

Securing Grist webhooks

Compare the Authorization header with the full Header Authorization value, in constant time, before you parse the body. Don't strip or decode the prefix: Grist sends the string exactly as typed.

const crypto = require('crypto');
const express = require('express');

const app = express();

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on buffers of different lengths
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/webhooks/grist', express.raw({ type: '*/*' }), (req, res) => {
  const expected = process.env.GRIST_AUTHORIZATION; // e.g. "Bearer 3f9c..."
  if (!expected) return res.sendStatus(500); // fail closed when unconfigured

  const received = req.get('authorization');
  if (typeof received !== 'string' || !safeEqual(received, expected)) {
    return res.sendStatus(401);
  }

  let rows;
  try {
    rows = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!Array.isArray(rows)) return res.sendStatus(400);

  // Queue the rows for a worker, which upserts each one on row.id.
  res.sendStatus(200);
});

The same check in Python:

import hmac
import os


def verify_grist_request(headers) -> bool:
    expected = os.environ.get("GRIST_AUTHORIZATION")
    if not expected:
        return False  # fail closed
    received = headers.get("authorization")
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

A matching header shows the sender knows your credential. It doesn't show that the rows are unchanged, and a captured request can be sent again later and still pass, because nothing is signed and there's no timestamp. Serve the endpoint over HTTPS only, use a long random value, and change it in the webhook card if it may have leaked.

Make Grist webhooks production-ready. Hookdeck Event Gateway checks the Authorization header on every Grist delivery, accepts and stores each batch of rows straight away, and delivers it to your service with retries and replay you control.

Grist webhook limitations and pain points

Adds and updates look the same

The Problem: A webhook set to fire on both adds and updates sends rows with no hint of which one happened.

Why It Happens: The body is a bare array of rows. The event types are part of the webhook's configuration, not the request.

Workarounds:

  • Write handlers as upserts keyed on the row id, so the distinction doesn't matter.
  • Where it does matter, create two webhooks on the same table, one for each event type, with different URLs, for example by adding ?event=add and ?event=update.

How Hookdeck Can Help: Hookdeck filters can match on the query string as well as the body, so one Grist source can route add and update deliveries to different destinations.

The credential is optional and static

The Problem: A webhook created without Header Authorization sends rows that any receiver has to accept on trust, and one created with it sends the same value forever.

Why It Happens: Header Authorization is optional, and Grist sends it unchanged on every request. Nothing is computed over the body.

Workarounds:

  • Always fill in Header Authorization, with a long random value, and reject requests without it.
  • Keep the endpoint on HTTPS and out of public lists.
  • Treat the value like a password and rotate it in the webhook card if it leaks.

How Hookdeck Can Help: Hookdeck's Grist source compares the Authorization header with the exact value you enter. Hookdeck answers Grist with a 200 either way, then records a request without the right header as failed verification, so it never reaches your destination.

A failing endpoint builds a queue

The Problem: While your endpoint is down or rejecting requests, deliveries pile up, and there's no published limit on how long Grist keeps trying.

Why It Happens: Grist delivers from a batched queue and "will keep retrying periodically" until a delivery succeeds. The page doesn't give a schedule, a timeout or the response codes that count as success.

Workarounds:

  • Watch each webhook's Status field, which shows errors and delivery counts.
  • Fix the endpoint and let the queue drain, or use Clear Queue to discard pending payloads after a misconfiguration.
  • Return a 2xx only once you've stored the rows, so nothing is lost when you do.

How Hookdeck Can Help: Hookdeck accepts each Grist delivery as it arrives, so the queue in Grist stays empty. Your service's downtime turns into a backlog in Hookdeck that you can retry and replay on your own schedule.

Rows arrive in batches

The Problem: A handler that expects one row per request processes only the first, or fails on an array.

Why It Happens: Grist sends rows that trigger the same webhook together in one payload.

Workarounds:

  • Always loop over the array.
  • Use the Ready column to control when rows become visible, so related rows tend to arrive together.

How Hookdeck Can Help: Hookdeck stores every request with its full body, so you can see exactly how Grist batched the rows and replay a batch after fixing your handler.

Best practices

Store the full header value

Keep the whole Header Authorization string, prefix included, as the expected value. Comparing against the bare token rejects every request.

Upsert on the row id

Retries and the lack of an event type both point the same way: make each row an idempotent upsert keyed on id, not an insert. See our guide to webhook idempotency.

Narrow what fires the webhook

Use the column filter so cosmetic edits don't send updates, and a Ready column so half-finished rows don't send adds.

Expect column IDs, not labels

Rows are keyed by column ID. Renaming a column's ID in Grist changes the keys your handler receives, so treat schema changes in the document as changes to the webhook contract.

Acknowledge fast and process asynchronously

Verify, store the rows, respond with 200, then do the work. See why to process webhooks asynchronously.

Conclusion

Grist sends changed rows as a JSON array, with your Header Authorization value as the only credential and no event type in the request. Compare the full Authorization header in constant time over HTTPS, loop over every row, upsert on id, and keep an eye on each webhook's Status field.

Hookdeck Event Gateway sits in front of your receiver, checks the Authorization header, takes each batch off Grist's queue straight away, and holds it until your service has processed it.

Get started with Hookdeck for free and handle Grist 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.