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.

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 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:

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.

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#

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

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.