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.
To react to the same events inside Laver rather than in your own service, see Automation rules — the trigger names there are these event names, and the two are meant to be read together.
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 a public https address. See below |
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.
A workspace may hold 20 endpoints. The twenty-first is refused with 409,
and deleting one frees the slot — deactivating one does not, because an
inactive endpoint is still registered. Twenty is twenty destinations, not
twenty events: one endpoint can subscribe to everything, and usually should.
Which URLs are accepted#
The endpoint must be a public https address reached by hostname. Refused with
400:
http— a signed payload sent in the clear defeats the point of signing it;- an IP address in place of a hostname, v4 or v6;
localhost, and any.localor.internalname;- credentials in the URL (
https://user:pass@host/); - any hostname that resolves to a private, loopback, link-local or otherwise non-public address.
Any port is fine — TLS on 8443 is normal.
That last rule is checked again on every delivery attempt, not only when you register. If your hostname stops resolving to a public address, deliveries stop with that error rather than being retried.
Events#
| Event | Sent when |
|---|---|
ticket.created |
A ticket is created |
ticket.updated |
A ticket's fields change |
ticket.moved |
A ticket changes column or board |
ticket.assigned |
Someone is added to a ticket |
ticket.unassigned |
Someone is removed from a ticket |
ticket.archived |
A ticket is deleted, singly or in bulk |
ticket.restored |
A ticket is restored from the trash |
ticket.duplicated |
A ticket is created as a copy of another |
ticket.title_changed |
A ticket's title changes |
ticket.priority_changed |
A ticket's priority changes |
ticket.due_date_changed |
A ticket's due date is set, changed, cleared |
ticket.label_added |
A label is put on a ticket |
ticket.label_removed |
A label is taken off a ticket |
comment.created |
A comment is posted |
wiki_page.updated |
A wiki page is saved |
wiki_page.published |
A wiki page is put on the public web |
wiki_page.unpublished |
A wiki page is taken off the public web |
board.published |
A board is put on the public web |
board.unpublished |
A board is taken off the public web |
The four publishing events carry the same names their audit records have always
carried, so a subscriber and the audit log call the same occurrence the same
thing. wiki_page.published and board.published include public_token — the
link itself, so whatever receives the event can go and look at what was exposed.
The unpublished pair deliberately does not: the token has just been destroyed,
and republishing mints a different one.
These are the events to point at a security channel. "What is public right
now?" is a different question, and it is answered by
GET /workspaces/{workspace_uuid}/published and by Published to the web in
User access — an event stream tells you what changed, not what is true.
ticket.updated fires for any field edit and tells you only which field names
were sent, in a changed array. The field-specific events below it carry from
and to, which is the difference that matters if you are reacting to a
particular transition rather than logging that something happened. Both fire, so
subscribe to one or the other rather than both unless you want it twice.
A field written with the value it already held is not a change and sends nothing. That is deliberate: without it, anything that reacts to an event by writing back can retrigger itself.
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.
Redirects are not followed. A 3xx fails the delivery immediately and is
not retried, because retrying it would only produce the same redirect. The
rules above are checked against the URL you registered, so following a redirect
would take the request to a host they never judged. If your endpoint redirects
— http to https, or on to a trailing slash — register the final URL.
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.
Each pass of the sender takes a turn from every workspace that has something waiting, so a backlog belongs to whoever built it. Your queue is unaffected by how far behind anyone else's endpoints are, and within your workspace events still go out oldest first.
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 your
server answered with, 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".
The log records your server's status code, not its response body. The body is read and discarded — returning it would have made the delivery log a way to read back whatever any URL answered with, from inside our network.
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.