Outbound webhooks#
A webhook endpoint is a URL Laver posts to when something changes in your workspace. It is the push half of Concurrency and polling — use it when you would otherwise poll on a timer and mostly find nothing.
Endpoints are managed by a workspace owner or admin, either under Admin → User access → Webhooks or through the API with a session token. An API key cannot manage them.
Register an endpoint#
curl -X POST https://api.laver.app/admin/workspaces/$WORKSPACE/webhooks \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/laver",
"description": "Deploy bot",
"events": ["ticket.moved", "ticket.assigned"]
}'
{
"webhook": {
"uuid": "11111111-2222-4333-8444-555555555555",
"url": "https://example.com/hooks/laver",
"description": "Deploy bot",
"events": ["ticket.moved", "ticket.assigned"],
"is_active": true,
"created_at": "2026-08-03T09:20:00.512Z"
},
"secret": "whsec_9f2c…"
}
| Field | Required | Notes |
|---|---|---|
url |
yes | Must be https. An http URL is refused with 400 |
description |
no | Up to 200 characters, for your own benefit in the list |
events |
no | Which events to send. Omit it for all of them, including future ones |
The secret is returned once, on create, and never again. Listing
endpoints deliberately omits it — it signs payloads, so it is a credential. If
you lose it, delete the endpoint and register a new one.
An unknown event name in events is rejected at registration rather than
accepted and silently never matched.
Events#
| Event | Sent when |
|---|---|
ticket.created |
A ticket is created |
ticket.updated |
A ticket's fields change |
ticket.moved |
A ticket changes column |
ticket.assigned |
Someone is added to a ticket |
ticket.unassigned |
Someone is removed from a ticket |
comment.created |
A comment is posted |
wiki_page.updated |
A wiki page is saved |
Verify the signature#
Every delivery carries an X-Laver-Signature header:
X-Laver-Signature: t=1785734400,v1=1b2c3d…
v1 is HMAC-SHA256 of ${t}.${raw_request_body}, keyed with your endpoint
secret, hex-encoded. This is the same construction Stripe uses, so if you have
verified a Stripe webhook before, the code is the code you already have.
import crypto from 'node:crypto';
function verify(raw_body, header, secret) {
const parts = Object.fromEntries(
header.split(',').map(pair => pair.split('='))
);
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${raw_body}`)
.digest('hex');
// Constant-time: a plain === leaks the answer one byte at a time.
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1)
);
// Reject anything older than five minutes, or a captured payload can be
// replayed forever.
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return ok && fresh;
}
Two things matter:
- Sign the raw body, before any JSON parsing. Re-serialising changes the bytes and the signature will not match.
- Check
t. The timestamp is inside the signed string precisely so you can reject an old one; a signature alone is valid forever.
Retries#
A delivery is attempted up to 5 times. The gap grows by 5× each time — roughly 1 minute, 5 minutes, 25 minutes, then a little over 2 hours — so a short outage at your end is ridden out without being hammered. Each attempt waits up to 10 seconds for a response.
Return a 2xx promptly. Anything else counts as a failure and is retried;
after the fifth attempt the delivery is marked failed and left in the log.
Deliveries are queued inside the same transaction as the change that caused them, so an event is never sent for something that then rolled back, and is not lost if the process dies before the HTTP call.
The delivery log#
curl "https://api.laver.app/admin/workspaces/$WORKSPACE/webhooks/$ENDPOINT/deliveries?limit=50" \
-H "Authorization: Bearer $SESSION_TOKEN"
Each row carries event, status, attempts, the response_status and a
truncated response_body from your server, any error, next_attempt_at
while it is still being retried, and delivered_at once it succeeded. This is
the first place to look when something did not arrive: it distinguishes "we
never sent it" from "we sent it and your server said 500".
List and delete#
curl https://api.laver.app/admin/workspaces/$WORKSPACE/webhooks \
-H "Authorization: Bearer $SESSION_TOKEN"
curl -X DELETE https://api.laver.app/admin/workspaces/$WORKSPACE/webhooks/$ENDPOINT \
-H "Authorization: Bearer $SESSION_TOKEN"
The list also returns available_events, so you can offer the current set
without hard-coding it.
What this is not#
- No replay endpoint. A failed delivery is visible in the log but cannot be re-sent from the API; re-read the affected records instead.
- No per-event secrets. One secret per endpoint.
- No rotation. An endpoint's secret is fixed for its life; to change it, delete the endpoint and register a new one.