Setup webhooks
Receive article events in your app and verify that they really came from ArticlesQ.
Add an endpoint
Open Settings → Integrations & API → Webhooks in your project, add the URL you want us to call, and pick
the events you care about. We show you a signing secret starting with whsec_ — copy it into your app's
environment as ARTICLESQ_WEBHOOK_SECRET.
Each endpoint gets its own secret, so staging and production never share one.
What we send
A POST with a JSON body and three headers:
| Header | Value |
|---|---|
webhook-id | Unique id for this delivery. Use it to ignore an event you already handled. |
webhook-timestamp | Seconds since the epoch, part of the signature. |
webhook-signature | v1,<base64 HMAC-SHA256> over <webhook-id>.<webhook-timestamp>.<body>. |
{
"type": "article.published",
"timestamp": "2026-06-01T09:00:00.000Z",
"article": {
"id": "clz9k2h1a0000abcd1234efgh",
"slug": "best-crm-for-startups",
"projectId": "proj_abc123"
}
}The event tells you what changed, not the content. Fetch GET /articles/{slug} for the article itself, so
you always render the current version.
Verify the signature
We follow the Standard Webhooks specification, so most languages have an official library that verifies a delivery in one call.
Verification recomputes the HMAC from the id, the timestamp and the exact body you received, then compares it to the header in constant time. That is why you must pass the raw body: parsing and re-serialising it first changes the bytes, and the signature no longer matches.
// npm install standardwebhooks
import { revalidatePath } from "next/cache";
import { Webhook, WebhookVerificationError } from "standardwebhooks";
export async function POST(request: Request) {
const payload = await request.text();
let event: { type: string; article: { slug: string } };
try {
event = new Webhook(process.env.ARTICLESQ_WEBHOOK_SECRET!).verify(payload, {
"webhook-id": request.headers.get("webhook-id") ?? "",
"webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
"webhook-signature": request.headers.get("webhook-signature") ?? "",
}) as { type: string; article: { slug: string } };
} catch (error) {
if (error instanceof WebhookVerificationError) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
return Response.json({ error: "Invalid payload" }, { status: 400 });
}
revalidatePath("/blog");
revalidatePath(`/blog/${event.article.slug}`);
return Response.json({ received: true });
}In the Next.js example, revalidatePath marks those routes stale, so the next visitor gets a freshly rendered
page carrying the new article instead of the copy built at deploy time. If your article data is fetched through
a cache tag, call revalidateTag once instead of listing every path.
Answer first, work afterwards: we wait up to 5 seconds for a response, so a rebuild or a cache purge belongs
after you reply (in Next.js, after() from next/server).
Testing your handler
Publish or edit an article in the dashboard, then check your logs for the delivery. A 401 means the secret in
your app does not match the endpoint's, so rotate it in settings and copy the new value across.
Update an article PATCH
Update editable fields on an article — currently `content` and `metaDescription`. Send only the fields you want to change. Returns the full updated article.
Article published Webhook
Sent when an article becomes public. Every delivery is signed: verify the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers with the endpoint's signing secret before acting on the body. See Setup webhooks for a copy-paste handler.