Webhook
Custom · you control the publish stepReceive a signed HTTPS POST every time we publish, update, or unpublish an article. Verify with HMAC-SHA256, dedupe on the delivery UUID, respond 2xx with a URL — that’s the whole protocol.
How it works
Webhook is the “everything else” integration. Use it for Next.js ISR triggers, Vercel deploy hooks, Make / Zapier / n8n flows, or a custom Rails / Django / PHP / Express backend.
SEORAV signs and POSTs the full canonical article payload to your endpoint. What happens next is yours: write to a database and revalidate, commit a markdown file to Git and trigger a deploy, push into Sanity / Strapi, fan out to multiple destinations — the receiver is your code. We retry on failure, log every attempt, and re-fetch the live URL after publish to score it against 17 rendered-HTML checks.
Setup
- 1
Expose a public HTTPS POST endpoint
The endpoint must be reachable from the public internet. Loopback, RFC1918 (10/8, 172.16/12, 192.168/16), link-local, and metadata-service IPs are blocked at connect time and re-checked before every send. For local development, use
cloudflared tunnelor ngrok. - 2
Generate a signing secret
Run on your machine:
shellopenssl rand -hex 32Keep the hex string. We sign with it; you verify with it.
- 3
Connect inside SEORAV
In the dashboard, go to Integrations → Connect → Webhook. Paste your URL and the secret. We immediately fire a
connect.testevent with a UUID nonce. Your endpoint must respond2xxwithin 10 seconds. Optionally, return{"echo": "<the nonce>"}in the body so we can confirm you’re parsing the JSON, not just blanket-200’ing. - 4
Approve an article and watch it flow
When you approve a generated article in SEORAV, we POST a
post.publishevent to your URL. Your activity timeline in Integrations → [your hook]shows the request, status, latency, and retry plan.
What we send
Every event has the same envelope: { event, data, integration_hints }. The data.postobject is the canonical article — every field SEORAV knows about, fully populated. You can ignore fields you don’t need; missing fields are typed as null in our payload, never absent.
Request headers
| Property | Type | Description |
|---|---|---|
| X-SEORAV-Signaturerequired | string | sha256=<hex> of HMAC-SHA256 over the raw request body. |
| X-SEORAV-Deliveryrequired | uuid | Unique per delivery. Treat as an idempotency key — store and reject duplicates. |
| X-SEORAV-Eventrequired | enum | One of post.publish, post.update, post.unpublish, connect.test. |
| X-SEORAV-Timestamprequired | ISO 8601 UTC | Reject anything older than ~5 minutes for replay protection. |
| X-SEORAV-Request-Id | uuid | Mirror of X-SEORAV-Delivery. Log it next to your own request id so support can cross-reference. |
| X-SEORAV-Entity-Typerequired | "article" | "answer_page" | … | The module this payload belongs to. Same value lives at data.post.entity_type in the body — header is provided so simple routers (Zapier filters, edge functions) can split traffic without parsing the JSON. Also emitted as X-Entity-Type for receivers that prefer the short name. |
| X-Entity-Typerequired | "article" | "answer_page" | … | Alias of X-SEORAV-Entity-Type. Pick whichever your routing layer is happier with. |
| User-Agent | string | SEORAV/1.0 (+webhook) |
| Content-Type | string | Always application/json. |
Routing articles vs answer pages (and future modules)
SEORAV ships at least two payload kinds through your webhook: long-form article events and FAQ-style answer_page events. New modules (case studies, comparisons, glossary entries) will use the same envelope with a different entity_type. Your receiver must either route by entity_type or be content-shape-agnostic.
entity_type.Two patterns work, pick whichever fits your stack:
One endpoint, internal routing. One webhook URL receives everything. Your handler reads the X-Entity-Type header (ordata.post.entity_type) and dispatches to the right destination — a different collection, a different DB table, or a different downstream service.
Multiple endpoints, per-module URLs.Inside SEORAV, open the CMS connection and add a per-entity destination override. Articles continue to POST to the connection’s primary URL; answer pages POST to a separate URL you set in the Publish channels section. Use this if your serverless stack is easier to maintain as several small functions than one big router.
Either way, the payload always includes entity_type in the body AND in the headers, and SEORAV signs each event with the same HMAC secret regardless of destination.
Example payload
Here is a post.publish body for a real article. Fields without values are explicit null, not omitted, so your TypeScript types stay sound.
{
"event": "post.publish",
"data": {
"post": {
"entity_type": "article",
"entity_id": "9b1c5e0a-7a7e-4d1d-b2cb-2f5b41a0c0e2",
"site_id": "1f3a09a8-2a3e-4b1f-a4d6-5fabb4ab27ee",
"org_id": "5c5f56a9-8b1a-4d2e-a8be-0a3a8a1c2c4d",
"title": "How to choose a reverse-osmosis system in 2026",
"slug": "how-to-choose-reverse-osmosis-system-2026",
"body_markdown": "## What you actually need to know …",
"body_html": "<h2>What you actually need to know</h2><p>…</p>",
"excerpt": "Skip the spec sheet. The three things that matter are membrane stage count, recovery rate, and remineralisation.",
"meta_title": "Reverse osmosis systems · the 3 specs that matter (2026)",
"meta_description": "Membrane stages, recovery rate, remineralisation — the three specs that change your RO water tomorrow. Independent guide, no affiliate links.",
"canonical_url": "https://yoursite.com/blog/how-to-choose-reverse-osmosis-system-2026",
"og_title": "Reverse osmosis systems · the 3 specs that matter",
"og_description": "Membrane stages, recovery rate, remineralisation. Plain-English explanation of the only three RO specs that actually change your water.",
"og_image": "https://cdn.seorav.com/articles/9b1c5e0a/hero.webp",
"jsonld_blocks": [
{ "@context": "https://schema.org", "@type": "Article", "headline": "How to choose a reverse-osmosis system in 2026", "datePublished": "2026-04-27T08:00:00Z" },
{ "@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [ /* … */ ] }
],
"tags": ["reverse osmosis", "water filtration", "buying guide"],
"categories": ["guides"],
"author_ref": "elena-vance",
"hero_image_url": "https://cdn.seorav.com/articles/9b1c5e0a/hero.webp",
"hero_image_alt": "Three reverse-osmosis systems lined up on a kitchen counter",
"scheduled_for": null,
"publish_mode": "publish",
"source_url_on_platform": null
},
"mode": "publish"
},
"integration_hints": {
"render_jsonld_in_head": true,
"render_canonical_link_rel": true,
"render_meta_description": true,
"render_opengraph": true,
"publish_url_field": "url",
"required_response_fields": ["post_id", "url", "status"]
}
}data.post.hero_image_url we send is a signed Supabase Storage URL with a ~15-minute TTL. Your receiver MUST download the image bytes inside the publish handler and re-host on your own storage / CDN, then point your post’s image reference at your re-hosted URL. After the TTL expires the original URL returns 403 and your post will show a broken image. Same goes for og_image when populated. The accessibility text lives at hero_image_alt — store it alongside the re-hosted file. This is signalled to your receiver explicitly via integration_hints.hero_image.must_download on every publish event.integration_hints object
Every post.publish / post.update event carries anintegration_hintsobject that tells your receiver, in machine-readable form, what SEORAV expects you to do with the payload. Cheap to ignore if you already know — invaluable when you’re wiring a fresh receiver. Not sent on connect.test or post.unpublish.
| Property | Type | Description |
|---|---|---|
| render_jsonld_in_head | bool | Always true. Each item in data.post.jsonld_blocks[] should become its own <script type="application/ld+json"> in <head>. |
| render_canonical_link_rel | bool | Always true. Emit <link rel="canonical" href="{canonical_url}"> in head. |
| render_meta_description | bool | Always true. Emit <meta name="description"> with meta_description. |
| render_opengraph | bool | Always true. Emit og:title, og:description, og:image, og:url. |
| publish_url_field | string | Always "url". Tells you which response key SEORAV reads for the live URL. |
| required_response_fields | string[] | Always ["post_id", "url", "status"]. If any are missing, post-publish verification falls back or is skipped. |
| how_to_verify | string | Human-readable note describing what we do with the URL you return. Safe to log. |
| hero_image.must_download | bool | Only present when hero_image_url is set. Always true. You MUST fetch the bytes inside your publish handler. |
| hero_image.ttl_seconds | number | Only present when hero_image_url is set. Currently 900 (15 min). After this the URL returns 403. |
| hero_image.instructions | string | Human-readable note explaining the re-host requirement and alt-text storage. |
Answer-page extras (when entity_type = “answer_page”)
FAQ / answer-engine pages flow through the same webhook envelope as articles. They carry every core field listed in the article properties table below (title, slug, body_markdown, meta_*, jsonld_blocks, etc.) PLUS the following top-level extras — flattened from the canonical extra object so your receiver can read them without parsing the body. They are present only when entity_type === "answer_page".
| Property | Type | Description |
|---|---|---|
| question_h1 | string | The question, exactly as it should appear as the page H1. Title field carries the same string. |
| tldr | string | One-sentence summary, 15–25 words. Designed to read cleanly as a voice-assistant response. |
| direct_answer | string | The 65–85 word self-contained answer paragraph. This is the band AI engines extract — render it prominently, ideally in the first 30% of the page. |
| key_facts | string[] | 5–8 scannable factual bullets, each 12–28 words. Render as a list near the top of the page. |
| supporting_content | markdown | Optional 150–350 word markdown block providing deeper context. May contain H3 subheadings. |
| sections | object[] | Pre-split version of supporting_content (heading + body pairs). Use whichever shape your renderer prefers. |
| cta_text | string | null | Optional CTA label (e.g. "Get the AEO score"). |
| cta_url | string | null | Optional CTA destination. Render with the cta_text as a button or link near the bottom of the answer. |
| cluster_name | string | null | The topic cluster this answer page belongs to. Useful if your receiver routes by cluster. |
X-Entity-Type header (or data.post.entity_type) — there’s no second webhook URL, no second secret, no second signature scheme.Article properties
| Property | Type | Description |
|---|---|---|
| entity_typerequired | "article" | "answer_page" | Articles are long-form posts; answer_pages are FAQ-style short answers we generate for “people also ask” queries. |
| entity_idrequired | uuid | SEORAV-side primary key. Use this if you store our id alongside yours. |
| site_id, org_id | uuid | Workspace identifiers — mostly useful if you publish to multiple SEORAV sites from one receiver. |
| title, slugrequired | string | Headline + URL slug. The slug is unique per site and stable across updates. |
| body_markdown | string | Source markdown. Best choice if your CMS stores markdown natively (Ghost, MDX-based sites). |
| body_html | string | Pre-rendered HTML — already sanitised, no inline scripts. Best for WordPress / classic CMSes. |
| excerpt | string | Short summary, ~30 words. Use for list-page teasers. |
| meta_title, meta_description | string | SEO-tuned title + description. Render in <head> if your platform separates these from the body title. |
| canonical_url | url | The URL we expect the post to live at. We use this to verify the rendered page after publish. |
| og_title, og_description, og_image | string | OpenGraph fields for social previews. og_image is signed alongside hero_image_url with the same 15-min TTL — re-host on the same publish path. |
| og_url | url | Mirrors canonical_url automatically. Render as <meta property="og:url">. |
| jsonld_blocks | JSON[] | Array of JSON-LD schema graphs (Article / BlogPosting + FAQPage + HowTo + Speakable where applicable). Render each as a separate <script type="application/ld+json"> in <head>. Do not merge — Google parses each independently. |
| tags, categories | string[] | Taxonomies. Map to whatever your CMS calls them. |
| author_ref | string | null | Slug of the author the article was generated under. Resolve to your CMS user id. null on entity_type="answer_page". |
| hero_image_url | url (signed, 15-min TTL) | Signed Supabase Storage URL. Download and re-host inside your publish handler — after the TTL the URL returns 403. See the warning callout above and integration_hints.hero_image. |
| hero_image_alt | string | null | Accessible alt text. Store alongside your re-hosted image so accessibility is preserved. |
| published_at | ISO 8601 | null | Original publish date. Render as datePublished in the JSON-LD we send AND in the post meta where your CMS surfaces it. |
| modified_at | ISO 8601 | null | Last meaningful update. Always ≥ published_at. Maps to dateModified in JSON-LD. |
| scheduled_for | ISO 8601 | null | Set when publish_mode is "scheduled". Otherwise null. |
| publish_moderequired | "publish" | "scheduled" | "draft" | How the receiver should handle the post. |
| source_url_on_platform | url | null | Our internal detail-page URL for this post. Some CMSes store this as a custom field for editor reference; safe to ignore. |
Verify the signature
Compute HMAC-SHA256 over the rawbody — not a re-serialised JSON. Whitespace differences will silently break verification, and you’ll spend 30 minutes wondering why your tests pass and prod doesn’t.
Python (FastAPI / Flask)
import hmac, hashlib
from datetime import datetime, timezone
from fastapi import FastAPI, Request, HTTPException
SECRET = b"<your-hmac-secret>"
MAX_SKEW_SECONDS = 5 * 60 # reject deliveries older than 5 min
app = FastAPI()
seen_deliveries: set[str] = set() # use Redis / DB in prod
@app.post("/seorav-webhook")
async def receive(req: Request):
raw = await req.body() # MUST be raw bytes, not parsed JSON
sig_header = req.headers.get("x-seorav-signature", "")
if not sig_header.startswith("sha256="):
raise HTTPException(401, "missing signature")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig_header.removeprefix("sha256="), expected):
raise HTTPException(401, "bad signature")
# Replay window
ts = req.headers.get("x-seorav-timestamp")
sent_at = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if abs((datetime.now(tz=timezone.utc) - sent_at).total_seconds()) > MAX_SKEW_SECONDS:
raise HTTPException(401, "stale request")
# Idempotency
delivery = req.headers.get("x-seorav-delivery", "")
if delivery in seen_deliveries:
return {"post_id": delivery, "url": "", "status": "duplicate"}
seen_deliveries.add(delivery)
body = await req.json()
post = body["data"]["post"]
# …persist to your DB / write file / trigger ISR…
return {"post_id": post["slug"], "url": f"https://yoursite.com/blog/{post['slug']}", "status": "published"}Node.js (Next.js Route Handler)
// app/api/seorav-webhook/route.ts (Next.js App Router)
import crypto from "node:crypto"
import { revalidatePath } from "next/cache"
const SECRET = process.env.SEORAV_SECRET!
const MAX_SKEW = 5 * 60_000
export async function POST(req: Request) {
const raw = await req.text() // raw, NOT .json()
const sig = req.headers.get("x-seorav-signature") ?? ""
const ts = req.headers.get("x-seorav-timestamp") ?? ""
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(raw).digest("hex")
const sigBuf = Buffer.from(sig)
const expBuf = Buffer.from(expected)
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return new Response("bad signature", { status: 401 })
}
if (Math.abs(Date.now() - Date.parse(ts)) > MAX_SKEW) {
return new Response("stale", { status: 401 })
}
const { event, data } = JSON.parse(raw)
if (event === "post.publish" || event === "post.update") {
// upsert into your DB by data.post.slug, then:
revalidatePath(`/blog/${data.post.slug}`)
}
return Response.json({
post_id: data.post.slug,
url: `https://yoursite.com/blog/${data.post.slug}`,
status: "published",
})
}await request.text() first, then JSON.parse the verified string. In Express, install express.raw() instead of express.json() for the webhook route only.Go (net/http)
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"strings"
"time"
)
const maxSkew = 5 * time.Minute
func Handler(secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sig := strings.TrimPrefix(r.Header.Get("X-SEORAV-Signature"), "sha256=")
sent, _ := hex.DecodeString(sig)
mac := hmac.New(sha256.New, secret); mac.Write(body)
if !hmac.Equal(sent, mac.Sum(nil)) { http.Error(w, "bad sig", 401); return }
ts, _ := time.Parse(time.RFC3339, r.Header.Get("X-SEORAV-Timestamp"))
if math.Abs(time.Since(ts).Seconds()) > maxSkew.Seconds() { http.Error(w, "stale", 401); return }
// … persist + respond …
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"post_id":"…","url":"…","status":"published"}`))
}
}What we expect back
Respond 2xx within 30 seconds with the published URL. We re-fetch that URL afterwards and run the 17-check rendered-HTML scorer (title in <head>, canonical link, JSON-LD parses, meta description present, …). If you don’t return a URL we fall back to canonical_url. If neither is set, scoring is skipped — you can still publish, you just lose the post-publish report.
{
"post_id": "<your-internal-id-or-our-slug>",
"url": "https://yoursite.com/blog/the-published-slug",
"status": "published", // or "scheduled" | "draft"
"message": "<optional human-readable note>",
"echo": "<X-SEORAV-Delivery — only on connect.test for round-trip proof>"
}Activity timeline
Every delivery is logged on your connection page. A successful publish flow looks like this:
- job_queuedidempotency_key=8b3c…
- adapter_requestPOSThttps://yoursite.com/seorav-webhook
- adapter_response200243ms
- verify_startGET https://yoursite.com/blog/how-to-…
- verify_complete200187msscore 96/100 (canonical link missing)
- job_succeeded6 of 17 checks weighted; schema valid
A failure with retries looks like this:
- adapter_requestPOST
- adapter_response5031.2s
- job_failedcms_5xx · "Service unavailable"
- retry_scheduledattempt 2 in ~5 min
- adapter_requestPOST
- adapter_response200198ms
- job_succeededrecovered on attempt 2
Retries
Any non-2xx, network error, or timeout triggers retry with exponential backoff. You will see deliveries again — design your endpoint to be idempotent on X-SEORAV-Delivery.
Attempt 1 → ~1 minute later
Attempt 2 → ~5 minutes
Attempt 3 → ~25 minutes
Attempt 4 → ~2 hours
Attempt 5 → ~10 hours
Attempt 6 → ~2 days (after this, dead-lettered)Dead-lettered jobs surface in the connection’s activity feed with a manual Retry button. Clicking it resets the schedule.
Reachability & SSRF guard
Webhook URLs are validated at connect time and re-resolved before every send. We reject any host that resolves to a private, loopback, link-local, multicast, or cloud-metadata address — even after a redirect, even if DNS flips after-the-fact (rebinding defence). For local dev, expose your endpoint with Cloudflare Tunnel or ngrok and use the public hostname.
After publishing — make it shine
Render JSON-LD in <head>
Each block in jsonld_blocks wants its own <script type="application/ld+json"> tag. Don’t merge them into one — Google parses each independently and silently drops malformed graphs.
Mirror og_image to your domain
Our CDN URL works, but social validators (LinkedIn especially) prefer images on the publishing domain. Re-host on first publish.
Add a <link rel="canonical">
Use the value of canonical_url. Without this, the post-publish verifier flags “canonical missing” and your score drops 5 points.
Index immediately
Submit to Bing Webmaster Tools and IndexNow as soon as your render finishes. SEORAV doesn’t do this for webhook receivers — your last mile, your call.
Watch the verify_complete score
If you see scores below 80 consistently, open the activity row and look at the “checks” array — it tells you exactly which signal failed (e.g., title-tag mismatch, missing schema graph).
Plan for updates
When SEORAV detects content drift or stale data, we re-fire post.update with the same slug. Upsert by slug; don’t insert.
Best practices
- Read the body as raw text first, then verify, then parse JSON. Frameworks that auto-parse will silently break HMAC.
- Constant-time compare with
hmac.compare_digest/crypto.timingSafeEqual. String equality leaks timing. - Persist
X-SEORAV-Deliveryfor at least 7 days. Dead-lettered retries can arrive up to 2 days late, and a manual retry could fire after that. - Respond 200 fast, do work async. If your DB write or build step takes longer than 30 seconds, ack the webhook and process in a queue. Slow 200s cause us to timeout and retry.
- Never log the secret or signature. Either is enough to forge requests if leaked.
- Rotate periodically. Today, rotation = delete + recreate the connection. We’ll add a rotate-in-place button when enough customers ask.
Troubleshooting
Receiver returns 401 / signature mismatch
Almost always whitespace. Make sure you’re computing HMAC over the exact bytes we sent, before any framework re-serialises the JSON. Print both the bytes you verified and the bytes you parsed — they should be byte-for-byte identical.
Deliveries timing out (504)
We give you 30 seconds. If your handler kicks off a build or runs an LLM call inline, respond 200 first and process async. Long-running 200s look identical to failures from our side.
“Webhook URL rejected by SSRF guard”
The URL resolves to a private or loopback IP. Check that your-domain.comactually resolves to a public IP. If you’re behind Cloudflare, make sure proxy is enabled. For local dev, use a tunnel.
Verifier reports “url missing”
Your response didn’t include a url field, and no canonical_url is set on the SEORAV site. Either return the live URL, or set the canonical base URL in Settings → Site → Canonical URL.