Intégration

Webhooks

Événements, signature, déduplication et nouvelles tentatives.

Événements

license.created, license.activated, license.expired, license.renewed, license.revoked, license.suspended, license.device_reset, user.created, user.banned, application.disabled, et webhook.test depuis le dashboard.

json
{
  "id": "1f0c…",
  "type": "license.revoked",
  "created_at": "2026-10-22T10:00:00.000Z",
  "organization_id": "…",
  "application_id": "…",
  "data": { "license": { "id": "…", "key_preview": "LIC-••••-••••-••••-6A12", "status": "revoked", "expires_at": null, "max_devices": 1 }, "reason": "remboursement" }
}

En-têtes

En-têteContenu
Keyline-Event-IdIdentifiant unique : clé de déduplication
Keyline-Event-TypeType d’événement
Keyline-Delivery-IdIdentifiant de la livraison
Keyline-TimestampSecondes unix de la tentative
Keyline-Signaturev1=hex(HMAC-SHA256(secret, "ts.corps")), deux valeurs pendant une rotation

Réception (Express)

typescript
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();
const seen = new Set<string>(); // en production : table avec contrainte d'unicité

// Corps brut obligatoire : la signature porte sur les octets exacts.
app.post('/keyline/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = Number(req.header('Keyline-Timestamp'));
  const body = req.body.toString('utf8');
  const expected = createHmac('sha256', process.env.KEYLINE_WEBHOOK_SECRET!).update(`${ts}.${body}`).digest();
  const valid = Math.abs(Date.now() / 1000 - ts) <= 300 &&
    String(req.header('Keyline-Signature')).split(',').some((part) => {
      const sig = Buffer.from(part.trim().replace(/^v1=/, ''), 'hex');
      return sig.length === expected.length && timingSafeEqual(sig, expected);
    });
  if (!valid) return res.status(401).end();

  const eventId = req.header('Keyline-Event-Id')!;
  if (seen.has(eventId)) return res.status(200).end(); // déjà traité (retry)
  seen.add(eventId);

  const event = JSON.parse(body);
  switch (event.type) {
    case 'license.revoked': /* … */ break;
    case 'user.banned': /* … */ break;
  }
  res.status(200).end(); // répondre en moins de 10 s ; traiter en asynchrone si besoin
});

Nouvelles tentatives

10 tentatives au maximum, délai 30 s × 2^(n-1) (±20 %, plafond 6 h). Réponse 2xx attendue en moins de 10 s ; les redirections ne sont pas suivies. Après 25 livraisons définitivement en échec consécutives, le webhook est désactivé. Les destinations privées, loopback ou internes sont refusées.