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

  1. A row is inserted, updated or deleted in a table with Realtime enabled.
  2. 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.
  3. Each matching webhook gets its own signed POST. The attempt is recorded in the webhook's delivery log.

Create a webhook

In Studio

  1. Open the project and go to Settings → Webhooks. Project settings are only shown to workspace admins.
  2. Click New webhook and fill in:
    • Name: a label, for example Order created notifier.
    • Endpoint URL: a public http or https URL. See Allowed endpoints.
    • Events: any of INSERT, UPDATE and DELETE.
    • Table: optional. Setting a table enables Realtime on it. Leave it blank to receive every Realtime-enabled table.
  3. 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

Terminal
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 pathPurpose
GET /api/admin/projects/:projectId/webhooksList webhooks (secrets are never returned)
POST /api/admin/projects/:projectId/webhooksCreate a webhook. The response includes secret once
GET /api/admin/projects/:projectId/webhooks/:idGet one webhook
PATCH /api/admin/projects/:projectId/webhooks/:idUpdate any field
DELETE /api/admin/projects/:projectId/webhooks/:idDelete a webhook and its delivery log
POST /api/admin/projects/:projectId/webhooks/:id/testSend a ping event
GET /api/admin/projects/:projectId/webhooks/:id/deliveries?limit=20Recent deliveries (up to 100)

Create and update accept these fields:

FieldTypeNotes
namestringRequired on create, up to 120 characters
urlstringRequired on create. A public http or https URL; internal addresses return 400 (see Allowed endpoints)
eventsstring[]Any of INSERT, UPDATE, DELETE. Defaults to all three
table_namestring or nullExact 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
secretstringYour own signing secret. If omitted on create, one is generated (whsec_...)
headersobjectExtra headers sent with every delivery
is_activebooleanDefaults 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 http or https;
  • the host is localhost or ends in .localhost, .local, .internal, .corp or .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:

HeaderValue
Content-Typeapplication/json
User-AgentOrbitNest-Webhooks/1.0
X-Orbitnest-EventINSERT, UPDATE, DELETE or ping
X-Orbitnest-Signaturesha256=<hex HMAC-SHA256 of the raw body>

Any custom headers you configured are added to these.

The body for a row change:

JSON
{
  "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"
}
FieldDescription
eventINSERT, UPDATE or DELETE
delivered_atWhen this delivery was sent (ISO 8601)
table, schemaWhere the change happened
recordThe row after the change. null for DELETE
old_recordThe full row before the change. null for INSERT
commit_tsWhen 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:

JSON
{
  "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:

server.ts
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 2xx response counts as success.
  • A network error, timeout or 5xx response is retried once, immediately. A 4xx response is treated as a permanent rejection and isn't retried.
  • Redirects are not followed. A 3xx response 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_ts to 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

SymptomCheck
Test works but row changes never arriveFor 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'tThe 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 3xxThe endpoint resolves to an internal address, or redirects. See Allowed endpoints and Delivery and retries.
Nothing is logged at allThe webhook is paused, or the event isn't selected.
Your endpoint rejects every signatureYou're hashing parsed or re-serialized JSON. Hash the raw body.
record is null on an UPDATEThe row is too large for the change notification. See Large rows.