# Guide to SeaTable Webhooks: Features and Best Practices

SeaTable webhooks send every row change in a base to a URL you choose. When someone adds a row in the web interface, a form submits, an API call edits a cell or a row is deleted, SeaTable posts the change to your endpoint, so you can sync records to another system, notify a team or start a workflow without polling the SeaTable API.

This guide covers how SeaTable webhooks are created and signed, the payload and its three operation types, the rule that switches a failing webhook off, and the best practices for running a SeaTable receiver in production.

## What are SeaTable webhooks?

A SeaTable webhook belongs to one base. SeaTable says that "once the webhook is set up, any change to the contents of the base is sent to the target URL", always as a `POST` with a JSON body. There is no event picker: you get every row insert, modification and deletion in the base, and nothing for structural changes such as new columns or views.

Signing depends on the secret key. The key is optional, and SeaTable is direct about what that means: without it, "anyone can send you appropriate requests to this webhook URL". When you set one, each request carries an `X-Seatable-Signature` header holding `sha256=` and a hex HMAC-SHA256 of the raw body.

## SeaTable webhook features

| Feature | Details |
| --- | --- |
| Configuration | Per base, from the base's context menu under **Advanced > Webhooks** |
| Subscription model | Every row insert, modify and delete in the base. No filter |
| Method and format | `POST`, `application/json` |
| Signature header | `X-Seatable-Signature`, only when a secret key is set |
| Scheme | HMAC-SHA256, lowercase hex, prefixed with `sha256=` |
| Signed content | The raw request body, nothing else |
| Secret | A secret key you choose on the webhook, used as a UTF-8 string |
| Replay window | None. No timestamp is sent or signed |
| Expected response | HTTP `200` |
| Auto-disable | Set to invalid after five non-`200` or missing responses in a row (since version 3.5) |
| Retries | Not documented |

SeaTable publishes no response timeout, retry schedule or source IP list for webhooks. With no IP list, the secret key and signature are the only way to tell SeaTable's requests from anyone else's.

## Common events

Every request has two top-level keys, `event` and `data`. SeaTable says `event` "is currently always 'update'", so the kind of change is in `data.op_type`:

| `data.op_type` | Fires when |
| --- | --- |
| `insert_row` | A row is added or duplicated, in the web interface, through a web form or by an API call |
| `modify_row` | A row's content changes |
| `delete_row` | A row is deleted |

Here is SeaTable's `modify_row` example, with the select options trimmed:

```json
{
  "event": "update",
  "data": {
    "dtable_uuid": "fae0a3a42ba94df88e8303fd385e37de",
    "row_id": "QoNno3QiTF26sxmG1sl3_g",
    "op_user": "4bc9a7c79c5942c5b6238413f2132bea@auth.local",
    "op_type": "modify_row",
    "op_time": 1649929622.589,
    "table_id": "9g8f",
    "table_name": "Opportunities",
    "row_name": "",
    "row_data": [
      {
        "column_key": "589r",
        "column_name": "Prio",
        "column_type": "single-select",
        "column_data": { "options": [] },
        "value": "381992",
        "old_value": ""
      }
    ]
  }
}

```

`dtable_uuid` identifies the base, `row_id` the row and `table_id` the table. `op_time` is a Unix timestamp in seconds with a fractional part. On `modify_row`, `row_data` has one entry per changed column with both `value` and `old_value`. On `insert_row` and `delete_row` it is an empty array, so the webhook tells you which row changed but not what it now contains. When the change came through the API, `op_app` holds the name of the API token that made it.

Two details sit outside that table. When one action in the web editor changes several rows, such as pasting a value into many rows at once, SeaTable sends a single webhook with an extra `row_count` parameter instead of one per row. SeaTable shows that body only as a screenshot. And the sample request on SeaTable's secret-key page has `op_type` set to `create_row`, a value the data-structure page does not list.

## Setting up SeaTable webhooks

1. Choose a secret key, a long random string, and store it server-side, for example as `SEATABLE_WEBHOOK_SECRET`. SeaTable does not generate one for you.
2. On the SeaTable home page, hover over the base, open its context menu and choose **Advanced > Webhooks**.
3. Enter your endpoint URL and the secret key, and confirm. The webhook is active immediately.
4. Add, edit or delete a row in the base to send a delivery. SeaTable has no test button.

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 seatable --path /webhooks/seatable`) gives you a public HTTPS URL that forwards to your local server, with no account required.

## Securing SeaTable webhooks

Read `X-Seatable-Signature`, strip the `sha256=` prefix, compute an HMAC-SHA256 of the raw request body with your secret key as a UTF-8 string, encode it as hex and compare in constant time. Reject requests with no header: a webhook that has a secret key always sends one.

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

function verifySeaTableSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const received = Buffer.from(signatureHeader.trim().replace(/^sha256=/, '').toLowerCase(), 'utf8');
  const computed = Buffer.from(
    crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
    'utf8'
  );
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/seatable', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifySeaTableSignature(req.body, req.get('x-seatable-signature'), process.env.SEATABLE_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const { data } = JSON.parse(req.body.toString('utf8'));
  // Route on data.op_type. Persist the change, then process it asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_seatable_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    received = signature_header.strip().removeprefix("sha256=").lower().encode("utf-8")
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_seatable_signature(raw_body, request.headers.get("x-seatable-signature"),
#                           os.environ["SEATABLE_WEBHOOK_SECRET"])

```

To check your verifier, use the secret key `seatable-test-secret` and this exact body:

