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.
The signature
Section titled “The signature”Every post has a citedseo-signature header:
citedseo-signature: t=1791079500,v1=5f2b6c0e9a…tis when we signed the post, in Unix seconds.v1is 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.
The check
Section titled “The check”-
Read the request body as raw text. Don’t parse it and write it out again: a re-serialised body won’t match.
-
Split the header into
tandv1. -
Refuse the post if
tis more than five minutes from now. That stops an old post from being replayed. -
Work out the HMAC-SHA256 of
<t>.<body>with your secret, in hex. -
Compare it to
v1in constant time. If they differ, answer400and 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:
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:
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.
Without the package, on Node’s own crypto:
import { createHmac, timingSafeEqual } from 'node:crypto';
const tolerance = 300; // seconds
export function verifyCitedSEO(secret, body, header) { const parts = Object.fromEntries((header ?? '').split(',').map((part) => part.split('=', 2))); const t = Number(parts.t); if (!Number.isInteger(t) || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')); const given = Buffer.from(parts.v1); return expected.length === given.length && timingSafeEqual(expected, given);}With Express, read the body raw on this route:
import express from 'express';import { verifyCitedSEO } from './verify.js';
const app = express();
app.post('/webhooks/citedseo', express.text({ type: 'application/json' }), (req, res) => { const ok = verifyCitedSEO( process.env.CITEDSEO_WEBHOOK_SECRET, req.body, req.get('citedseo-signature'), ); if (!ok) return res.status(400).send('Bad signature');
const { event, data } = JSON.parse(req.body); res.sendStatus(204);});import hashlibimport hmacimport time
TOLERANCE = 300 # seconds
def verify_citedseo(secret: str, body: bytes, header: str | None) -> bool: parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part) try: t = int(parts["t"]) given = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - t) > TOLERANCE: return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, given)With Flask:
import jsonimport os
from flask import Flask, request
from verify import verify_citedseo
app = Flask(__name__)
@app.post("/webhooks/citedseo")def citedseo(): body = request.get_data() header = request.headers.get("citedseo-signature") if not verify_citedseo(os.environ["CITEDSEO_WEBHOOK_SECRET"], body, header): return "Bad signature", 400
post = json.loads(body) # post["event"], post["data"] return "", 204<?php
const TOLERANCE = 300; // seconds
function verify_citedseo(string $secret, string $body, ?string $header): bool{ $parts = []; foreach (explode(',', $header ?? '') as $part) { [$name, $value] = array_pad(explode('=', $part, 2), 2, ''); $parts[$name] = $value; } $t = $parts['t'] ?? ''; $given = $parts['v1'] ?? ''; if (!ctype_digit($t) || $given === '') { return false; } if (abs(time() - (int) $t) > TOLERANCE) { return false; }
$expected = hash_hmac('sha256', $t . '.' . $body, $secret); return hash_equals($expected, $given);}
$body = file_get_contents('php://input');$header = $_SERVER['HTTP_CITEDSEO_SIGNATURE'] ?? null;
if (!verify_citedseo(getenv('CITEDSEO_WEBHOOK_SECRET'), $body, $header)) { http_response_code(400); exit('Bad signature');}
$post = json_decode($body, true);// $post['event'], $post['data']http_response_code(204);