Webhooks
Un webhook est un POST HTTPS que Livstick envoie à un endpoint que vous enregistrez, chaque fois qu’il se passe quelque chose sur un message ou une galerie. Chaque envoi est signé, chaque envoi peut arriver plusieurs fois, et rien ne garantit que les envois arrivent dans l’ordre où les événements se sont produits.
Garanties d’envoi
Lisez ces règles avant d’écrire un récepteur.
- Au moins une fois. Un envoi peut se répéter, par exemple après un timeout de votre côté. Dédoublonnez sur l’en-tête Livstick-Event-Id.
- Aucun ordre garanti. Un message.ready peut arriver avant le message.held du même message, et les événements de galerie peuvent arriver dans n’importe quel ordre. Fiez-vous à l’état indiqué dans le payload, pas à l’ordre d’arrivée.
- Nouvelles tentatives pendant environ 24 heures. Un envoi qui ne reçoit pas de réponse 2xx est retenté selon le calendrier ci-dessous, puis mis de côté.
- Aucune donnée personnelle. Le payload ne transporte que des identifiants et des états : ni nom, ni texte, ni e-mail, ni adresse, ni URL de média, ni clé d’édition, ni jeton.
Catalogue des événements
Sept événements. Abonnez un endpoint à tous, ou à une partie d’entre eux.
| Événement | Quand il est envoyé |
|---|---|
| message.held | Un message enregistré avec hold est encodé. Il n’est pas encore visible. |
| message.ready | Un message est encodé et publié, ou un message retenu est libéré. Pour le message principal d’une galerie, c’est le seul événement. |
| gallery.message_added | Une contribution à une galerie devient définitivement visible. |
| gallery.viewed | Le destinataire ouvre une galerie pour la première fois. |
| message.viewed | Le destinataire voit un message pour la première fois. |
| reaction.created | Une réaction est enregistrée : quick, video ou thanks. Le champ reaction précise laquelle. |
| message.deleted | Un message est supprimé par l’API, le portail, un modérateur ou la purge des messages retenus. Le champ reason précise l’origine. |
Payload
Le corps est en JSON, 16 Kio au plus. Le même corps est envoyé quel que soit le mode du conteneur, et l’ensemble des clés est fixe pour chaque événement.
{
"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 est stable pour l’événement. Un même événement a toujours le même id, d’une tentative à l’autre.
- mrid vaut null quand le message n’en a pas.
- reason est présent sur message.deleted : author, client, portal ou hold_expired.
- reaction est présent sur reaction.created : {"kind": "quick" | "video" | "thanks", "replyMessageId": "…"}. Ses messageId et mrid sont ceux du message visé par la réaction.
- messageId, role, state, revision et durationMs sont absents ou null sur gallery.viewed.
En-têtes
Chaque envoi porte ces en-têtes.
| En-tête | Valeur |
|---|---|
| Content-Type | application/json |
| User-Agent | Livstick-Webhooks/1 |
| Livstick-Event-Id | L’identifiant de l’événement, evt_…. Servez-vous-en pour dédoublonner. |
| Livstick-Event-Type | Le type d’événement, par exemple message.ready. |
| Livstick-Delivery-Id | L’identifiant de l’envoi, dlv_…. Un par événement et par endpoint. |
| Livstick-Delivery-Attempt | Le numéro de la tentative, de 1 à 8. |
| Livstick-Signature | t=<unix seconds>,v1=<hex>. Deux valeurs v1 apparaissent tant qu’un secret remplacé reste valide. |
Vérifier la signature
La signature est un HMAC-SHA256 calculé sur l’horodatage, un point et le corps brut, avec pour clé la chaîne complète du secret (elle commence par whsec_). Vérifiez les octets reçus, avant tout parsing JSON, et rejetez un horodatage qui s’écarte de plus de 5 minutes de votre horloge. Comparez en temps constant. Acceptez l’événement si l’une des valeurs v1 correspond.
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
}
Nouvelles tentatives et échecs
Votre endpoint doit répondre par un 2xx en moins de 10 secondes. Toute autre réponse, un timeout ou une erreur réseau compte comme une tentative échouée. Livstick réessaie selon ce calendrier, compté à partir de la tentative échouée.
| Tentative | Délai avant la tentative suivante |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 1 heure |
| 5 | 3 heures |
| 6 | 6 heures |
| 7 | 13 heures |
| 8 | Dernière tentative, sans délai supplémentaire. Si elle échoue, l’envoi est mis de côté comme échoué |
- L’ensemble du calendrier dure environ 23 heures et 36 minutes.
- Une réponse 4xx, y compris 410, est elle aussi retentée : vous pouvez corriger votre serveur pendant cette fenêtre.
- Un envoi échoué est conservé dans le journal des envois et peut être rejoué depuis le portail client Livstick.
- Un endpoint en pause ne reçoit rien : ses envois en attente sont ignorés et ne sont pas retentés.
Endpoints et secrets
Les endpoints se créent et se gèrent dans le portail client Livstick, par un utilisateur autorisé à gérer les webhooks. Une plateforme peut avoir jusqu’à cinq endpoints.
- L’URL doit utiliser https, sans identifiants ni fragment. Les adresses de réseaux privés ou de loopback sont refusées.
- Un secret commence par whsec_. Il n’est affiché en entier qu’une fois, à sa création ou à sa rotation. Stockez-le dans votre gestionnaire de secrets.
- La rotation d’un secret laisse le précédent valide pendant 24 heures. Pendant ce temps, l’en-tête de signature porte deux valeurs v1, et les deux sont valides.
- Un appel de test envoie à l’endpoint un événement webhook.test signé : vous pouvez ainsi vérifier votre récepteur sans attendre un vrai événement.
Journal des envois
GET /webhooks/deliveries liste les envois récents de votre plateforme, du plus récent au plus ancien, avec le type d’événement, le conteneur, le mrid, l’état et le nombre de tentatives. Il ne renvoie jamais le corps ni le texte de l’erreur. Servez-vous-en pour retrouver ce que votre récepteur a manqué, puis rejouez-le depuis le portail. Les filtres sont eventType, state (pending, done, failed, skipped), crid, mrid, limit (1 à 100) et skip.