All posts
Technical SetupMarch 3, 2026

How to Publish to Any Blog with How to SEO's Custom Webhook

How to SEO's Custom API platform lets you push AI-generated posts to any endpoint you control. This guide walks through the webhook payload, HMAC signature verification, and a complete Next.js implementation.

How to SEO generates SEO-optimized content automatically — but where it lands is up to you. Out of the box you can push straight to Webflow or Framer, but with the Custom API platform you can send every published post to any endpoint you control: a Next.js API route, an Astro server action, a Hugo webhook, a Zapier zap, or a headless CMS like Contentful or Sanity.

This tutorial walks through exactly how the integration works — including the HMAC signature verification that prevents anyone else from spoofing posts to your site. Everything here is the same setup that powers this blog.


How the integration works

When How to SEO publishes a piece of content (either manually via the Publish button or automatically via the daily cron), it:

  1. Builds a JSON payload with the post's title, slug, excerpt, body, cluster metadata, and publish date.
  2. Signs the payload with HMAC-SHA256 using a shared secret you set in site settings.
  3. POSTs the signed payload to the endpoint URL you configured.

Your endpoint verifies the signature, writes the post to wherever you store content, and returns { "url": "..." } with the live post URL.


Step 1 — Create a site with the Custom API platform

In the How to SEO dashboard, create a new site (or edit an existing one in Settings):

  • Platform: Custom API (the webhook icon)
  • Endpoint URL: the full HTTPS URL of the route that will receive posts — e.g. https://yoursite.com/api/publish
  • Webhook secret: click Generate to get a random whsec_... value, or paste your own

Save the settings. How to SEO will send every published post to that endpoint from this point on.


Step 2 — Understand the payload

Every POST from How to SEO sends a JSON body with this shape:

{
  "title": "How to Do Keyword Research in 2026",
  "slug": "keyword-research-guide-2026",
  "excerpt": "A step-by-step framework for finding keywords...",
  "body": "# How to Do Keyword Research\n\nKeyword research is...",
  "is_pillar": true,
  "cluster_topic": "SEO Fundamentals",
  "published_at": "2026-03-03T09:00:00.000Z"
}
FieldTypeNotes
titlestringPost title
slugstringURL-safe slug, unique per site
excerptstringShort description (1–2 sentences)
bodystringFull post in Markdown
is_pillarbooleantrue for hub posts, false for supporting posts
cluster_topicstring | nullThe cluster this post belongs to
published_atISO 8601 stringPublish timestamp

The body field is plain Markdown — you decide how to render it in your template.


Step 3 — Verify the HMAC signature

Every request includes an X-HowToSEO-Signature header:

X-HowToSEO-Signature: sha256=a3f2c1...

The signature is sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body (before any JSON parsing) using the webhook secret as the key.

Always verify the signature before processing. Here is a Node.js / Next.js helper:

import { createHmac, timingSafeEqual } from 'crypto'
 
function verifySignature(rawBody: string, sigHeader: string, secret: string): boolean {
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
  try {
    return timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))
  } catch {
    return false
  }
}

Two important details:

  • Read the raw body before calling JSON.parse — any whitespace normalisation will break the signature.
  • Use timingSafeEqual to prevent timing attacks.

Step 4 — Build the endpoint (Next.js example)

Here is the complete Next.js App Router route that this blog uses:

// app/api/publish/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createHmac, timingSafeEqual } from 'crypto'
import { revalidatePath } from 'next/cache'
 
export const dynamic = 'force-dynamic'
 
export async function POST(req: NextRequest) {
  const rawBody = await req.text()
 
  // 1. Verify signature
  const sig = req.headers.get('x-howtoseo-signature') ?? ''
  const expected = 'sha256=' + createHmac('sha256', process.env.WEBHOOK_SECRET!).update(rawBody).digest('hex')
  let valid = false
  try { valid = timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) } catch {}
  if (!valid) return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
 
  // 2. Parse payload
  const { title, slug, excerpt, body, is_pillar, cluster_topic, published_at } = JSON.parse(rawBody)
  if (!title || !slug) return NextResponse.json({ error: 'title and slug required' }, { status: 400 })
 
  // 3. Write to your data store (database, MDX files, headless CMS, etc.)
  await db.posts.upsert({ slug }, {
    title, slug, excerpt, body, is_pillar, cluster_topic,
    published_at: published_at ?? new Date().toISOString(),
  })
 
  // 4. Invalidate ISR cache so the post goes live immediately
  revalidatePath('/blog')
  revalidatePath('/blog/' + slug)
 
  // 5. Return the live URL — How to SEO stores this as the published_url
  return NextResponse.json({ url: `https://yoursite.com/blog/${slug}` })
}

Replace the db.posts.upsert call with whatever storage you use — Supabase, Prisma, a file on disk, a CMS SDK call. The contract with How to SEO is just the JSON body in and the { "url": "..." } response out.


Step 5 — Return the live URL

How to SEO expects a JSON response with a url field:

{ "url": "https://yoursite.com/blog/keyword-research-guide-2026" }

This URL is stored as the post's published_url in the dashboard, and surfaced as the View live button on the article detail page. If your endpoint returns a non-2xx status or omits url, the publish is marked as failed.


Step 6 — Test with curl

Before wiring up How to SEO, confirm your endpoint handles valid and invalid payloads:

# Build the payload
PAYLOAD='{"title":"Test post","slug":"test-post","excerpt":"Just a test","body":"Hello world","is_pillar":false}'
SECRET="whsec_your_secret_here"
 
# Compute the HMAC (macOS / Linux)
SIG="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
 
# Send it
curl -X POST https://yoursite.com/api/publish \
  -H "Content-Type: application/json" \
  -H "X-HowToSEO-Signature: $SIG" \
  -d "$PAYLOAD"
# → {"url":"https://yoursite.com/blog/test-post"}
 
# Try with a bad signature — should get 401
curl -X POST https://yoursite.com/api/publish \
  -H "Content-Type: application/json" \
  -H "X-HowToSEO-Signature: sha256=badhash" \
  -d "$PAYLOAD"
# → {"error":"Invalid signature"}

Adapting for other frameworks

The same pattern works anywhere that can run server-side code:

Astro — use an API route in src/pages/api/publish.ts with the same Node crypto imports.

Hugo — write a small Go HTTP server or use a Netlify / Vercel function and drop posts into the content/ directory, then trigger a rebuild.

Contentful / Sanity — call the CMS Management API inside the webhook handler instead of writing to a local database. How to SEO sends the content; you forward it to the CMS.

Zapier / Make — set your endpoint to a Zapier webhook step, then use downstream Zap steps to create entries in Notion, Airtable, or any tool Zapier connects to. Signature verification is not possible in no-code tools, so accept only calls from trusted IP ranges or skip verification and rely on the secret in a query parameter.


Troubleshooting

401 Invalid signature — the most common cause is JSON serialisation differences. Ensure you read the raw body bytes before parsing, and that your HMAC key is the full secret string including the whsec_ prefix.

Endpoint not reached — check that the URL is publicly accessible over HTTPS. How to SEO cannot reach localhost. Use a tunnel like ngrok during local development.

Post publishes but ISR shows stale content — call revalidatePath after writing to your store, or set a short revalidate interval on your blog index and post pages.

How to SEO marks the publish as failed — your endpoint returned a non-2xx status, or the response body did not include a url field. Check your server logs for the underlying error.