Skip to content
Docs

Verify the signature

Anyone who learns your endpoint’s address can post to it. The signature is how your code tells a post from CitedSEO from any other. Check it on every request, before you trust anything in the body.

Every post has a citedseo-signature header:

citedseo-signature: t=1791079500,v1=5f2b6c0e9a…
  • t is when we signed the post, in Unix seconds.
  • v1 is an HMAC-SHA256, in hex, of the text <t>.<body> keyed with your endpoint’s signing secret, where <body> is the request body exactly as sent.

It is the scheme Stripe’s webhooks use, so if you have verified one of those, this is the same check.

  1. Read the request body as raw text. Don’t parse it and write it out again: a re-serialised body won’t match.

  2. Split the header into t and v1.

  3. Refuse the post if t is more than five minutes from now. That stops an old post from being replayed.

  4. Work out the HMAC-SHA256 of <t>.<body> with your secret, in hex.

  5. Compare it to v1 in constant time. If they differ, answer 400 and stop.

@citedseo/feed does the whole check, on Web Crypto, so it runs in Node.js 20+, Cloudflare Workers, Deno, Bun, and Vercel and Netlify functions. Every framework package exports the same functions.

webhookHandler is a whole route for anything that takes a standard Request:

webhook.ts
import { webhookHandler } from '@citedseo/feed';
export const POST = webhookHandler({
secret: process.env.CITEDSEO_WEBHOOK_SECRET!,
onPost: async ({ event, data }) => {
// event is "article.published" or "article.updated"; data is the article.
},
});

It answers 400 to a post that isn’t ours, 204 once onPost is done, and 500 when onPost throws, so the post is tried again. To answer yourself, use readWebhook, which gives the verified post or null:

webhook.ts
import { readWebhook } from '@citedseo/feed';
export async function POST(request: Request) {
const post = await readWebhook(request, process.env.CITEDSEO_WEBHOOK_SECRET!);
if (!post) return new Response('Bad signature', { status: 400 });
// post.event, post.delivery, post.data
return new Response(null, { status: 204 });
}

And where you have the raw body and the header some other way, verifyWebhook(secret, body, header) gives true or false.