Developer docs

Publication webhook

Receive LinkQuiver articles as HMAC-signed JSON POST requests on your own endpoint. The publication channel for non-WordPress sites: Next.js, Symfony, Astro, headless CMS…

How it works

At each scheduled date, LinkQuiver sends a POST application/json request to the URL you configured. The body contains the full article (HTML + Markdown + cover image). Verify the signature, respond with a 2xx to acknowledge receipt, then do whatever you want with it: publish, store, transform.

Setup

Dashboard → your site → Integrations tab → Webhook section. Save your URL: the signing secret (64 hex characters) is shown only once at creation. Store it server-side (environment variable, never in client code). Lost the secret? Regenerate it from the same screen, then update your receiver.

Headers sent with every call

  • X-LQ-Event event type (e.g. article.published)
  • X-LQ-Delivery-Id unique delivery identifier (= idempotency_key, for deduplication)
  • X-LQ-Timestamp ISO 8601 timestamp of the send, to include in the HMAC computation
  • X-LQ-Signature sha256=<hex>: the HMAC signature to verify

Compatibility: every request also carries the same values under the legacy X-BLE-* names (X-BLE-Signature, X-BLE-Timestamp…). Existing receivers keep working; use X-LQ-* for any new integration.

Verifying the signature

Every request is signed with HMAC SHA-256 using your secret. The signed string is the concatenation of the timestamp, a dot and the raw body:

signature = "sha256=" + hex( HMAC_SHA256( secret, timestamp + "." + body ) )
  1. Read the X-LQ-Signature and X-LQ-Timestamp headers.
  2. Concatenate timestamp + "." + the RAW request body (the bytes received, not a re-serialized object).
  3. Compute the HMAC SHA-256 with your secret, hex-encode it, prefix with sha256=.
  4. Compare with X-LQ-Signature using a constant-time comparison (timingSafeEqual or equivalent).
  5. Recommended: reject if the gap between now and timestamp exceeds 5 minutes (anti-replay).

Sign the RAW body you received, never a re-serialized object: JSON.stringify can reorder keys and break the signature.

Possible events

  • test sent by the dashboard's "Test" button
  • article.published new article published (article object)
  • article.link_inserted existing article updated with an inserted link (article + link_insertion objects)
  • resurrection.published page of a resurrected domain (page object instead of article)

Example 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: valid for 30 days

Images (the cover_image_url cover AND the <img> tags in the HTML/Markdown) are only guaranteed for 30 days: after that, their URLs return a 404. Download and rehost them on your own storage as soon as you receive them.

Expected response and best practices

  • Respond 2xx quicklyany other response counts as a failure; the timeout is 30 seconds. Process asynchronously if needed and acknowledge right away.
  • Deduplicate with delivery_ida failed delivery is retried (up to 3 attempts): the same delivery_id can therefore arrive several times.
  • Public endpoint requiredinternal addresses (localhost, private IPs) are rejected. HTTPS strongly recommended.
  • User-Agentrequests carry the User-Agent LinkQuiver-Webhook/1.0, useful if you filter upstream.

Full example: Node.js / Express

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

const SECRET = process.env.LQ_WEBHOOK_SECRET; // the secret shown only once
const app = express();
app.use(express.raw({ type: 'application/json' })); // IMPORTANT: sign the RAW body, not a re-serialized object

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 ok =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!ok) return res.status(401).json({ reason: 'invalid_signature' });

  // optional: reject if |now - timestamp| > 5 min
  const payload = JSON.parse(body); // valid signature → process the payload
  console.log(payload.event, payload.article?.title);

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

Ready to plug in your site?

Set up your webhook in two minutes from your dashboard connections, then validate with the test event.

Set up in the dashboard