Webhooks
Have Sennith POST a signed event to your own endpoint whenever an update goes out.
Outgoing webhooks let Sennith call your system whenever something happens in a workspace. Every time an update is posted to one of your projects, Sennith sends a signed POST request to a URL you choose — so you can forward it to Slack, a database, an automation tool, or anything else.
This page is about outgoing webhooks — Sennith notifying you. If instead you want an external service (GitHub, Vercel, Trello and friends) to notify Sennith, that's a trigger — see Connections & integrations.
What gets sent, and when
Right now Sennith emits one real event:
| Event | When it fires |
|---|---|
update.created | An update is posted to a project the webhook is watching. |
ping | A test event you send yourself from the webhook's settings, to confirm your endpoint is reachable. |
Each webhook you create can be narrowed down so you only receive what you care about:
- Specific projects — or all of them.
- Only important updates — skip routine ones.
- Include customer details — attach the recipient's name and email to the payload, or leave them out.
Your endpoint URL must be served over HTTPS. Plain http:// URLs are rejected.
Creating a webhook
Add the endpoint
In your workspace settings, add a webhook: give it a name, paste your HTTPS URL, and choose which projects and updates it should receive.
Copy the signing secret
When the webhook is created, Sennith generates a signing secret. Keep it safe — you'll use it to verify that incoming calls really came from Sennith. You can regenerate it at any time (which invalidates the old one).
Send a test
Use the Test button to fire a ping at your endpoint and confirm it responds. Sennith shows you the HTTP status it got back.
You can also create and list webhooks through the API with a workspace key holding the outgoing_webhooks scope (GET/POST /api/v1/outgoing_webhooks). The signing secret is only ever shown in the web app, never returned by the API.
The payload
Sennith sends Content-Type: application/json. A update.created body looks like this:
{
"event": "update.created",
"delivery_id": 812,
"created_at": "2026-07-12T10:00:00Z",
"update": {
"id": 4021,
"title": "Staging is live",
"message": "Preview it at the link below.",
"content": null,
"type": "manual",
"is_important": true,
"created_at": "2026-07-12T10:00:00Z",
"public_link": "https://sennith.com/p/a1b2c3"
},
"project": { "id": 42, "name": "Website relaunch", "public_id": "a1b2c3" },
"customer": { "name": "Acme Inc.", "email": "hi@acme.example" }
}The customer block is only present if you enabled Include customer details for that webhook. This payload shape is a stable contract — you can build against it.
Verifying the signature
Every delivery carries three headers so you can prove it came from Sennith and hasn't been tampered with:
| Header | Contents |
|---|---|
X-Sennith-Event | The event type, e.g. update.created |
X-Sennith-Timestamp | Unix timestamp (seconds) when the request was signed |
X-Sennith-Signature | sha256=<hex> — an HMAC of the request |
Requests also carry User-Agent: Sennith-Webhooks/1.
To verify: compute an HMAC-SHA256 over the string <timestamp>.<raw request body> using your webhook's signing secret, prefix it with sha256=, and compare it — with a constant-time comparison — against the X-Sennith-Signature header. Use the raw request body exactly as received, before any JSON parsing.
import { createHmac, timingSafeEqual } from 'crypto'
function isFromSennith(rawBody, headers, secret) {
const timestamp = headers['x-sennith-timestamp']
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const got = headers['x-sennith-signature'] ?? ''
return got.length === expected.length &&
timingSafeEqual(Buffer.from(got), Buffer.from(expected))
}Also check that X-Sennith-Timestamp is recent (for example, within a few minutes) to guard against replayed requests.
Responses, retries, and delivery history
Your endpoint should answer quickly — Sennith waits up to 5 seconds — and return any 2xx status to acknowledge receipt. Anything else (an error status, a timeout, an unreachable host) counts as a failure and is retried.
Failed deliveries are retried up to five times with growing gaps:
1 min → 5 min → 30 min → 2 hours → 6 hoursAfter six total attempts without success, the delivery is marked dead and no longer retried. Because retries are possible, your endpoint should be idempotent — use the delivery_id (stable across retries of the same event) to ignore duplicates.
Every attempt is recorded, so you can review each webhook's recent deliveries — status, response code, and any error — in its settings.