Developers
Webhooks
OpenRows sends a base's changes to a URL you choose, as signed JSON. Use them to push records into n8n, Zapier, Make or your own code instead of polling the REST API.
On the hosted service, the Zapier app registers and checks a webhook for each Zap's trigger the same way, and the Make app for Watch Records (Make can't check the signature, so it reads each delivery's record again through the API instead).
In n8n, the OpenRows Trigger of @openrows/n8n-nodes-openrows
registers and verifies a webhook for you.
Managing webhooks
A base's creators and owners manage its webhooks under Webhooks in the base menu, or through
the REST API with a session or a personal access token with the webhook:manage scope:
| Method and path | What it does |
|---|---|
GET /api/v1/bases/{baseId}/webhooks |
The base's webhooks (never their secrets) |
POST /api/v1/bases/{baseId}/webhooks |
Create one; the answer's secret is shown only this once |
PATCH /api/v1/bases/{baseId}/webhooks/{webhookId} |
Change the URL, description, events or filters; enabled; { "rotateSecret": true } returns a new secret (the old one still signs for 24 hours); { "expirePreviousSecret": true } ends that grace period now |
DELETE /api/v1/bases/{baseId}/webhooks/{webhookId} |
Delete it and its delivery log |
GET /api/v1/bases/{baseId}/webhooks/{webhookId}/deliveries?limit=50 |
Its delivery attempts of the last 7 days, newest first |
POST /api/v1/bases/{baseId}/webhooks/{webhookId}/ping |
Send a signed ping now and answer with the attempt |
POST /api/v1/bases/{baseId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver |
Queue a logged event again, with the same event id ({ "queued": true, "eventId": "…" }); 409 for a ping, a webhook that is off, or an event past the 7-day event retention |
POST /api/v1/bases/{baseId}/webhooks
{
"url": "https://example.com/hooks/openrows",
"description": "Sync to the CRM",
"events": ["record.created", "record.updated", "record.deleted"],
"tableIds": ["<table id>"],
"fieldIds": null
}
- events:
record.created,record.updated,record.deleted,record.restored(out of the trash, or a record revision restore that brings one back),form.submitted(a public form's submission; delivered asrecord.createdto webhooks that don't ask for it),field.created,field.updated,field.deleted,field.restored,table.created,table.updated,table.deleted,table.restored,comment.created,comment.updated(an edit or a reaction) andcomment.deleted. - tableIds: only these tables' events (
null: every table, including new ones). - fieldIds: record updates only when one of these fields changed (
nullor[]: any field). - A base has at most 10 webhooks. The URL must be
httpson port 443 at a public address (a self-hosted server can allow more withOUTBOUND_HTTP_*, see the deployment docs). It is checked when saved and again before every delivery.
Deliveries
Each event is one POST with Content-Type: application/json and these headers:
| Header | Value |
|---|---|
X-OpenRows-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)> |
X-OpenRows-Event-Id |
The event's id, the same on every retry: skip ids you already handled |
X-OpenRows-Event-Type |
record.updated, form.submitted, ping, … |
{
"id": "4b0c6f2e-…",
"type": "record.updated",
"timestamp": "2026-10-26T10:00:00.000Z",
"webhookId": "…",
"base": { "id": "…", "name": "Roadmap" },
"table": { "id": "…", "name": "Tasks" },
"record": {
"id": "…",
"before": { "<field id>": "Draft" },
"after": { "<field id>": "Shipped" }
},
"fields": { "<field id>": { "name": "Status", "type": "singleSelect" } },
"actor": { "id": "…", "name": "Ada" }
}
record.created(andform.submitted):afterholds every cell,beforeisnull.record.updated: the changed cells before and after (beforeisnullfor changes made by links on the other side).record.deleted:beforeholds the record's cells,afterisnull.record.restored: likerecord.created,afterholds every cell.field.*:fieldis{ id, name, type }.table.*:tablenames the table.comment.*:commentis{ id, recordId, body, authorId, mentions, createdAt, editedAt };bodyis sanitized Markdown with@[name](user:<id>)mention tokens, andnull(likeauthorIdand the dates) forcomment.deleted.actorisnullfor a public form's submission.- Payloads only hold what the webhook's creator may read. A webhook whose creator lost access to the base is turned off.
Answer with any 2xx within 10 seconds. Redirects are not followed. Anything else (an error
status, a timeout, a refused address) is retried 7 times with growing delays: about 7.5
minutes, 15 minutes, 30 minutes, 1, 2, 4 and 8 hours (each up to twice that, at random), about
a day in all. Deliveries aren't ordered: use timestamp and the event id. A webhook that fails
10 times in a row over at least a day is turned off and its creator gets an email; fix the
receiver, then turn it on again (the count starts afresh). Every attempt is in the delivery log
for 7 days, with its status, duration and the first 1 KiB of the response. A failed delivery has
a Redeliver button there (or POST …/deliveries/{deliveryId}/redeliver): the worker sends
the event again, as it is stored, with the same event id and the next attempt number; it counts
like any attempt.
Verifying signatures
Check every request before you trust it:
- Read the raw body as received (before parsing the JSON).
- Split
X-OpenRows-Signatureintotand itsv1values: there are two for 24 hours after a secret rotation, one per secret. - Refuse a
tmore than 5 minutes from your clock: that is the replay protection (a captured request can't be sent again later), together with skipping event ids you already handled. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as hex and compare it with eachv1in constant time; accept the request when one matches.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, header, rawBody, toleranceSeconds = 300) {
const parts = header.split(',').map((p) => p.split('=', 2));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return parts
.filter(([k]) => k === 'v1')
.some(([, v]) => {
const given = Buffer.from(v ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Express: app.post('/hooks/openrows', express.text({ type: 'application/json' }), (req, res) => {
// if (!verify(process.env.OPENROWS_WEBHOOK_SECRET, req.get('X-OpenRows-Signature'), req.body)) {
// return res.sendStatus(401);
// }
// const event = JSON.parse(req.body);
// res.sendStatus(204);
// });
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
try:
t = int(next(v for k, v in parts if k == "t"))
except (StopIteration, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")
Rotating the secret (PATCH … { "rotateSecret": true } or "Rotate secret" in the app) returns
the new one once. The old one keeps signing for 24 hours (previousSecretExpiresAt on the
webhook): every delivery and ping carries a v1 for each, so a receiver still on the old secret
and one already on the new one both pass. Switch your receiver over within the day. If the old
secret leaked, end the grace period at once with { "expirePreviousSecret": true } (or rotate
with both). Rotating again within the 24 hours keeps only the secret just replaced as the
previous one.