Webhook reference

Receive OverSerp articles as signed JSON POST requests. Payload format, HMAC SHA-256 signature verification and example handlers.

The webhook integration sends every article to your own HTTPS endpoint as a JSON POST. Use it for custom CMSs, headless setups, static sites, Zapier/Make, or internal tools.

Setup

  1. Create an endpoint that accepts POST requests with a JSON body.
  2. In OverSerp open your site → Integrations → Webhook and paste the endpoint URL.
  3. Copy the signing secret. Store it as an environment variable, for example OVERSERP_WEBHOOK_SECRET.
  4. Save. OverSerp sends a ping event to test the connection.

The URL must be publicly reachable over HTTP(S). Private addresses such as localhost or 192.168.x.x are rejected; use a tunnel (ngrok, Cloudflare Tunnel) for local testing.

Request

POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: OverSerp-Webhook/1.0
X-OverSerp-Signature: sha256=5d41402abc4b2a76b9719d911017c592...

Events

article.published

Sent when an article is published to this integration, either by autopilot at its scheduled slot or when you click Publish.

{
  "event": "article.published",
  "article": {
    "id": "k57a9c2m1x8...",
    "title": "How to Choose a CRM for a Startup",
    "slug": "choose-crm-startup",
    "metaDescription": "Compare the CRM features startups actually need, what they cost and how to pick one your team will use. Includes a comparison table.",
    "excerpt": "The best startup CRM is the one your team updates every day...",
    "keyword": "crm for startups",
    "html": "<p>The best startup CRM is...</p><h2>...</h2>",
    "markdown": "The best startup CRM is...\n\n## ...",
    "faq": [
      { "q": "What does a CRM cost for a startup?", "a": "..." }
    ],
    "schemaJson": "{\"@context\":\"https://schema.org\",\"@graph\":[...]}",
    "scheduledFor": "2026-11-03T09:00:00.000Z"
  }
}
FieldTypeDescription
eventstringAlways article.published for articles
article.idstringStable OverSerp article ID. Use it to make your handler idempotent
article.titlestringArticle title (use it as your H1)
article.slugstringLowercase, hyphenated slug
article.metaDescriptionstringMeta description, 110–160 characters
article.excerptstringShort summary for listings
article.keywordstringPrimary keyword targeted
article.htmlstringRendered HTML body, without H1, FAQ section appended
article.markdownstringMarkdown body, without H1 and without the FAQ
article.faqarray[{ q, a }] question/answer pairs
article.schemaJsonstringJSON-LD (Article + FAQPage) as a string. Inject it in a <script type="application/ld+json"> tag
article.scheduledForstringISO 8601 date of the publishing slot

ping

Sent when you create or re-test the webhook.

{ "event": "ping", "sentAt": "2026-10-21T10:00:00.000Z" }

Return any 2xx status.

Verifying the signature

Every request includes X-OverSerp-Signature: sha256=<hex>, where <hex> is the HMAC SHA-256 of the raw request body, keyed with your signing secret. Always compute it over the raw bytes, before parsing JSON, and compare in constant time.

Node.js / Next.js route handler

// app/api/overserp/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(req: Request) {
  const raw = await req.text();
  const expected = "sha256=" + createHmac("sha256", process.env.OVERSERP_WEBHOOK_SECRET!).update(raw).digest("hex");
  const received = req.headers.get("x-overserp-signature") ?? "";

  const ok = received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!ok) return new Response("Invalid signature", { status: 401 });

  const payload = JSON.parse(raw);
  if (payload.event === "ping") return Response.json({ ok: true });

  const { article } = payload;
  // Upsert by article.id so retries never create duplicates.
  await savePost(article);

  // Optional: tell OverSerp where the article lives.
  return Response.json({ url: "https://example.com/blog/" + article.slug });
}

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request, abort, jsonify

app = Flask(__name__)
SECRET = os.environ["OVERSERP_WEBHOOK_SECRET"].encode()

@app.post("/api/overserp")
def overserp():
    raw = request.get_data()
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-OverSerp-Signature", "")):
        abort(401)
    payload = request.get_json()
    if payload["event"] == "article.published":
        save_post(payload["article"])
    return jsonify(ok=True)

PHP

<?php
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('OVERSERP_WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_OVERSERP_SIGNATURE'] ?? '')) {
  http_response_code(401);
  exit;
}
$payload = json_decode($raw, true);

Responding

  • Return a 2xx status within 30 seconds to confirm delivery.
  • Optionally return JSON { "url": "https://..." }. OverSerp stores it as the article's published URL and shows it in the dashboard.
  • Any other status, or a timeout, marks the article with a publish error. Fix your endpoint and click Publish again on the article.

Best practices

  • Idempotency: upsert by article.id or slug. Re-publishing the same article sends the same ID.
  • Raw body: frameworks that auto-parse JSON (Express json(), some serverless runtimes) change the bytes. Read the raw body for signature checks.
  • Rotate secrets by creating a new webhook integration and deleting the old one.
  • Render safely: html is generated from Markdown with raw HTML escaped, but you should still sanitize it according to your own policies.

Stuck? Email hello@overserp.com with your site URL and the error shown in the dashboard. A human answers.