Webhook

Custom · you control the publish step

Receive 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. 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 tunnel or ngrok.

  2. 2

    Generate a signing secret

    Run on your machine:

    shell
    openssl rand -hex 32

    Keep the hex string. We sign with it; you verify with it.

  3. 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 respond 2xx within 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. 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

PropertyTypeDescription
X-SEORAV-Signaturerequiredstringsha256=<hex> of HMAC-SHA256 over the raw request body.
X-SEORAV-DeliveryrequireduuidUnique per delivery. Treat as an idempotency key — store and reject duplicates.
X-SEORAV-EventrequiredenumOne of post.publish, post.update, post.unpublish, connect.test.
X-SEORAV-TimestamprequiredISO 8601 UTCReject anything older than ~5 minutes for replay protection.
X-SEORAV-Request-IduuidMirror 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-AgentstringSEORAV/1.0 (+webhook)
Content-TypestringAlways 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.

Why this matters
Articles and answer pages can share a slug (the database constraint is per-site, not per-type). If your receiver writes both into the same collection keyed only by slug, publishing an answer page can silently overwrite the article with the same slug, or vice versa. Always partition your downstream storage by 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.

json
{
  "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"]
  }
}
hero_image_url is a short-lived signed URL — download it before it expires
The 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.

PropertyTypeDescription
render_jsonld_in_headboolAlways true. Each item in data.post.jsonld_blocks[] should become its own <script type="application/ld+json"> in <head>.
render_canonical_link_relboolAlways true. Emit <link rel="canonical" href="{canonical_url}"> in head.
render_meta_descriptionboolAlways true. Emit <meta name="description"> with meta_description.
render_opengraphboolAlways true. Emit og:title, og:description, og:image, og:url.
publish_url_fieldstringAlways "url". Tells you which response key SEORAV reads for the live URL.
required_response_fieldsstring[]Always ["post_id", "url", "status"]. If any are missing, post-publish verification falls back or is skipped.
how_to_verifystringHuman-readable note describing what we do with the URL you return. Safe to log.
hero_image.must_downloadboolOnly present when hero_image_url is set. Always true. You MUST fetch the bytes inside your publish handler.
hero_image.ttl_secondsnumberOnly present when hero_image_url is set. Currently 900 (15 min). After this the URL returns 403.
hero_image.instructionsstringHuman-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".

PropertyTypeDescription
question_h1stringThe question, exactly as it should appear as the page H1. Title field carries the same string.
tldrstringOne-sentence summary, 15–25 words. Designed to read cleanly as a voice-assistant response.
direct_answerstringThe 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_factsstring[]5–8 scannable factual bullets, each 12–28 words. Render as a list near the top of the page.
supporting_contentmarkdownOptional 150–350 word markdown block providing deeper context. May contain H3 subheadings.
sectionsobject[]Pre-split version of supporting_content (heading + body pairs). Use whichever shape your renderer prefers.
cta_textstring | nullOptional CTA label (e.g. "Get the AEO score").
cta_urlstring | nullOptional CTA destination. Render with the cta_text as a button or link near the bottom of the answer.
cluster_namestring | nullThe topic cluster this answer page belongs to. Useful if your receiver routes by cluster.
Same envelope, same signature, same secret
Articles and answer pages share the webhook envelope + HMAC secret. Route on theX-Entity-Type header (or data.post.entity_type) — there’s no second webhook URL, no second secret, no second signature scheme.

Article properties

PropertyTypeDescription
entity_typerequired"article" | "answer_page"Articles are long-form posts; answer_pages are FAQ-style short answers we generate for &ldquo;people also ask&rdquo; queries.
entity_idrequireduuidSEORAV-side primary key. Use this if you store our id alongside yours.
site_id, org_iduuidWorkspace identifiers — mostly useful if you publish to multiple SEORAV sites from one receiver.
title, slugrequiredstringHeadline + URL slug. The slug is unique per site and stable across updates.
body_markdownstringSource markdown. Best choice if your CMS stores markdown natively (Ghost, MDX-based sites).
body_htmlstringPre-rendered HTML — already sanitised, no inline scripts. Best for WordPress / classic CMSes.
excerptstringShort summary, ~30 words. Use for list-page teasers.
meta_title, meta_descriptionstringSEO-tuned title + description. Render in <head> if your platform separates these from the body title.
canonical_urlurlThe URL we expect the post to live at. We use this to verify the rendered page after publish.
og_title, og_description, og_imagestringOpenGraph 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_urlurlMirrors canonical_url automatically. Render as <meta property="og:url">.
jsonld_blocksJSON[]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, categoriesstring[]Taxonomies. Map to whatever your CMS calls them.
author_refstring | nullSlug of the author the article was generated under. Resolve to your CMS user id. null on entity_type="answer_page".
hero_image_urlurl (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_altstring | nullAccessible alt text. Store alongside your re-hosted image so accessibility is preserved.
published_atISO 8601 | nullOriginal publish date. Render as datePublished in the JSON-LD we send AND in the post meta where your CMS surfaces it.
modified_atISO 8601 | nullLast meaningful update. Always ≥ published_at. Maps to dateModified in JSON-LD.
scheduled_forISO 8601 | nullSet when publish_mode is "scheduled". Otherwise null.
publish_moderequired"publish" | "scheduled" | "draft"How the receiver should handle the post.
source_url_on_platformurl | nullOur 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)

python
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)

typescript
// 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",
  })
}
Read the body once, as text
Frameworks that auto-parse JSON re-serialise the body, breaking the HMAC. In Next.js, call 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)

go
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.

json
{
  "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:

Activity timeline · what shows up in your dashboard
  • 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:

Activity timeline · what shows up in your dashboard
  • 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 &lt;head&gt;

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 &lt;link rel=&quot;canonical&quot;&gt;

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

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.

Security

What we encrypt
Your HMAC secret is encrypted at rest with AES-256-GCM. RLS on the database blocks the authenticated role entirely — only the publish worker (running with a service-role key inside our Python process) can decrypt it. The plaintext leaves the API boundary the moment you submit and is never logged.
What we do at the connection edge
Every outgoing POST re-resolves your hostname and re-validates against the SSRF allow-list — defending against DNS rebinding and post-connect changes. Schema is locked to HTTPS and HTTP only. Redirects are followed manually with per-hop validation.