---
title: Outbound webhooks
description: Get a signed POST when a ticket changes, instead of polling for it. Events, the signature scheme, retries, and the delivery log.
section: Agents and API
order: 5
---

# 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](/docs/agents-and-api/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.
