Webhooks
A webhook is an HTTPS POST that Livstick sends to an endpoint you register, each time something happens to a message or a gallery. Every delivery is signed, every delivery may arrive more than once, and deliveries are not guaranteed to arrive in the order the events happened.
Delivery guarantees
Read these rules before writing a receiver.
- At least once. A delivery can repeat, for example after a timeout on your side. Deduplicate on the Livstick-Event-Id header.
- No ordering. A message.ready can arrive before the message.held of the same message, and gallery events can arrive in any order. Use the state in the payload, not the arrival order.
- Retries for about 24 hours. A delivery that is not answered with a 2xx is retried on the schedule below, then set aside.
- No personal data. The payload carries identifiers and states only: no name, text, e-mail, address, media URL, edit key or token.
Event catalogue
Seven events. Subscribe an endpoint to all of them, or to a subset.
| Event | When it is sent |
|---|---|
| message.held | A message recorded with hold is encoded. It is not visible yet. |
| message.ready | A message is encoded and published, or a held message is released. For the main message of a gallery, this is the only event. |
| gallery.message_added | A gallery contribution becomes visible for good. |
| gallery.viewed | The recipient opens a gallery for the first time. |
| message.viewed | The recipient sees a message for the first time. |
| reaction.created | A reaction is recorded: quick, video or thanks. The reaction field tells which. |
| message.deleted | A message is deleted by the API, the portal, a moderator or the hold purge. The reason field tells which. |
Payload
The body is JSON, at most 16 KiB. The same body is sent whatever the mode of the container, and the set of keys is fixed for each event.
{
"id": "evt_3f9c0a1b2c3d4e5f60718293a4b5c6d7",
"type": "message.held",
"createdAt": "2026-10-20T09:15:41Z",
"platformId": "<platform id>",
"data": {
"crid": "CAG-8F2K",
"urid": "4hc8wq2x1m",
"mrid": "DON-51873",
"messageId": "Qm4Zt8LpXa",
"role": "contributor",
"state": "held",
"revision": 1,
"durationMs": 41000
}
}
- id is stable for the event. The same event always has the same id, across retries.
- mrid is null when the message has none.
- reason is present on message.deleted: author, client, portal or hold_expired.
- reaction is present on reaction.created: {"kind": "quick" | "video" | "thanks", "replyMessageId": "…"}. Its messageId and mrid are those of the message the reaction targets.
- messageId, role, state, revision and durationMs are absent or null on gallery.viewed.
Headers
Each delivery carries these headers.
| Header | Value |
|---|---|
| Content-Type | application/json |
| User-Agent | Livstick-Webhooks/1 |
| Livstick-Event-Id | The event id, evt_…. Use it to deduplicate. |
| Livstick-Event-Type | The event type, for example message.ready. |
| Livstick-Delivery-Id | The delivery id, dlv_…. One per event and endpoint. |
| Livstick-Delivery-Attempt | The attempt number, from 1 to 8. |
| Livstick-Signature | t=<unix seconds>,v1=<hex>. Two v1 values appear while a rotated secret is still valid. |
Verify the signature
The signature is an HMAC-SHA256 over the timestamp, a dot, and the raw body, keyed with the full secret string (it starts with whsec_). Verify the bytes you received, before any JSON parsing, and reject a timestamp more than 5 minutes away from your clock. Compare in constant time. Accept the event if any v1 value matches.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
// rawBody: the exact bytes received (Buffer or string), not re-serialised JSON.
export function verifyLivstickSignature(rawBody, header, secret, now = Date.now()) {
let timestamp = null;
const signatures = [];
for (const item of header.split(",")) {
const i = item.trim().indexOf("=");
if (i < 0) return false;
const key = item.trim().slice(0, i);
const value = item.trim().slice(i + 1);
if (key === "t") timestamp = value;
else if (key === "v1") signatures.push(value);
}
if (!timestamp || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return signatures.some((hex) => {
if (!/^[0-9a-f]{64}$/i.test(hex)) return false;
return crypto.timingSafeEqual(Buffer.from(hex, "hex"), expected);
});
}
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
var errSignature = errors.New("invalid Livstick-Signature")
// Verify checks the Livstick-Signature header against the raw request body.
func Verify(header, secret string, body []byte, now time.Time) error {
var ts string
var candidates [][]byte
for _, item := range strings.Split(header, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(item), "=")
if !ok {
return errSignature
}
switch key {
case "t":
ts = value
case "v1":
if sig, err := hex.DecodeString(value); err == nil && len(sig) == sha256.Size {
candidates = append(candidates, sig)
}
}
}
sent, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errSignature
}
if d := now.Sub(time.Unix(sent, 0)); d > tolerance || d < -tolerance {
return errSignature
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, candidate := range candidates {
if hmac.Equal(candidate, expected) {
return nil
}
}
return errSignature
}
Retries and failures
Your endpoint must answer with a 2xx within 10 seconds. Any other answer, a timeout or a network error counts as a failed attempt. Livstick retries on this schedule, counted from the failed attempt.
| Attempt | Wait before the next attempt |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 1 hour |
| 5 | 3 hours |
| 6 | 6 hours |
| 7 | 13 hours |
| 8 | Last attempt, with no further wait. If it fails, the delivery is set aside as failed |
- The whole schedule lasts about 23 hours and 36 minutes.
- A 4xx answer, including 410, is retried too: you can fix your server during the window.
- A failed delivery is kept in the delivery log, and it can be replayed from the Livstick client portal.
- A paused endpoint receives nothing: its pending deliveries are skipped, and they are not retried.
Endpoints and secrets
Endpoints are created and managed in the Livstick client portal, by a user with the right to manage webhooks. A platform can have up to five endpoints.
- The URL must use https, with no credentials and no fragment. Addresses on private or loopback networks are refused.
- A secret starts with whsec_. It is shown in full once, when it is created or rotated. Store it in your secret manager.
- Rotating a secret keeps the previous one valid for 24 hours. During that time, the signature header carries two v1 values, and both verify.
- A test call sends a signed webhook.test event to the endpoint, so you can check your receiver without waiting for a real event.
Delivery log
GET /webhooks/deliveries lists recent deliveries for your platform, newest first, with the event type, container, mrid, state and attempt count. It never returns the body or the error text. Use it to find what your receiver missed, then replay it from the portal. The filters are eventType, state (pending, done, failed, skipped), crid, mrid, limit (1 to 100) and skip.