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

A business change becomes an event, then a signed delivery to your endpoint, retried until it succeedsBusiness changee.g. invoice paidEvent outboxinside every workerSigned deliveryHMAC-SHA256 + timestampYour endpointanswer 2xx within 10 snon-2xx or timeout: retry from 15 s, up to 1 h apart, 8 attemptsone workspace's events only
Delivery runs inside every OpsAPI worker using the plugin event outbox, so there's no extra service to run. Each webhook keeps a delivery log with status, timing, retries and a Redeliver button.

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.

EntityBusiness events
invoicesent, paid, partially_paid, overdue, cancelled
orderconfirmed, shipped, delivered, cancelled, paid, refunded
crm.dealwon, lost
crm.leadqualified, converted, lost
timesheetsubmitted, approved, rejected
kanban.taskcompleted, blocked
fs.jobscheduled, started, completed, cancelled
memberjoined, 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

Verifying a webhook: rebuild the signature, compare in constant time, check the age, then handle onceRead raw body+ X-Opsapi-TimestampHMAC-SHA256timestamp + '.' + bodyCompare + ageconstant time, < 5 minHandle onceskip event ids seenReject
Sign the timestamp and the raw body together, so a captured request can't be replayed later with a fresh timestamp.
// 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 id and 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.

An editor triggers the CMS webhook; the website verifies it and refetches published postsEditorpublishes, then notifiesOpsAPI CMSPOST …/triggerYour websitechecks the signatureRefresh contentrevalidate or rebuildGET /api/v2/public/cms/:namespace/posts
The website re-reads published posts from the public CMS endpoint, which needs no token. A server-rendered site can revalidate its cache; a static site can start a rebuild.
Workspace webhooksCMS webhook
FiresAutomatically, on every subscribed eventWhen someone calls POST /api/v2/cms/webhooks/:uuid/trigger
EventsAny entity or business eventpost.created, post.updated, post.deleted
SignatureHMAC of timestamp + '.' + bodyHMAC-SHA256 of the raw body
HeaderX-Opsapi-Signature-256X-Opsapi-Signature-256
RetriesYes, with a delivery logNo: 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 posts

Over the API

Everything the dashboard does is available with a JWT or API key, X-Namespace-Id and the webhooks permission:

MethodPath
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