Doc développeur

Webhook de publication

Recevez les articles LinkQuiver en POST JSON signé HMAC sur votre propre endpoint. Le canal de publication pour les sites non-WordPress : Next.js, Symfony, Astro, CMS headless…

Comment ça marche

À chaque date planifiée, LinkQuiver envoie une requête POST application/json à l'URL que vous avez configurée. Le corps contient l'article complet (HTML + Markdown + image de couverture). Vérifiez la signature, répondez 2xx pour accuser réception, puis faites-en ce que vous voulez : publier, stocker, transformer.

Configuration

Dashboard → votre site → onglet Intégrations → section Webhook. Enregistrez votre URL : le secret de signature (64 caractères hexadécimaux) est affiché une seule fois à la création. Stockez-le côté serveur (variable d'environnement, jamais dans du code client). Secret perdu ? Régénérez-le depuis le même écran puis mettez à jour votre receveur.

En-têtes envoyés à chaque appel

  • X-LQ-Event — type d'événement (ex. article.published)
  • X-LQ-Delivery-Id — identifiant unique de la livraison (= idempotency_key, pour dédupliquer)
  • X-LQ-Timestamp — horodatage ISO 8601 de l'envoi, à inclure dans le calcul HMAC
  • X-LQ-Signature — sha256=<hex> : la signature HMAC à vérifier

Compatibilité : chaque requête porte aussi les mêmes valeurs sous les anciens noms X-BLE-* (X-BLE-Signature, X-BLE-Timestamp…). Les receveurs existants continuent de fonctionner ; utilisez X-LQ-* pour toute nouvelle intégration.

Vérifier la signature

Chaque requête est signée HMAC SHA-256 avec votre secret. La chaîne signée est la concaténation du timestamp, d'un point et du corps brut :

signature = "sha256=" + hex( HMAC_SHA256( secret, timestamp + "." + body ) )
  1. Lisez les en-têtes X-LQ-Signature et X-LQ-Timestamp.
  2. Concaténez timestamp + "." + corps BRUT de la requête (les octets reçus, pas un objet re-sérialisé).
  3. Calculez le HMAC SHA-256 avec votre secret, encodez en hexadécimal, préfixez de sha256=.
  4. Comparez avec X-LQ-Signature en temps constant (timingSafeEqual ou équivalent).
  5. Recommandé : rejetez si l'écart entre maintenant et timestamp dépasse 5 minutes (anti-rejeu).

Signez le corps BRUT reçu, jamais un objet re-sérialisé : JSON.stringify peut réordonner les clés et casser la signature.

Événements possibles

  • test — envoyé par le bouton « Tester » du dashboard
  • article.published — nouvel article publié (objet article)
  • article.link_inserted — article existant mis à jour avec un lien inséré (objet article + link_insertion)
  • resurrection.published — page d'un domaine ressuscité (objet page au lieu de article)

Exemple de payload (article.published)

{
  "event": "article.published",
  "delivery_id": "9f1c2e7a-4b3d-4f8a-9c10-2b6e5d8a1f33",
  "idempotency_key": "9f1c2e7a-4b3d-4f8a-9c10-2b6e5d8a1f33",
  "timestamp": "2026-06-26T10:00:00.000Z",
  "site": {
    "domain": "villaor.fr",
    "vdl_website_id": "b2a1c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
  },
  "article": {
    "id": "c7d8e9f0-1234-5678-9abc-def012345678",
    "subject": "Location villa avec piscine en Provence",
    "title": "Louer une villa avec piscine en Provence : le guide",
    "slug": "louer-villa-piscine-provence-guide",
    "html": "<h1>Louer une villa…</h1><p>…</p>",
    "markdown": "# Louer une villa…\n\n…",
    "meta_title": "Villa avec piscine en Provence | Guide",
    "meta_description": "Tout pour louer la bonne villa…",
    "cover_image_url": "https://…/cover.jpg",
    "scheduled_at": "2026-06-26T10:00:00.000Z"
  }
}

Images : valides 30 jours

Les images (la couverture cover_image_url ET les <img> dans le HTML/Markdown) ne sont garanties que 30 jours : passé ce délai, leurs URLs renvoient une 404. Téléchargez-les et réhébergez-les sur votre propre stockage dès réception.

Renvoyer l'URL de l'article publié

Renvoie l’URL une fois le contenu en ligne. Cela concerne les articles générés, les articles invités et les pages restaurées. Pour une commande, cette URL sert aussi au suivi de la publication. Un accusé de réception 2xx seul ne nous donne pas cette URL.

1. Tu publies tout de suite : réponds l'URL

Ajoute « url » à ta réponse JSON 200 si le contenu est déjà publié. Les clés « post_url », « published_url » et « article_url » sont aussi acceptées, à la racine. Réponds en moins de 30 secondes. Le corps doit faire au plus 4 Kio et être transmis en moins de 5 secondes après les en-têtes. Sur un doublon, renvoie la même URL.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "received": true,
  "url": "https://villaor.fr/louer-villa-piscine-provence-guide"
}

