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
- Create an endpoint that accepts
POSTrequests with a JSON body. - In OverSerp open your site → Integrations → Webhook and paste the endpoint URL.
- Copy the signing secret. Store it as an environment variable, for example
OVERSERP_WEBHOOK_SECRET. - Save. OverSerp sends a
pingevent 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"
}
}
| Field | Type | Description |
|---|---|---|
event | string | Always article.published for articles |
article.id | string | Stable OverSerp article ID. Use it to make your handler idempotent |
article.title | string | Article title (use it as your H1) |
article.slug | string | Lowercase, hyphenated slug |
article.metaDescription | string | Meta description, 110–160 characters |
article.excerpt | string | Short summary for listings |
article.keyword | string | Primary keyword targeted |
article.html | string | Rendered HTML body, without H1, FAQ section appended |
article.markdown | string | Markdown body, without H1 and without the FAQ |
article.faq | array | [{ q, a }] question/answer pairs |
article.schemaJson | string | JSON-LD (Article + FAQPage) as a string. Inject it in a <script type="application/ld+json"> tag |
article.scheduledFor | string | ISO 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
2xxstatus 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.idorslug. 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:
htmlis 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.