# Webhook Integration (/docs/webhook)



How It Works [#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 [#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 [#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:

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

4. 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 [#step-3--handle-incoming-requests]

HTTP Headers [#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 [#json-payload-structure]

The body is a JSON object. The primary article data is in `data.articles[0]`:

```json
{
  "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 [#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 [#example-nextjs-route-handler]

A complete, working receiver endpoint:

```ts
// 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 [#environment-variable]

```bash
EARLYSEO_WEBHOOK_TOKEN=your-secret-token-at-least-12-chars
```

***

Example: Express.js [#example-expressjs]

```ts
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) [#example-python-flask]

```python
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) [#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) [#verification-nodejs]

```ts
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) [#verification-python]

```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 [#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 [#response-format]

Your endpoint should return a JSON body (optional). EarlySEO reads these fields if present:

```json
{
  "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 [#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 [#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).
