Webhooks

Get a signed HTTP POST on your server when an article is published, a scan finishes or the growth board changes.

What webhooks are for

The article API is pull: your site asks for the list of published articles when it wants to. Webhooks are the push half. Add an endpoint under Settings > Webhooks, pick the events it should hear about, and Recited sends a signed JSON POST to it the moment one happens. The usual job is to drop a cache or trigger a rebuild so a new article is live on your site within seconds.

Events

EventWhen it fires
article.publishedAn article went live for the first time.
article.updatedA live article was published again, with new content or new metadata. Edits are drafts until you press Publish changes.
article.unpublishedA live article was taken down. The article API now returns 404 for it.
article.deletedAn article was deleted. Only its id, slug and title are sent.
scan.completedA visibility scan finished for one region.
opportunities.detectedThe growth board was refreshed after a scan. One event per scan, with counts.

article.published and article.updated carry the whole published article: the same fields the article API returns, plus markdown with the full body. Save it straight from the event; you do not need an API key to receive articles. The signature is what makes the body trustworthy, so verify it first. article.unpublished and article.deleted send metadata only. The article API remains the way to backfill everything already published, or to resync if you ever miss a delivery.

The request

Every delivery is a POST with a JSON body:

json
{
  "id": "evt_5f1c9d2e8a7b4c3d9e0f1a2b3c4d5e6f",
  "type": "article.published",
  "createdAt": "2026-09-23T09:12:41.000Z",
  "projectId": "your-project-id",
  "test": false,
  "data": {
    "article": {
      "id": "3f7c2a1e-5b7d-4c1a-9e2f-8a6d4b3c2e10",
      "slug": "best-aeo-tools",
      "title": "The best AEO tools in 2026",
      "status": "published",
      "publishedAt": "2026-09-23T09:12:41.000Z",
      "updatedAt": "2026-09-23T09:12:41.000Z",
      "url": "https://app.recitedai.com/p/3f7c2a1e-5b7d-4c1a-9e2f-8a6d4b3c2e10",
      "apiUrl": "https://app.recitedai.com/api/v1/seo/articles/3f7c2a1e-5b7d-4c1a-9e2f-8a6d4b3c2e10",
      "tags": ["category:Guides", "aeo"],
      "metaTitle": "The best AEO tools in 2026",
      "metaDescription": "Which answer engine optimisation tools are worth paying for.",
      "metaImageUrl": "https://app.recitedai.com/images/og/best-aeo-tools.png",
      "metaImageAlt": "Six AEO tools compared on one board",
      "canonicalUrl": null,
      "noindex": false,
      "markdown": "# The best AEO tools in 2026\n\n**Recited AI, Peec AI and Profound lead the field…"
    }
  }
}

Headers on every request:

  • Recited-Signature: t=<unix seconds>,v1=<hex>, the HMAC described below
  • Recited-Event: the event type
  • Recited-Event-Id: the event id, the same for every endpoint that received this event and for a resend
  • Recited-Delivery-Id: unique per attempt

test is true for events sent from the Send test event button. They use a realistic sample payload and are never retried.

Verify the signature

Each endpoint has its own signing secret, shown once when you add it and again behind Reveal on the endpoint page. The signature is HMAC-SHA256 with that secret over the string <t>.<raw body>, where t is the timestamp from the header and the body is the exact request text. Compute it from the raw text, not from a parsed and re-serialised object, compare in constant time, and reject timestamps older than five minutes.

ts
import { createHmac, timingSafeEqual } from "crypto";

export async function POST(request: Request) {
  const body = await request.text();
  const header = request.headers.get("recited-signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = createHmac("sha256", process.env.RECITED_WEBHOOK_SECRET!)
    .update(`${parts.t}.${body}`)
    .digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  if (!fresh || given.length !== 32 || !timingSafeEqual(given, Buffer.from(expected, "hex"))) {
    return new Response("invalid signature", { status: 401 });
  }
  const event = JSON.parse(body);
  // do your work here, or queue it
  return Response.json({ received: true });
}

After rotating a secret, deliveries signed with the old one stop verifying straight away, so update your server first and rotate second.

Respond quickly, retry is ours

Return any 2xx status within ten seconds. Do slow work after responding, or queue it. Any other status, a timeout or a connection error counts as a failure and the delivery is retried five more times over about nine hours (30 seconds, 5 minutes, 30 minutes, 2 hours, 6 hours). Deliveries can arrive more than once and, rarely, out of order; use the event id to make your handler idempotent.

After ten events in a row fail every retry, the endpoint is switched off and shown as Disabled with the reason. Fix it, send a test event, then enable it again from the endpoint menu.

Debugging

The endpoint page lists every delivery with its status, response code, attempts and timing. Open one to see the exact request body and the response your server returned, and resend it with one click. Send test event posts a sample of any event type, signed like a real one, and shows the response in place.

Two common set-ups

A Next.js site that pulls from the article API. Subscribe to the article events, verify the signature, call revalidateTag on the tag your fetch uses, and return 200. The next request renders the new article.

A site with its own database. Subscribe to the article events, verify the signature, upsert data.article (id, slug, title, markdown and metadata) into your table on article.published and article.updated, remove it on article.unpublished and article.deleted, and trigger your host's deploy hook if the site is static. Run one backfill from the article API when you first connect.

Requirements

  • Endpoints use https. Plain http is refused except for localhost in development.
  • Up to ten endpoints per project.
  • Managing endpoints needs the project admin role, the same as API keys.