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 computationX-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 ) )- Read the X-LQ-Signature and X-LQ-Timestamp headers.
- Concatenate timestamp + "." + the RAW request body (the bytes received, not a re-serialized object).
- Compute the HMAC SHA-256 with your secret, hex-encode it, prefix with sha256=.
- Compare with X-LQ-Signature using a constant-time comparison (timingSafeEqual or equivalent).
- 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" buttonarticle.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 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 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