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:
{
"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
- 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. - On the SeaTable home page, hover over the base, open its context menu and choose Advanced > Webhooks.
- Enter your endpoint URL and the secret key, and confirm. The webhook is active immediately.
- Add, edit or delete a row in the base to send a delivery. SeaTable has no test button.
For local development, the Hookdeck 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.
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:
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 verifies the
X-Seatable-Signatureheader, returns the200SeaTable 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
200to 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 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 usingdtable_uuid,table_idandrow_id. - On
delete_row, userow_idto remove your own copy. The row can no longer be fetched.
How Hookdeck Can Help: Hookdeck 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_countand, when it is present, re-sync the affected table from the API instead of trusting the singlerow_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 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.
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.
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 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 for free and handle SeaTable webhooks reliably in minutes.