DevelopersWebhooks
Webhooks
Register an https endpoint and SAGARIS will POST a signed JSON envelope to it when something happens in your workspace. Delivery is durable: every event becomes a queued row that is retried on its own schedule rather than fired once and forgotten.
Registering an endpoint
Registering is an admin action, on the same four workspace roles that can mint an API key. A subscription is a persistent, signed, active outbound channel carrying contact records and reply content, so a lower-privilege member cannot stand one up. Deleting one and rotating its secret are admin actions for the mirror-image reason: a member should not be able to tear down or quietly re-point another admin's delivery channel.
You supply a target_url and, optionally, the event types you want. An empty list means every event. Any type you do list must be a known event type, so a typo is refused at registration rather than silently subscribing you to nothing.
The signing secret is generated server-side and returned exactly once, in the response to the registration call. Listing your subscriptions afterwards never echoes it. If you lose it, rotate it; rotation issues a new secret and is the only way to get one.
- The target must be https. An http url is refused.
- The hostname is resolved at registration and rejected if it points anywhere private, internal, or at a cloud metadata endpoint, so a subscription cannot be used to make the platform fetch something on its own network.
- That check is not only done once. It runs again immediately before every single delivery, because a host that resolved publicly at registration can be re-pointed later.
The nine events, and what emits each one
Nine event types exist. Each is listed here with the action that actually fires it, because the interesting question is not what the type list contains but which of your real workflows will produce one.
- contact.created
- A contact is created in the app.
- contact.stage_changed
- A contact's lifecycle stage moves.
- contact.unsubscribed
- An opt-out is recorded, whether from a consent change in the app, an unsubscribe or a reply handled by inbound mail processing.
- meeting.booked
- A meeting is booked.
- reply.received
- An inbound reply arrives, by email or by SMS.
- deal.closed_won
- An opportunity moves to closed won.
- deal.closed_lost
- An opportunity moves to closed lost.
- campaign.launched
- A campaign is launched.
- sequence.completed
- An enrollment reaches the completed state through a manual advance. See the limits below.
Two limits worth knowing before you design against these. Writes through the public REST API do not emit events at all: a contact created with POST /api/v1/contacts produces no contact.created. And sequence.completed is emitted only from the manual completion path, the one a rep triggers by completing a task or logging a LinkedIn send; the scheduler's own timer-driven completion is deliberately not wired to it. If you need to observe either, poll rather than wait for a webhook.
Emission is best effort by design, and never part of the transaction it reports. A failure to enqueue is swallowed rather than rolled back, on the reasoning that a webhook problem must never undo or block the business write that succeeded.
What arrives at your endpoint
A POST with a JSON body and three headers alongside the content type.
- x-sagaris-signature
- The HMAC of the raw body, in the form sha256=<hex>.
- x-sagaris-event
- The event type, so you can route without parsing the body first.
- x-sagaris-delivery
- The delivery id. Every retry of the same event carries the same id, which is what makes it usable as a de-duplication key.
The body is a normalized envelope carrying a schema version, a unique event id, a deterministic idempotency key, the event type, the workspace id, and the resource the event is about. The idempotency key is derived from the logical event, so the same underlying occurrence produces the same key even if it is emitted twice.
Verifying the signature
Compute an HMAC-SHA256 of the exact raw request body using your subscription secret, hex-encode it, prefix it with sha256=, and compare against the header. Compare in constant time. Do this before you parse or trust anything in the payload: the signature is the only thing distinguishing a genuine delivery from anyone who guessed your url.
import crypto from "node:crypto";const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");const a = Buffer.from(expected, "utf8");const b = Buffer.from(req.headers["x-sagaris-signature"] ?? "", "utf8");const valid = a.length === b.length && crypto.timingSafeEqual(a, b);Sign the raw body, not a re-serialized copy of the parsed JSON. Re-serializing can reorder keys or change whitespace, and the signature will then never match. Capture the body as text before your framework parses it.
Delivery, retries and dead letters
Emitting an event does not POST anything. It inserts one durable delivery row per active subscription that wants that type. A worker, scheduled to run every minute, claims the rows whose next attempt is due and POSTs them.
- Any 2xx is success. Everything else, including a timeout or a connection failure, is a failure.
- Each POST is given 10 seconds. A slow endpoint is treated as a transient failure and retried, never dropped.
- There are six attempts. The wait doubles from one minute: the retries after the first five attempts fall roughly one, two, four, eight and sixteen minutes later.
- After the sixth failed attempt the delivery is marked failed and stops being retried. The last response code and error text stay on the row so you can see why.
A delivery is claimed with a lease before it is sent, so two overlapping worker passes cannot both POST the same row. Your endpoint should still be idempotent: the delivery id is stable across retries precisely so you can discard one you have already processed.
Two conditions end a delivery immediately rather than retrying it, because retrying would be pointless or unsafe. If the subscription has been deleted or deactivated since the event was queued, the delivery is failed rather than sent into nothing. If the target url no longer passes the safety check at send time, the delivery is failed with no request made at all, rather than retried against a host that has been re-pointed somewhere private.
Operating a subscription
Listing your subscriptions returns each one with its latest delivery and its ten most recent deliveries, each carrying status, attempt count, response code, last error and the time of the next attempt. That is the first place to look when a subscriber has gone quiet.
- test
- Queue a delivery to the subscription so you can confirm the endpoint receives and verifies it.
- replay
- Requeue a specific delivery that ended in failed, once the receiver is fixed.
- rotate
- Issue a new signing secret, returned once. The only action permitted on an inactive subscription.
All three are admin actions and all three are scoped to your own workspace: a delivery is addressed by id together with the workspace and subscription that own it, so a replay can never reach another workspace's queue. Creating, rotating and deleting a subscription are each written to the workspace audit.
Behaviour on this page is read from
- src/lib/webhooks/webhook-delivery.ts
- src/lib/webhooks/emit-trigger-event.ts
- src/lib/api-trigger-event-builder.ts
- src/app/api/webhooks/subscriptions/route.ts
- src/app/api/webhooks/subscriptions/[id]/route.ts
- src/app/api/webhooks/subscriptions/[id]/actions/route.ts
- src/app/api/internal/webhooks/dispatch/route.ts
- src/lib/browser-actions/url-safety.ts
- src/lib/sequence-manual-advance.ts
- infra/terraform/gcp/cloud_scheduler_crons.tf
Was this page helpful?