Webhook Integration
Receive EarlySEO article payloads via HTTP webhook — build a receiver endpoint, verify signatures, and publish content automatically.
How It Works
EarlySEO pushes articles to your server. You build an HTTPS endpoint (the "webhook receiver"), and EarlySEO sends an HTTP POST to it every time an article is ready to publish. Your server receives the article data and does whatever it needs — save to a database, publish to a CMS, trigger a pipeline, etc.
Flow:
EarlySEO generates article → POST to your endpoint → Your server processes it → Returns 2xxYou do not poll EarlySEO. EarlySEO calls you.
Step 1 — Build Your Webhook Receiver Endpoint
Create an HTTPS endpoint on your server that:
- Accepts
POSTrequests - Validates the
Authorizationheader (Bearer token) - Parses the JSON body
- Saves / publishes the article
- Returns a
2xxresponse within 10 seconds
Step 2 — Register the Webhook in EarlySEO
- Go to Integrations in your EarlySEO dashboard
- Click Add Integration → select Webhook
- Fill in the form:
| Field | Required | Description |
|---|---|---|
| Endpoint URL | Yes | Your HTTPS endpoint URL, e.g. https://your-api.com/earlyseo-webhook. Must be https:// (or http://localhost for local dev). |
| Access Token | Yes | A secret Bearer token you generate (minimum 12 characters, maximum 200). EarlySEO sends this in the Authorization header — validate it on your server to reject unauthorized requests. |
| HMAC Secret | No | A shared secret (minimum 12 characters) for cryptographic signature verification. If set, EarlySEO signs every request body with HMAC-SHA256. |
- Click Create Integration
EarlySEO sends a test POST with event type integration.test to verify connectivity. Your endpoint must return 2xx for setup to succeed.
Step 3 — Handle Incoming Requests
HTTP Headers
Every request from EarlySEO includes these headers:
| Header | Example Value | Description |
|---|---|---|
Content-Type | application/json | Always JSON. |
Authorization | Bearer your-secret-token | The Access Token you configured. Always validate this. |
X-EarlySEO-Event | article.published | Event type. Currently always article.published for articles, or integration.test for the setup test. |
X-EarlySEO-Delivery | a1b2c3d4e5... | Unique delivery ID (SHA-256 hash). Use for deduplication. |
Idempotency-Key | a1b2c3d4e5... | Same as delivery ID. Safe to use for idempotent upserts. |
X-EarlySEO-Signature | sha256=abc123... | HMAC-SHA256 signature of the raw request body. Only present if you configured an HMAC Secret. |
JSON Payload Structure
The body is a JSON object. The primary article data is in data.articles[0]:
{
"event_type": "publish_articles",
"timestamp": "2025-02-22T10:00:00.000Z",
"data": {
"articles": [
{
"id": "task_abc123",
"title": "Best Practices for API Design",
"slug": "api-design-best-practices",
"meta_description": "Short SERP-optimized description...",
"content_html": "<div class=\"earlyseo-article\"><style>...</style><h2>Introduction</h2><p>Styled HTML — works out of the box, colors overridable via CSS variables...</p></div>",
"content_raw_html": "<h2>Introduction</h2><p>Bare HTML — no wrapper or styles...</p>",
"content_css": ".earlyseo-article { color: var(--earlyseo-text-color, inherit); ... }",
"content_markdown": "<h2>Introduction</h2><p>...</p>",
"image_url": "https://cdn.example.com/featured.webp",
"created_at": "2025-02-22T10:00:00.000Z",
"tags": ["api", "design", "rest"]
}
]
},
"id": "a1b2c3d4e5f6...",
"type": "article.published",
"created_at": "2025-02-22T10:00:00.000Z",
"site": {
"id": "site_xyz",
"domain": "example.com"
},
"article": {
"id": "task_abc123",
"primary_keyword": "api design",
"title": "Best Practices for API Design",
"slug": "api-design-best-practices",
"meta_description": "Short SERP-optimized description...",
"html": "<h2>Introduction</h2><p>Clean HTML — inherits your site theme...</p>",
"styled_html": "<div class=\"earlyseo-article\"><style>...</style><h2>Introduction</h2><p>...</p></div>",
"featured_image": {
"url": "https://cdn.example.com/featured.webp",
"alt": "API design diagram"
},
"internal_links": [
{ "href": "/blog/rest-api-guide", "anchor": "REST API guide" }
],
"summary": "A comprehensive guide to API design...",
"keywords": ["api design", "rest api", "best practices"],
"tags": ["api", "design", "rest"]
},
"meta": {
"workspaceId": "ws_123",
"integrationId": "int_456",
"taskId": "task_abc123",
"idempotencyKey": "a1b2c3d4e5f6..."
}
}Field Reference
Use data.articles[0] — this is the primary, stable structure:
| Field | Type | Description |
|---|---|---|
data.articles[0].id | string | Unique article/task ID. |
data.articles[0].title | string | Article title. |
data.articles[0].slug | string | URL-safe slug, e.g. api-design-best-practices. |
data.articles[0].meta_description | string | SEO meta description. |
data.articles[0].content_html | string | Styled HTML with embedded <style> block inside .earlyseo-article wrapper. Works out of the box — tables, blockquotes, lists, code blocks all styled. All colors are CSS custom properties you can override. Use this for publishing. |
data.articles[0].content_raw_html | string | Completely bare HTML — no wrapper div, no styles. Use only if your site has full CSS for all HTML elements. |
data.articles[0].content_css | string | Standalone CSS for .earlyseo-article — useful if you want to manage the stylesheet separately instead of using the embedded <style>. |
data.articles[0].content_markdown | string | HTML content (legacy name). |
data.articles[0].image_url | string | null | Featured image URL. |
data.articles[0].created_at | string | ISO 8601 timestamp. |
data.articles[0].tags | string[] | Article tags/keywords. |
Additional fields available in the top-level article object:
| Field | Type | Description |
|---|---|---|
article.primary_keyword | string | The main SEO keyword. |
article.html | string | Styled HTML (same as content_html). |
article.raw_html | string | Bare HTML (same as content_raw_html). |
article.css | string | Standalone CSS (same as content_css). |
article.featured_image | { url, alt } | Featured image with alt text. |
article.internal_links | { href, anchor }[] | Suggested internal links. |
article.summary | string | Article summary. |
article.keywords | string[] | All target keywords. |
site.id | string | EarlySEO site ID. |
site.domain | string | Your site domain. |
meta.idempotencyKey | string | Use for dedup / idempotent upserts. |
Example: Next.js Route Handler
A complete, working receiver endpoint:
// app/api/earlyseo-webhook/route.ts
export async function POST(req: Request) {
// 1. Verify the Bearer token
const authHeader = req.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.EARLYSEO_WEBHOOK_TOKEN}`) {
return new Response("Unauthorized", { status: 401 });
}
const body = await req.json();
// 2. Handle test events (sent when you first create the integration)
if (body.type === "integration.test") {
return Response.json({ ok: true });
}
// 3. Extract article data
const article = body.data?.articles?.[0];
if (!article) {
return new Response("No article in payload", { status: 400 });
}
// 4. Use the idempotency key to prevent duplicate processing
const idempotencyKey = body.meta?.idempotencyKey;
// 5. Save or publish the article
// Example: save to your database
await db.article.upsert({
where: { externalId: idempotencyKey },
create: {
externalId: idempotencyKey,
title: article.title,
slug: article.slug,
html: article.content_html,
metaDescription: article.meta_description,
imageUrl: article.image_url,
tags: article.tags,
publishedAt: new Date(),
},
update: {
title: article.title,
html: article.content_html,
metaDescription: article.meta_description,
imageUrl: article.image_url,
tags: article.tags,
},
});
// 6. Return 2xx — EarlySEO considers anything else a failure
return Response.json({ ok: true });
}Environment Variable
EARLYSEO_WEBHOOK_TOKEN=your-secret-token-at-least-12-charsExample: Express.js
import express from "express";
const app = express();
app.use(express.json());
app.post("/earlyseo-webhook", (req, res) => {
// 1. Verify Bearer token
const auth = req.headers.authorization;
if (auth !== `Bearer ${process.env.EARLYSEO_WEBHOOK_TOKEN}`) {
return res.status(401).json({ error: "Unauthorized" });
}
// 2. Handle test events
if (req.body.type === "integration.test") {
return res.json({ ok: true });
}
// 3. Extract article
const article = req.body.data?.articles?.[0];
if (!article) {
return res.status(400).json({ error: "No article" });
}
// 4. Process article (save, publish, enqueue, etc.)
console.log("Received article:", article.title, article.slug);
// 5. Return 200 quickly — do heavy processing in a background job
res.json({ ok: true });
});
app.listen(3000);Example: Python (Flask)
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
@app.route("/earlyseo-webhook", methods=["POST"])
def earlyseo_webhook():
# 1. Verify Bearer token
auth = request.headers.get("Authorization", "")
expected = f"Bearer {os.environ['EARLYSEO_WEBHOOK_TOKEN']}"
if auth != expected:
return jsonify({"error": "Unauthorized"}), 401
body = request.get_json()
# 2. Handle test events
if body.get("type") == "integration.test":
return jsonify({"ok": True})
# 3. Extract article
articles = body.get("data", {}).get("articles", [])
if not articles:
return jsonify({"error": "No article"}), 400
article = articles[0]
# 4. Process article
# save_article(article["title"], article["slug"], article["content_html"], ...)
return jsonify({"ok": True})HMAC Signature Verification (Optional)
If you configured an HMAC Secret, EarlySEO signs the raw request body with HMAC-SHA256 and sends the signature in the X-EarlySEO-Signature header as sha256=<hex-digest>.
If no HMAC Secret is set, EarlySEO uses the Access Token as the HMAC key instead, so the signature header is always present.
Verification (Node.js)
import crypto from "crypto";
function verifySignature(rawBody: string, secret: string, signatureHeader: string): boolean {
// signatureHeader looks like: "sha256=abcdef1234..."
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// Use timingSafeEqual to prevent timing attacks
if (expected.length !== signatureHeader.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
// Usage in your route handler:
const rawBody = await req.text(); // read body as string BEFORE parsing
const signature = req.headers.get("x-earlyseo-signature");
if (signature && !verifySignature(rawBody, process.env.EARLYSEO_HMAC_SECRET!, signature)) {
return new Response("Invalid signature", { status: 403 });
}
const body = JSON.parse(rawBody);Verification (Python)
import hmac
import hashlib
def verify_signature(raw_body: bytes, secret: str, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)Retry & Delivery Behavior
| Behavior | Detail |
|---|---|
| Timeout | EarlySEO waits 10 seconds for your response. Return 2xx quickly and do heavy work in a background job. |
| Retries | Failed deliveries are retried up to 10 times with exponential backoff (starting at 60 seconds). |
| Retry conditions | Retries on 429, 5xx, timeouts, and network errors. 4xx errors (except 429) are not retried. |
| Idempotency | Each delivery includes an Idempotency-Key header. Use it to safely deduplicate — returning 409 Conflict is treated as a successful delivery. |
| Test event | When you create the integration, EarlySEO sends a test POST with type: "integration.test". Your endpoint must return 2xx. |
Response Format
Your endpoint should return a JSON body (optional). EarlySEO reads these fields if present:
{
"id": "your-internal-article-id",
"url": "https://your-site.com/blog/article-slug",
"externalId": "your-internal-article-id"
}idorexternalId— EarlySEO stores this as a reference to your article. Useful for future updates.url— the published article URL, shown in the EarlySEO dashboard.
If you return an empty body or plain { "ok": true }, EarlySEO uses its own delivery ID as the reference.
Server Requirements
- Must be reachable via HTTPS (
http://localhostis allowed for local development only) - Must return 2xx within 10 seconds
- Do heavy processing (database writes, image generation, etc.) in a background job — return
2xximmediately - Must accept
POSTrequests withContent-Type: application/json - Must validate the
Authorization: Bearer <token>header
Checklist for AI Agents
If you are an AI agent integrating EarlySEO webhooks for a user, follow this checklist:
- Create an HTTPS POST endpoint at a path like
/api/earlyseo-webhook - Read the
Authorizationheader and compare it to the stored secret token. Return401if it doesn't match. - Check
body.type— if it equals"integration.test", return200 { "ok": true }immediately (this is the setup verification). - Extract article data from
body.data.articles[0]— this containstitle,slug,content_html,meta_description,image_url, andtags. - Use
body.meta.idempotencyKey(or theIdempotency-Keyheader) as a unique key for upserts to prevent duplicates. - Save the article using
content_htmlfor the body — it includes embedded CSS that styles tables, blockquotes, and lists out of the box. All colors use CSS custom properties that the site's theme can override. - Return
200with an optional JSON body containing{ "id": "...", "url": "..." }. - Set the environment variable
EARLYSEO_WEBHOOK_TOKENto match the Access Token configured in EarlySEO. - (Optional) If HMAC verification is needed, read the raw request body as a string, verify against
X-EarlySEO-Signatureusing HMAC-SHA256. The HMAC key is the HMAC Secret (or Access Token if no HMAC Secret was set).