Webhooks send a workspace's events to your own URL as they happen: an invoice is paid, a lead comes in, a task moves. Use them to connect OpsAPI to an ERP, Zapier, Make, n8n, Slack or your own code. They're set up in the dashboard under Dashboard → Webhooks; no code runs inside OpsAPI.
From change to delivery
Events
Every entity has <entity>.created, .updated and .deleted. Many also have business events that say what happened. A business event fires once, when a record enters the state: saving a paid invoice again doesn't repeat invoice.paid.
| Entity | Business events |
|---|---|
invoice | sent, paid, partially_paid, overdue, cancelled |
order | confirmed, shipped, delivered, cancelled, paid, refunded |
crm.deal | won, lost |
crm.lead | qualified, converted, lost |
timesheet | submitted, approved, rejected |
kanban.task | completed, blocked |
fs.job | scheduled, started, completed, cancelled |
member | joined, suspended, left |
Plugins can publish their own, such as helpdesk.ticket.closed. You only see entities enabled on your server, you can only subscribe to data your role can read, and a webhook only ever receives events from its own workspace.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: OpsAPI-Webhooks/1
X-Opsapi-Event: invoice.updated
X-Opsapi-Delivery: 81234
X-Opsapi-Timestamp: 1790753496
X-Opsapi-Signature-256: sha256=5f2c…
{
"id": "0b9f6c1e-…",
"type": "invoice.updated",
"created_at": "2026-09-30T07:31:36Z",
"namespace": { "id": "…", "slug": "acme" },
"data": {
"object": { "invoice_number": "INV-1042", "status": "paid", … },
"changes": { "status": { "from": "sent", "to": "paid" } }
}
}data.object is the record after the change (before it, for deletes) and mirrors the database row, so read it defensively. data.changes lists only the fields that changed.
Verifying a request
// Node.js (Express: use express.raw({ type: 'application/json' }) for the raw body)
const crypto = require('crypto');
function verify(req, rawBody) {
const ts = req.headers['x-opsapi-timestamp'];
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.OPSAPI_WEBHOOK_SECRET)
.update(ts + '.' + rawBody).digest('hex');
const given = req.headers['x-opsapi-signature-256'] || '';
return given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - Number(ts)) < 300;
}In TypeScript, the SDK does all of it, timestamp check included:
import { verifyWebhook } from '@opsapi/client';
const event = await verifyWebhook(rawBody, req.headers, { secret: process.env.OPSAPI_WEBHOOK_SECRET! });Retries and ordering
- Answer 2xx within 10 seconds; do slow work afterwards, for example on a queue. Connecting has a 3-second limit.
- Anything else is retried, waiting 15 s, 30 s, 1 min and so on, up to an hour between tries. After 8 attempts the delivery is marked failed; fix the endpoint and press Redeliver.
- At least once, in any order. Store the payload's
idand skip ids you've handled. Compare timestamps when order matters. - A paused webhook receives nothing, and events from while it was paused aren't sent later.
Security
- URLs must be
https://and resolve to public addresses. Private, loopback, link-local (cloud metadata) and cluster-internal destinations are refused when you save and on every send. - Signing secrets are encrypted at rest and shown once. Rotating one invalidates the old secret immediately.
- Limits: 25 webhooks per workspace and 100 events per webhook.
The CMS “website update” webhook
The CMS module has its own, simpler webhook for websites that publish OpsAPI blog content. It never fires on its own: an editor triggers it from the post editor's publish dialog or the Webhooks tab, so several edits can go out as one update.
| Workspace webhooks | CMS webhook | |
|---|---|---|
| Fires | Automatically, on every subscribed event | When someone calls POST /api/v2/cms/webhooks/:uuid/trigger |
| Events | Any entity or business event | post.created, post.updated, post.deleted |
| Signature | HMAC of timestamp + '.' + body | HMAC-SHA256 of the raw body |
| Header | X-Opsapi-Signature-256 | X-Opsapi-Signature-256 |
| Retries | Yes, with a delivery log | No: one synchronous attempt, result shown to the editor |
A Next.js receiver for the CMS webhook, as the Workstation website runs it:
const raw = await req.text();
const provided = (await headers()).get('x-opsapi-signature-256') || '';
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(raw).digest('hex');
const a = Buffer.from(provided), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return Response.json({ ok: false }, { status: 401 });
}
revalidateTag('cms-posts'); // the next request re-fetches published postsOver the API
Everything the dashboard does is available with a JWT or API key, X-Namespace-Id and the webhooks permission:
| Method | Path |
|---|---|
| GET | /api/v2/namespace/webhooks/events |
| GET / POST | /api/v2/namespace/webhooks |
| GET / PUT / DELETE | /api/v2/namespace/webhooks/:id |
| POST | /api/v2/namespace/webhooks/:id/rotate-secret |
| POST | /api/v2/namespace/webhooks/:id/test |
| GET | /api/v2/namespace/webhooks/:id/deliveries |
| POST | /api/v2/namespace/webhooks/:id/deliveries/:delivery_id/redeliver |