Laver docs
Open Laver
Agents and API

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#

bash
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"]
  }'
json
{
    "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 .local or .internal name;
  • 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:

text
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.

js
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#

bash
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#

bash
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.