```
{"event":"update","data":{"dtable_uuid":"fae0a3a42ba94df88e8303fd385e37de","row_id":"Fk-i7xZGS5iA5aSvaG5HAA","op_type":"insert_row","op_time":1649922777.279,"table_name":"Opportunities"}}

```

The header should be:

```
sha256=5945ee4bba1ded53434f5ce90bdda89a64b56ca0589ee116c38bb90bb033f95c

```

SeaTable's prose calls the header "X-SeaTable-Signature-Header", but its sample request and code read `X-Seatable-Signature`. Header names are case-insensitive, so `req.get('x-seatable-signature')` finds it either way. Keep the secret key as a plain string, and don't base64- or hex-decode it.

> **Make SeaTable webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Seatable-Signature` header, returns the `200` SeaTable expects as soon as each change arrives, and durably queues every row change before your service sees it.

## SeaTable webhook limitations and pain points

### No secret key, no signature

**The Problem:** Requests arrive with no `X-Seatable-Signature` header, and nothing proves they came from SeaTable.

**Why It Happens:** The secret key is optional and SeaTable only signs when one is set. The setup steps on SeaTable's create-webhook page mention only the URL, so it is easy to skip.

**Workarounds:**

* Set a secret key on every webhook whose URL is publicly reachable, which SeaTable itself recommends.
* Reject any request without the header once a key is set.
* If a webhook has to run without a key, treat each request as a hint and re-read the row through the SeaTable API before acting on it.

**How Hookdeck Can Help:** Hookdeck's SeaTable source type verifies `X-Seatable-Signature` with the secret key you give it. If you didn't set a secret key in SeaTable, leave verification off on the source.

### Five failures switch the webhook off

**The Problem:** Changes stop arriving, and nothing resumes until someone opens SeaTable and saves the webhook again.

**Why It Happens:** SeaTable expects a `200`. If it gets "no status code or a status code other than 200 back five times in a row, the webhook is set to invalid status". Up to and including version 3.4, the first error was enough. A `201` or `204` counts as an error, as does a deploy that takes your endpoint down for a few minutes on a busy base. SeaTable documents no retry, so don't count on the changes sent during those failures arriving again.

**Workarounds:**

* Return exactly `200`, not another 2xx code, once the request is verified and stored.
* Answer `200` to op_types you do not handle.
* To reactivate an invalid webhook, open it for editing and save it without changes.
* After an outage, reconcile against the SeaTable API rather than assuming you saw every change.

**How Hookdeck Can Help:** Hookdeck answers SeaTable with a `200` as soon as it stores each request, so your own downtime does not count towards the five failures. It then retries delivery to your service on [your own schedule](/docs/retries) and lets you replay anything that failed.

### Inserts and deletes carry no row data

**The Problem:** An `insert_row` webhook tells you a row was added but not what is in it.

**Why It Happens:** `row_data` is an empty array on `insert_row` and `delete_row` in SeaTable's examples. Only `modify_row` carries column values, and only for the columns that changed.

**Workarounds:**

* On `insert_row`, fetch the row from the SeaTable API using `dtable_uuid`, `table_id` and `row_id`.
* On `delete_row`, use `row_id` to remove your own copy. The row can no longer be fetched.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can send `insert_row` events to a worker that enriches them from the API and send `modify_row` events straight to their consumer.

### One webhook for many rows

**The Problem:** A user pastes a value into fifty rows, and your system updates one of them.

**Why It Happens:** A multi-row change in the web editor produces a single webhook with a `row_count` parameter, not one webhook per row. SeaTable says there is "currently no option to create a separate webhook for each row", and it only shows this body as a screenshot.

**Workarounds:**

* Check for `row_count` and, when it is present, re-sync the affected table from the API instead of trusting the single `row_id`.
* Log these bodies when they first arrive so you learn their shape from real traffic.

**How Hookdeck Can Help:** Hookdeck keeps every request in its log, so you can inspect the first `row_count` body you receive and add a filter that routes those requests to a full re-sync.

### No timestamp means no replay protection

**The Problem:** A captured request still verifies if someone sends it again later.

**Why It Happens:** The signature covers only the body. There is no timestamp header or event ID.

**Workarounds:**

* Make handlers idempotent.
* Keep the endpoint on HTTPS so requests cannot be captured in transit.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated requests based on the fields you choose, such as `data.row_id`, `data.op_type` and `data.op_time`.

## Best practices

### Verify against the raw body

The HMAC covers the exact bytes SeaTable sent. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in a Next.js route handler, or `await request.body()` in FastAPI. Parse JSON only after the check.

### Route on `data.op_type`, not `event`

`event` is always `update` today. Switch on `data.op_type`, and accept `create_row` alongside `insert_row` until you have seen which one your SeaTable version sends.

### Build your own idempotency key

There is no event ID. Combine `dtable_uuid`, `row_id`, `op_type` and `op_time` into a key and store it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Every non-`200` response brings the webhook closer to being disabled. Verify, store, return `200`, and do the real work from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Give each base its own URL

Each webhook covers one base and has its own secret key. If you receive several bases, give each one its own path, such as `/webhooks/seatable/<base>`, so your handler knows which key to verify with before it parses anything.

## Conclusion

SeaTable sends every row change in a base, signs it only when you set a secret key, and switches the webhook off after five failed responses. Set a key, verify `X-Seatable-Signature` against the raw body, route on `data.op_type`, fetch inserted rows from the API, and return a plain `200` quickly.

[Hookdeck Event Gateway](https://hookdeck.com) answers SeaTable straight away, checks every signature and holds each change until your service has processed it, so an outage on your side does not leave the webhook marked invalid.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle SeaTable webhooks reliably in minutes.