How it works
Next.js has no built-in blog editor, so StrideRank sends every finished article to a route in your app. The request is signed with a secret only you and StrideRank know. Your route checks the signature, saves the article wherever your site keeps its posts — a CMS, a database or files — and answers with the post’s ID and public address. The full format is described in Connect your own system (webhook).
- The route must be reachable over
https://on port 443 from the internet. Local and private addresses are refused. - Redirects are not followed — enter the final address.
- The route has 20 seconds to answer.
Set it up
Add the two files
Copy
verify.jsandroute.tsbelow intoapp/api/articles/(any path works). ReplacesavePostwith your own code that creates the post, or updates it whenarticle.externalIdis set.Deploy and enter the address
In StrideRank, open Autopilot → Settings and go to Publishing. During setup, the step-by-step guide on the connect step does the same. Only owners and admins of the account can change the connection. Choose Webhook (JSON) and enter the full address of the route, for example
https://example.com/api/articles.Save the signing secret
Click Save connection. StrideRank shows the signing secret only once — store it as the environment variable
WEBHOOK_SIGNING_SECRETand redeploy.Test the connection
Click Test connection. We send a
ping; the route answers with status 200.
The code
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300; // reject requests older than 5 minutes
// rawBody: the request body exactly as received (a Buffer), before JSON.parse
export function verifySignature(rawBody, header, secret) {
if (typeof header !== 'string') return false;
const parts = Object.fromEntries(
header.split(',').map((part) => part.trim().split('=', 2)),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? '')) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}import { verifySignature } from './verify';
import { savePost } from '@/lib/posts'; // your own code: create or update the post
export const runtime = 'nodejs'; // node:crypto is needed for the signature
export async function POST(request: Request) {
// Read the raw body first: the signature is made over these exact bytes.
const raw = Buffer.from(await request.arrayBuffer());
const signature = request.headers.get('x-seo-autopilot-signature');
if (!verifySignature(raw, signature, process.env.WEBHOOK_SIGNING_SECRET)) {
return new Response(null, { status: 401 });
}
const body = JSON.parse(raw.toString('utf8'));
if (body.event === 'ping') {
return Response.json({ ok: true });
}
if (body.event === 'article.publish') {
// body.article.externalId is set when we update an article you published before.
const post = await savePost(body.article);
return Response.json({ id: String(post.id), url: post.url });
}
return new Response(null, { status: 400 });
}Read the body with request.arrayBuffer() before anything else. The signature is made over the raw bytes; request.json() would change them.
The route needs the Node.js runtime (node:crypto). Don’t move it to the Edge runtime.
Questions
Do I need the Pages Router version?
The same logic works in a Pages Router API route. Turn off the body parser for that route so you can read the raw body for the signature check.