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).
How it behaves
Retries
A delivery succeeds when your endpoint answers 2xx within 5 seconds. Anything else, a non-2xx, a timeout,
a connection error, is retried on a widening schedule, up to 10 attempts in total:
| Attempt | Delay before it | Time since the first attempt |
|---|---|---|
| 1 | immediate | 0 |
| 2 | 3s | ~3s |
| 3 | 7s | ~10s |
| 4 | 20s | ~30s |
| 5 | 55s | ~1m 25s |
| 6 | 2m 28s | ~3m 53s |
| 7 | 6m 43s | ~10m 37s |
| 8 | 18m 17s | ~28m 53s |
| 9 | 49m 41s | ~1h 19m |
| 10 | 2h 15m | ~3h 34m |
Each endpoint is delivered to independently, so one endpoint that is down never delays another. Every attempt is listed in Settings → Integrations & API → Webhooks → Deliveries, with the status code, how long it took and the first 2 KB of what your endpoint answered.
Deliveries arrive at least once
The same event can reach you twice: your 2xx may be lost on the way back, or an attempt that took longer than
5 seconds is treated as failed and retried even though your handler finished. So make the handler idempotent.
The webhook-id header is the deduplication key. It stays the same across every attempt of one event, while
webhook-timestamp and webhook-signature change per attempt (each attempt is signed fresh, or the five
minute replay window would reject a retry sent hours later). Store the id in a unique-indexed table or a cache
before processing, and skip the payload when it is already there.
Each delivery also carries User-Agent: ArticlesQ-Webhooks/1.0.
Endpoints that keep failing get switched off
An endpoint is disabled after 5 consecutive deliveries have used up all 10 attempts, and only when nothing has been delivered to it for 3 days. A single success resets the counter, so an endpoint that is merely flaky stays on. The project owner gets an email naming the endpoint and quoting what it last answered, and the settings page shows when it happened and how many deliveries failed. Fix the endpoint, then switch it back on there.
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.