Guide to Grist Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Configuration | Per document: Tools > Settings, then Manage Webhooks in the API section |
| Scope | One table per webhook |
| Event types | Adding rows (add), modifying rows (update), or both |
| Filtering | An optional list of column IDs, and an optional Ready column |
| Body | A JSON array of row objects. Several rows can arrive in one request |
| Event identifier | None in the request |
| Authentication | Optional Header Authorization, sent verbatim as Authorization. No signature |
| Retries | Periodic, from a batched queue. No published schedule or timeout |
| Monitoring | A read-only Status field on each webhook |
| Self-hosting | Only domains in ALLOWED_WEBHOOK_DOMAINS can be called |
| Source IPs | Not published |
Common events
Grist has two event types, and you pick them on the webhook rather than receiving them in the body:
| Event type | Fires when |
|---|---|
add | A row is added to the table, or a row's Ready column turns true |
update | An 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
- Generate a long random value for the credential, for example with
openssl rand -hex 32, and decide on the full header value, such asBearer <value>. - Open the document and click Settings under the Tools menu at the bottom of the left-hand panel.
- In the API section of Document Settings, click Manage Webhooks.
- 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.
- 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_, 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=addand?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
2xxonly 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.