Webhooks
Have your pod POST a signed request to your URL when something changes.
A webhook subscription tells your pod to send an HTTP POST to a URL you own whenever an event you chose happens:
an entity was created, a proposal was approved, a document was updated, and so on.
Create a subscription
Webhooks are managed through the tRPC router webhooks (with the SDK or a signed-in session):
const { subscription, secret } = await synap.webhooks.create.mutate({
name: "CRM sync",
url: "https://example.com/hooks/synap",
eventTypes: ["entity.create.completed", "proposal.approved.completed"],
// secret: "…" optional; generated (32 random bytes, hex) when omitted
});eventTypesneeds at least one event type. Pick them from the event catalog.- The
secretis returned once, at creation.webhooks.listnever returns it. Store it to verify signatures. webhooks.updatechangesname,url,eventTypesoractive;webhooks.deleteremoves the subscription.- A subscription created with
webhooks.createForWorkspacereceives only that workspace's events.
The Hub REST API also exposes webhooks under /api/hub/webhooks for API-key callers. See the pod's
/api/hub/openapi.json for the exact shapes.
What your endpoint receives
Most deliveries use the event.v1 format:
POST /hooks/synap HTTP/1.1
Content-Type: application/json
User-Agent: Synap-Webhook/1.0
X-Synap-Webhook-Format: event.v1
X-Synap-Delivery-Id: 6f1c…
X-Synap-Event-Type: entity.create.completed
X-Synap-Event-Id: <id of the subject, for example the entity>
X-Synap-Signature: <hex HMAC-SHA256 of the body>
{ "type": "entity.create.completed", "subjectId": "…", "userId": "…", "data": { … }, "timestamp": "…" }Chat events and entity property updates use the chat.v1 format instead: body { event, data, timestamp }, header
X-Synap-Event instead of X-Synap-Event-Type, and the signature prefixed with sha256=. Read
X-Synap-Webhook-Format to tell them apart.
Verify the signature
The signature is the HMAC-SHA256 of the raw request body, keyed with the subscription's secret, in hex. Compute it over the bytes you received, before parsing the JSON:
import { createHmac, timingSafeEqual } from "node:crypto";
export function isValidSynapSignature(rawBody: Buffer, header: string | undefined, secret: string): boolean {
if (!header) return false;
const received = header.startsWith("sha256=") ? header.slice(7) : header; // chat.v1 adds the prefix
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
return received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}Retries and duplicates
A delivery that fails is retried with backoff. Every retry sends the same body with the same signature and the
same X-Synap-Delivery-Id, so use that ID to ignore a delivery you already processed. Answer with a 2xx status
quickly and do the work afterwards.
Which events are sent
The pod sends a webhook for each change recorded with a subject and an action, as
<subject>.<action>.completed: entity.create.completed, document.update.completed,
proposal.approved.completed and so on. The event catalog lists the
event types the pod defines. A subscription receives an event only if its type is listed in eventTypes.
Changes that arrive through pod-to-pod sync are not sent again as webhooks.