2. Tu publies plus tard : rappelle-nous

Si tu publies plus tard, réponds 200 ou 202 sans URL. Appelle ensuite le callback avec {article_id, url}. Utilise article.id pour un article, ou page.id pour une page restaurée. Le champ reste nommé article_id. L’événement test n’accepte pas de callback.

Signe avec le secret de publication du site, utilisé comme texte UTF-8. Ne le décode pas en hexadécimal. Utilise un timestamp ISO 8601 frais : new Date().toISOString(). L’écart autorisé est de 5 minutes. Un timestamp Unix est refusé. Signe timestamp + « . » + le JSON exact envoyé. La signature doit être sha256= suivi du HMAC en hexadécimal minuscule. Après une rotation, utilise le nouveau secret.

import crypto from 'node:crypto';

// a appeler une fois l'article REELLEMENT en ligne
async function reportPublishedUrl(articleId, url) {
  const secret = process.env.LQ_WEBHOOK_SECRET; // le meme secret que pour verifier nos livraisons
  const body = JSON.stringify({
    article_id: articleId, // article.id ; pour resurrection.published, utiliser page.id
    url,
  });
  const timestamp = new Date().toISOString();
  const signature =
    'sha256=' +
    crypto.createHmac('sha256', secret)
          .update(`${timestamp}.${body}`)  // on signe la chaine envoyee, pas un objet re-serialise
          .digest('hex');

  const res = await fetch('https://linkquiver.com/api/webhooks/publication-callback', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-LQ-Timestamp': timestamp,
      'X-LQ-Signature': signature,
    },
    body,
  });

  const result = await res.json();
  if (!res.ok) {
    throw new Error('Callback HTTP ' + res.status + ': ' + (result.error ?? 'unknown'));
  }
  return result; // { ok: true } ou { ok: true, unchanged: true }
}

Réponses du callback

200
URL enregistrée. unchanged: true signifie qu’elle était déjà connue.
400 / 413
Corps invalide, champs manquants ou taille supérieure à 16 Kio.
401 invalid_signature
Vérifie le secret, le JSON exact, le timestamp et l’identifiant du contenu. Ce code couvre aussi un contenu inconnu ou un webhook inactif.
422 url_not_on_site_domain
L’URL ne correspond pas au domaine du site.
408 / 429 / 5xx
Réessaie avec un délai croissant. Respecte Retry-After s’il est présent. Garde le même identifiant et la même URL, mais recalcule le timestamp et la signature.

L’URL doit utiliser HTTP ou HTTPS, sur le domaine du site ou un sous-domaine. Une URL hors domaine est ignorée dans une réponse synchrone et refusée par le callback.

Réponse attendue et bonnes pratiques

  • Répondez 2xx rapidement — toute autre réponse compte comme un échec ; le timeout est de 30 secondes. Traitez en asynchrone si besoin et accusez réception tout de suite.
  • Dédupliquez avec delivery_id — une livraison échouée est retentée (jusqu'à 3 tentatives) : le même delivery_id peut donc arriver plusieurs fois.
  • Endpoint public obligatoire — les adresses internes (localhost, IP privées) sont refusées. HTTPS fortement recommandé.
  • User-Agent — les requêtes portent le User-Agent LinkQuiver-Webhook/1.0, utile si vous filtrez en amont.

Exemple complet : Node.js / Express

import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.LQ_WEBHOOK_SECRET; // le secret affiché une seule fois
const app = express();
app.use(express.raw({ type: 'application/json' })); // IMPORTANT : on signe le corps BRUT, pas un objet re-sérialisé

app.post('/api/webhook', (req, res) => {
  const signature = req.header('X-LQ-Signature') ?? '';
  const timestamp = req.header('X-LQ-Timestamp') ?? '';
  const body = req.body.toString('utf8');

  const expected =
    'sha256=' +
    crypto.createHmac('sha256', SECRET)
          .update(`${timestamp}.${body}`)
          .digest('hex');

  const receivedBytes = Buffer.from(signature, 'utf8');
  const expectedBytes = Buffer.from(expected, 'utf8');
  const ok =
    receivedBytes.length === expectedBytes.length &&
    crypto.timingSafeEqual(receivedBytes, expectedBytes);
  if (!ok) return res.status(401).json({ reason: 'invalid_signature' });

  // optionnel : rejeter si |maintenant - timestamp| > 5 min
  const payload = JSON.parse(body); // signature valide → traitez le payload
  console.log(payload.event, payload.article?.title);

  return res.status(200).json({ received: true });
});

Prêt à brancher votre site ?

Configurez votre webhook en deux minutes depuis les connexions de votre dashboard, puis validez avec l'événement test.

Configurer dans le dashboard