Platform
Webhooks
Send a signed HTTP POST to your own endpoint whenever rows are inserted, updated or deleted in your project's tables.
Database webhooks call your endpoint when rows change. Use them to sync another system, kick off a job, or notify a service. Each delivery is a JSON POST signed with HMAC-SHA256, so your endpoint can check that it came from OrbitNest.
Webhooks use the Realtime trigger
Webhooks are triggered by the same database trigger that powers Realtime. When you create or update a webhook with a table, OrbitNest enables Realtime on that table for you. A webhook for "all tables" only fires for tables that already have Realtime enabled. No WebSocket subscribers need to be connected.
How it works
- A row is inserted, updated or deleted in a table with Realtime enabled.
- OrbitNest checks every active webhook in the project. A webhook matches if the event is one of its selected events and either it has no table filter or the table name matches exactly.
- Each matching webhook gets its own signed
POST. The attempt is recorded in the webhook's delivery log.
Create a webhook
In Studio
- Open the project and go to Settings → Webhooks. Project settings are only shown to workspace admins.
- Click New webhook and fill in:
- Name: a label, for example
Order created notifier. - Endpoint URL: a public
httporhttpsURL. See Allowed endpoints. - Events: any of
INSERT,UPDATEandDELETE. - Table: optional. Setting a table enables Realtime on it. Leave it blank to receive every Realtime-enabled table.
- Name: a label, for example
- Click Create webhook, then copy the signing secret. It is shown once and can't be retrieved later.
Each webhook in the list has buttons to send a test event, pause or activate it, and delete it. Expand a webhook to see its 10 most recent deliveries.
With the CLI
orbitnest webhooks create --url https://example.com/hooks/orbitnest \
--name orders --events INSERT,UPDATE --table orders
orbitnest webhooks list
orbitnest webhooks test <id>
orbitnest webhooks delete <id>Commands target the linked project. Use --project <id> to target another one. --events defaults to all three events. See CLI.
With the MCP server
AI agents can use orbitnest_list_webhooks, orbitnest_create_webhook, orbitnest_test_webhook and orbitnest_delete_webhook. See MCP server.
With the REST API
Webhook management is an admin API. It authenticates with a Studio access token (the same session the CLI uses), not with project API keys. :projectId is the project's UUID.
| Method and path | Purpose |
|---|---|
GET /api/admin/projects/:projectId/webhooks | List webhooks (secrets are never returned) |
POST /api/admin/projects/:projectId/webhooks | Create a webhook. The response includes secret once |
GET /api/admin/projects/:projectId/webhooks/:id | Get one webhook |
PATCH /api/admin/projects/:projectId/webhooks/:id | Update any field |
DELETE /api/admin/projects/:projectId/webhooks/:id | Delete a webhook and its delivery log |
POST /api/admin/projects/:projectId/webhooks/:id/test | Send a ping event |
GET /api/admin/projects/:projectId/webhooks/:id/deliveries?limit=20 | Recent deliveries (up to 100) |
Create and update accept these fields:
| Field | Type | Notes |
|---|---|---|
name | string | Required on create, up to 120 characters |
url | string | Required on create. A public http or https URL; internal addresses return 400 (see Allowed endpoints) |
events | string[] | Any of INSERT, UPDATE, DELETE. Defaults to all three |
table_name | string or null | Exact name of a table in the public schema. Omit or set to null for all tables. Setting it enables Realtime on the table, keeping any existing Realtime settings. An unknown table, or one whose name starts with auth_, returns 400 |
secret | string | Your own signing secret. If omitted on create, one is generated (whsec_...) |
headers | object | Extra headers sent with every delivery |
is_active | boolean | Defaults to true. Set to false to pause |
Custom headers and a custom or changed secret are only available through the API. Studio can't edit them.
Allowed endpoints
Deliveries are sent from OrbitNest's servers, so webhook URLs must point at the public internet. When you create or update a webhook, the URL is rejected with 400 (Webhook url is not allowed: ...) if:
- the scheme isn't
httporhttps; - the host is
localhostor ends in.localhost,.local,.internal,.corpor.lan; - the host is, or resolves to, a loopback, private, link-local (including cloud metadata), carrier-grade NAT, multicast or reserved address, or doesn't resolve at all.
The same check runs again before every delivery, and the request goes to the address that was checked, so a hostname that later resolves to an internal address is blocked too. Such a delivery is logged as failed with Blocked URL: ... and isn't retried.
Events and payload
Deliveries are POST requests with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | OrbitNest-Webhooks/1.0 |
X-Orbitnest-Event | INSERT, UPDATE, DELETE or ping |
X-Orbitnest-Signature | sha256=<hex HMAC-SHA256 of the raw body> |
Any custom headers you configured are added to these.
The body for a row change:
{
"event": "UPDATE",
"delivered_at": "2026-10-01T12:00:00.123Z",
"table": "orders",
"schema": "public",
"record": { "id": 42, "status": "paid", "total": 99.5 },
"old_record": { "id": 42, "status": "pending", "total": 99.5 },
"commit_ts": "2026-10-01T12:00:00.101Z"
}| Field | Description |
|---|---|
event | INSERT, UPDATE or DELETE |
delivered_at | When this delivery was sent (ISO 8601) |
table, schema | Where the change happened |
record | The row after the change. null for DELETE |
old_record | The full row before the change. null for INSERT |
commit_ts | When the database trigger ran (UTC) |
Large rows
If a change notification would exceed about 7,900 bytes, the database sends a compact notification without row data. The delivery still arrives, but record and old_record are both null, so read the current state from the table yourself.
The test event sent by Send test, orbitnest webhooks test or the /test endpoint looks like this:
{
"event": "ping",
"delivered_at": "2026-10-01T12:00:00.000Z",
"table": null,
"message": "This is a test event from OrbitNest."
}Verify the signature
The signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the webhook's signing secret. Compute it over the exact bytes you received, before any JSON parsing, and compare in constant time:
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
const secret = process.env.ORBITNEST_WEBHOOK_SECRET!;
app.post('/hooks/orbitnest', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + createHmac('sha256', secret).update(req.body).digest('hex');
const received = req.get('X-Orbitnest-Signature') ?? '';
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send('bad signature');
}
const payload = JSON.parse(req.body.toString('utf8'));
// Reject stale deliveries: delivered_at is covered by the signature
if (Date.now() - Date.parse(payload.delivered_at) > 5 * 60 * 1000) {
return res.status(400).send('stale');
}
// ...handle payload.event / payload.record
res.sendStatus(204);
});The signature doesn't include a separate timestamp, so use the signed delivered_at field to reject replayed requests.
Delivery and retries
- Each attempt times out after 10 seconds.
- Any
2xxresponse counts as success. - A network error, timeout or
5xxresponse is retried once, immediately. A4xxresponse is treated as a permanent rejection and isn't retried. - Redirects are not followed. A
3xxresponse is logged as a failed delivery (HTTP 301, for example) and isn't retried, so point the webhook at the final URL. - There are no further retries, and failed events can't be re-sent. Events aren't stored before delivery, so a change that happens while OrbitNest's listener is disconnected is not delivered later.
- Deliveries to different webhooks, and for successive changes, are sent concurrently. Don't rely on arrival order. Use
commit_tsto order events.
Make handlers idempotent and reconcilable
Respond quickly with a 2xx and do slow work asynchronously. Design handlers so a repeated event is harmless, and if you need every change, periodically reconcile from the table itself.
Delivery log
Every delivery, including tests, is logged with the event, table, HTTP status code, success flag, error message, number of attempts (1 or 2) and timestamp. Studio shows the 10 most recent per webhook. The API returns up to 100.
Troubleshooting
| Symptom | Check |
|---|---|
| Test works but row changes never arrive | For an "all tables" webhook, Realtime isn't enabled on the table. See Realtime. For a webhook with a table, Realtime may have been turned off on it since; send a PATCH that includes table_name to turn it back on. |
| Some tables fire and others don't | The webhook's table filter must match the table name exactly. "All tables" webhooks only fire for tables with Realtime enabled. |
Every delivery fails with Blocked URL or HTTP 3xx | The endpoint resolves to an internal address, or redirects. See Allowed endpoints and Delivery and retries. |
| Nothing is logged at all | The webhook is paused, or the event isn't selected. |
| Your endpoint rejects every signature | You're hashing parsed or re-serialized JSON. Hash the raw body. |
record is null on an UPDATE | The row is too large for the change notification. See Large rows. |