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:

HeaderValue
webhook-idUnique id for this delivery. Use it to ignore an event you already handled.
webhook-timestampSeconds since the epoch, part of the signature.
webhook-signaturev1,<base64 HMAC-SHA256> over <webhook-id>.<webhook-timestamp>.<body>.
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.

app/webhooks/articlesq/route.ts
// 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:

AttemptDelay before itTime since the first attempt
1immediate0
23s~3s
37s~10s
420s~30s
555s~1m 25s
62m 28s~3m 53s
76m 43s~10m 37s
818m 17s~28m 53s
949m 41s~1h 19m
102h 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.

On this page