EarlySEO LogoEarlySEO Docs

Webhook Integration

Receive EarlySEO article payloads via HTTP webhook — build a receiver endpoint, verify signatures, and publish content automatically.

Open .mdx

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 2xx

You do not poll EarlySEO. EarlySEO calls you.


Step 1 — Build Your Webhook Receiver Endpoint

Create an HTTPS endpoint on your server that:

  1. Accepts POST requests
  2. Validates the Authorization header (Bearer token)
  3. Parses the JSON body
  4. Saves / publishes the article
  5. Returns a 2xx response within 10 seconds

Step 2 — Register the Webhook in EarlySEO

  1. Go to Integrations in your EarlySEO dashboard
  2. Click Add Integration → select Webhook
  3. Fill in the form:
FieldRequiredDescription
Endpoint URLYesYour HTTPS endpoint URL, e.g. https://your-api.com/earlyseo-webhook. Must be https:// (or http://localhost for local dev).
Access TokenYesA 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 SecretNoA shared secret (minimum 12 characters) for cryptographic signature verification. If set, EarlySEO signs every request body with HMAC-SHA256.
  1. 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:

HeaderExample ValueDescription
Content-Typeapplication/jsonAlways JSON.
AuthorizationBearer your-secret-tokenThe Access Token you configured. Always validate this.
X-EarlySEO-Eventarticle.publishedEvent type. Currently always article.published for articles, or integration.test for the setup test.
X-EarlySEO-Deliverya1b2c3d4e5...Unique delivery ID (SHA-256 hash). Use for deduplication.
Idempotency-Keya1b2c3d4e5...Same as delivery ID. Safe to use for idempotent upserts.
X-EarlySEO-Signaturesha256=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:

FieldTypeDescription
data.articles[0].idstringUnique article/task ID.
data.articles[0].titlestringArticle title.
data.articles[0].slugstringURL-safe slug, e.g. api-design-best-practices.
data.articles[0].meta_descriptionstringSEO meta description.
data.articles[0].content_htmlstringStyled 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_htmlstringCompletely bare HTML — no wrapper div, no styles. Use only if your site has full CSS for all HTML elements.
data.articles[0].content_cssstringStandalone CSS for .earlyseo-article — useful if you want to manage the stylesheet separately instead of using the embedded <style>.
data.articles[0].content_markdownstringHTML content (legacy name).
data.articles[0].image_urlstring | nullFeatured image URL.
data.articles[0].created_atstringISO 8601 timestamp.
data.articles[0].tagsstring[]Article tags/keywords.

Additional fields available in the top-level article object:

FieldTypeDescription
article.primary_keywordstringThe main SEO keyword.
article.htmlstringStyled HTML (same as content_html).
article.raw_htmlstringBare HTML (same as content_raw_html).
article.cssstringStandalone CSS (same as content_css).
article.featured_image{ url, alt }Featured image with alt text.
article.internal_links{ href, anchor }[]Suggested internal links.
article.summarystringArticle summary.
article.keywordsstring[]All target keywords.
site.idstringEarlySEO site ID.
site.domainstringYour site domain.
meta.idempotencyKeystringUse 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-chars

Example: 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

BehaviorDetail
TimeoutEarlySEO waits 10 seconds for your response. Return 2xx quickly and do heavy work in a background job.
RetriesFailed deliveries are retried up to 10 times with exponential backoff (starting at 60 seconds).
Retry conditionsRetries on 429, 5xx, timeouts, and network errors. 4xx errors (except 429) are not retried.
IdempotencyEach delivery includes an Idempotency-Key header. Use it to safely deduplicate — returning 409 Conflict is treated as a successful delivery.
Test eventWhen 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"
}
  • id or externalId — 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://localhost is allowed for local development only)
  • Must return 2xx within 10 seconds
  • Do heavy processing (database writes, image generation, etc.) in a background job — return 2xx immediately
  • Must accept POST requests with Content-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:

  1. Create an HTTPS POST endpoint at a path like /api/earlyseo-webhook
  2. Read the Authorization header and compare it to the stored secret token. Return 401 if it doesn't match.
  3. Check body.type — if it equals "integration.test", return 200 { "ok": true } immediately (this is the setup verification).
  4. Extract article data from body.data.articles[0] — this contains title, slug, content_html, meta_description, image_url, and tags.
  5. Use body.meta.idempotencyKey (or the Idempotency-Key header) as a unique key for upserts to prevent duplicates.
  6. Save the article using content_html for 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.
  7. Return 200 with an optional JSON body containing { "id": "...", "url": "..." }.
  8. Set the environment variable EARLYSEO_WEBHOOK_TOKEN to match the Access Token configured in EarlySEO.
  9. (Optional) If HMAC verification is needed, read the raw request body as a string, verify against X-EarlySEO-Signature using HMAC-SHA256. The HMAC key is the HMAC Secret (or Access Token if no HMAC Secret was set).

On this page