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.

Send back the published article URL

Send the URL once the content is live. This works for generated articles, guest posts and restored pages. For an order, the URL also tracks publication. A 2xx receipt alone does not give us that URL.

1. You publish right away: answer with the URL

Add a root-level url field to your JSON 200 response if the content is already live. post_url, published_url and article_url also work. Respond within 30 seconds. Keep the body within 4 KiB and send it within 5 seconds of the headers. Return the same URL for duplicate deliveries.

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

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

2. You publish later: call us back

If you publish later, respond with 200 or 202 without a URL. Then call the callback with {article_id, url}. Use article.id for articles, or page.id for restored pages. The field name stays article_id. The test event does not support callbacks.

Use the site’s publication secret as UTF-8 text. Do not hex-decode it. Send a fresh ISO 8601 timestamp: new Date().toISOString(). The allowed clock difference is 5 minutes. Unix timestamps are rejected. Sign timestamp + a dot + the exact JSON you send. Use sha256= followed by the lowercase hex HMAC. After a secret rotation, use the new secret.

import crypto from 'node:crypto';

// call this once the article is ACTUALLY live
async function reportPublishedUrl(articleId, url) {
  const secret = process.env.LQ_WEBHOOK_SECRET; // the same secret you use to verify our deliveries
  const body = JSON.stringify({
    article_id: articleId, // article.id; for resurrection.published, use page.id
    url,
  });
  const timestamp = new Date().toISOString();
  const signature =
    'sha256=' +
    crypto.createHmac('sha256', secret)
          .update(`${timestamp}.${body}`)  // sign the exact string you send, not a re-serialized object
          .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 }
}

Callback responses

200
URL saved. unchanged: true means it was already known.
400 / 413
Invalid body, missing fields or body larger than 16 KiB.
401 invalid_signature
Check the secret, exact JSON, timestamp and content ID. This also covers unknown content or an inactive webhook.
422 url_not_on_site_domain
The URL does not match the site’s domain.
408 / 429 / 5xx
Retry with increasing delays. Respect Retry-After when present. Keep the same ID and URL, but compute a fresh timestamp and signature.

The URL must use HTTP or HTTPS on the site’s domain or a subdomain. Off-domain URLs are ignored in synchronous responses and rejected by the callback.

Expected response and best practices

  • Respond 2xx quickly — any other response counts as a failure; the timeout is 30 seconds. Process asynchronously if needed and acknowledge right away.
  • Deduplicate with delivery_id — a failed delivery is retried (up to 3 attempts): the same delivery_id can therefore arrive several times.
  • Public endpoint required — internal addresses (localhost, private IPs) are rejected. HTTPS strongly recommended.
  • User-Agent — requests 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 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' });

  // 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