# 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:

```json
[
  {
    "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](/docs/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.

```javascript
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:

```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](/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](/docs/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](/docs/retries) 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Grist webhooks reliably in minutes.