> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trysight.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Integration

> Receive every Sight AI article on your own endpoint — including custom sites, headless CMSes, and any platform we don't natively support.

## Overview

The webhook integration is the **build-it-yourself** path. Every time an article is ready to ship — whether it was generated manually, by Autopilot, or by an AI agent — Sight AI POSTs the full article (HTML, SEO copy, images, metadata) as a single signed JSON request to a URL you control. What happens next is up to you: write it to your CMS, push it through a Zapier/Make scenario, drop it into a database, fan it out to multiple downstream systems — anything that can accept an HTTP POST.

This page is the official guide for building that endpoint. If you're integrating with a custom Next.js site (the most common case), we walk you through the entire route handler at the bottom — copy, paste, change three lines, ship.

## How it works (read this first)

A 30-second mental model that prevents 90% of the bugs we see:

<Steps>
  <Step title="One event type — every time">
    Every successful sync from Sight AI fires a single event named **`article.ready`**, regardless of whether it's the first time the article has ever shipped or the hundredth re-sync. Don't branch on the event name — branch on whether the article already exists in your system.
  </Step>

  <Step title="Upsert on `article.id`, not `article.slug`">
    `article.id` is Sight AI's stable identifier and **never changes** for the lifetime of an article. `article.slug` *can* change — users edit it from inside Sight AI when they want to rename a URL. If you key on slug, a rename in Sight AI will look like "delete the old article + create a new one" on your side. **Always upsert by `article.id`.**
  </Step>

  <Step title="Dedupe on `event_id` (optional but recommended)">
    Each delivery includes an `event_id`. If the same `event_id` arrives twice (network retry, queue at-least-once, etc.), it's the same logical event — drop the duplicate. Sight AI also dedupes server-side within a 60-minute window, so this is belt-and-braces for the rare cases that slip through.
  </Step>

  <Step title="Verify the HMAC signature, then return 2xx fast">
    Verify `X-SightAI-Signature` against the **raw request body** before you trust anything in the payload. Then return a 2xx within 30 seconds — even if you haven't finished processing yet. Long synchronous handlers cause timeouts and trigger retries you don't need.
  </Step>
</Steps>

That's the whole contract. Everything below is the spec for each piece, and a worked Next.js example.

## Use cases

* **Custom sites** built on Next.js, SvelteKit, Nuxt, Astro, Remix, etc.
* **Headless CMSes** we don't natively integrate with (Payload, [Sanity](/integrations/sanity), Strapi, Contentful, Hygraph, Storyblok, Directus…)
* **Automation tools** like Zapier, Make, n8n, Pipedream
* **Custom pipelines** that fan content out to multiple destinations
* **Internal CMSes** behind your firewall (with a public-facing relay)

## The endpoint contract

| Item              | Value                                                       |
| ----------------- | ----------------------------------------------------------- |
| Method            | `POST`                                                      |
| Content-Type      | `application/json`                                          |
| Auth              | HMAC-SHA256 signature in `X-SightAI-Signature`              |
| Replay window     | 5 minutes (`X-SightAI-Timestamp` in milliseconds)           |
| Expected response | `2xx` within **30 seconds**                                 |
| Retry policy      | Up to 3 retries with exponential backoff on `5xx` / timeout |
| Auto-pause        | After 10 consecutive failed deliveries                      |

### Headers Sight AI sends

| Header                                                                       | Value                 | Notes                                                                                                                                                                                |
| ---------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Content-Type`                                                               | `application/json`    | Always.                                                                                                                                                                              |
| `X-SightAI-Signature`                                                        | `sha256=<hex>`        | HMAC-SHA256 of the **raw request body**, keyed with your webhook secret. The `sha256=` prefix is part of the value. When signing is disabled, this is the literal string `unsigned`. |
| `X-SightAI-Timestamp`                                                        | `<unix_ms>`           | Unix timestamp in **milliseconds** when the request was signed. Reject anything more than 5 minutes off your clock.                                                                  |
| `X-SightAI-Version`                                                          | `1.0`                 | Payload schema version.                                                                                                                                                              |
| `User-Agent`                                                                 | `SightAI-Webhook/1.0` | Helpful in your access logs.                                                                                                                                                         |
| `X-IndexPilot-Signature` / `X-IndexPilot-Timestamp` / `X-IndexPilot-Version` | (legacy)              | Identical values to the `X-SightAI-*` headers, sent for backward compatibility with older integrations. New integrations should read only the `X-SightAI-*` headers.                 |

<Note>
  Some runtimes lowercase header names. In Next.js / Vercel, read the signature as `request.headers.get('x-sightai-signature')` (all lowercase). HTTP header names are case-insensitive per the spec — both forms work.
</Note>

### The payload

```json theme={null}
{
  "event_id": "a1b2c3d4e5f6...",
  "event": "article.ready",
  "timestamp": "2026-05-05T14:30:15.000Z",
  "site": {
    "id": "site_abc123",
    "name": "My Site",
    "host": "https://example.com"
  },
  "article": {
    "id": "art_xyz789",
    "slug": "how-to-build-better-content",
    "title": "How to Build Better Content",
    "content": "<h1>How to Build Better Content</h1><p>...</p>",
    "summary": "Learn how to build better content with this guide.",
    "seo_title": "How to Build Better Content | My Site",
    "seo_meta_description": "Learn how to build better content...",
    "target_keyword": "build better content",
    "main_image_url": "https://cdn.sightai.io/images/main.jpg",
    "thumbnail_image_url": "https://cdn.sightai.io/images/thumb.jpg",
    "article_type": "explainer",
    "category": "Content Marketing",
    "author_name": "Sight AI",
    "read_time_minutes": 5,
    "is_featured": false,
    "published_at": null,
    "created_at": "2026-05-05T12:00:00.000Z",
    "updated_at": "2026-05-05T14:30:15.000Z"
  }
}
```

#### Field reference

| Path                                  | Type                    | Required | What it's for                                                                                                                                 |
| ------------------------------------- | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_id`                            | string                  | Yes      | Idempotency key. Same logical event → same `event_id`. Use it to dedupe retries on your side.                                                 |
| `event`                               | string                  | Yes      | Currently always `"article.ready"`. See [Event types](#event-types).                                                                          |
| `timestamp`                           | ISO-8601 string         | Yes      | When the event was generated. (Replay protection uses the `X-SightAI-Timestamp` header, not this.)                                            |
| `site.id` / `site.name` / `site.host` | string                  | Yes      | The Sight AI workspace this article belongs to. `site.host` is whatever the user entered in their site settings, including the protocol.      |
| `article.id`                          | string                  | Yes      | **The upsert key.** Stable for the article's lifetime.                                                                                        |
| `article.slug`                        | string                  | Yes      | URL slug — use this to construct `/blog/<slug>`. Can change between deliveries if a user renames the article.                                 |
| `article.title`                       | string                  | Yes      | Display title.                                                                                                                                |
| `article.content`                     | string (HTML)           | Yes      | Full article body, ready to render.                                                                                                           |
| `article.summary`                     | string                  | Optional | Short excerpt. May be `null`.                                                                                                                 |
| `article.seo_title`                   | string                  | Optional | ≤60 chars recommended. May be `null`.                                                                                                         |
| `article.seo_meta_description`        | string                  | Optional | ≤160 chars recommended. May be `null`.                                                                                                        |
| `article.target_keyword`              | string                  | Optional | The primary keyword the article targets.                                                                                                      |
| `article.main_image_url`              | string (URL)            | Optional | Featured image. May be `null`. See [Image handling](#image-handling).                                                                         |
| `article.thumbnail_image_url`         | string (URL)            | Optional | Smaller version of the featured image.                                                                                                        |
| `article.article_type`                | string                  | Yes      | e.g. `"explainer"`, `"listicle"`, `"how-to"`.                                                                                                 |
| `article.category`                    | string                  | Optional | The category **name** assigned in Sight AI (not an ID). Use the `Filters` tab in Sight AI to set an `external_id` for mapping. May be `null`. |
| `article.author_name`                 | string                  | Optional | Display name.                                                                                                                                 |
| `article.read_time_minutes`           | number                  | Optional | Estimated read time.                                                                                                                          |
| `article.is_featured`                 | boolean                 | Yes      | Whether the user marked it as a featured article.                                                                                             |
| `article.published_at`                | ISO-8601 string \| null | Optional | When the article was published in Sight AI. `null` if unpublished.                                                                            |
| `article.created_at`                  | ISO-8601 string         | Yes      | When the article was first created in Sight AI.                                                                                               |
| `article.updated_at`                  | ISO-8601 string         | Yes      | When the article was last edited in Sight AI. Useful for "is this newer than what I have?" checks.                                            |

<Tip>
  Optional fields may be **`null`** or **absent from the JSON entirely** — your parser must accept both. Don't assume every key is present.
</Tip>

### Event types

`event` is currently always **`"article.ready"`**. Every sync — first publish, manual re-send, autopilot delivery, agent-driven update — uses this same event name. The receiver decides whether it's a create or an update by looking at whether `article.id` already exists in your system.

`article.updated` and `article.published` are reserved for future use and are not emitted by Sight AI today. If you want to be forward-compatible, treat any unknown event name as "ignore, return 200" so future event types don't break your endpoint.

## What to do when a webhook arrives

Your handler should do these six things, roughly in this order:

<Steps>
  <Step title="Read the raw request body">
    Don't let a body parser run first. You need the **exact bytes** of the request to verify the HMAC signature. In Next.js App Router that means `await request.text()` (not `request.json()`).
  </Step>

  <Step title="Verify the signature">
    Compute `HMAC-SHA256(secret, raw_body)` and compare it (timing-safe) to the value of `X-SightAI-Signature` after stripping the `sha256=` prefix. If it doesn't match, return **`401`** and stop.
  </Step>

  <Step title="Verify the timestamp">
    Reject if `|Date.now() - X-SightAI-Timestamp| > 5 minutes`. This prevents an attacker from replaying a captured request days later. Return **`401`** on failure.
  </Step>

  <Step title="Parse the JSON">
    Now that the body is verified, parse it. Validate that `article.id`, `article.slug`, `article.title`, and `article.content` exist — return **`400`** if not.
  </Step>

  <Step title="Upsert by `article.id`">
    Look up the article in your database by `article.id`. If found → update it in place. If not found → create a new record. Don't branch on `event` — `article.ready` covers both cases.
  </Step>

  <Step title="Acknowledge with 2xx, then do the heavy lifting async">
    Return a 200 response **as soon as the database write is committed**. If you also need to fetch images, push to a third-party API, regenerate static pages, etc., kick those off in the background or do them inline only if they're fast (\<1s). Aim to acknowledge well under the 30-second timeout.
  </Step>
</Steps>

## Verifying the signature

The HMAC signature is the only thing standing between your endpoint and an attacker who knows the URL. Get this right.

```ts theme={null}
import crypto from 'crypto';

function verifySignature(rawBody: string, signatureHeader: string, secret: string): boolean {
  if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');

  const provided = signatureHeader.slice('sha256='.length);

  // Both buffers must be the same length for timingSafeEqual.
  if (expected.length !== provided.length) return false;

  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(provided, 'hex'),
  );
}
```

<Warning>
  **Sign the raw bytes, not a re-stringified object.** If you `JSON.parse(body)` and then `JSON.stringify` it again before hashing, key order and whitespace will drift and the HMAC will mismatch. Always hash the exact string you received over the wire.
</Warning>

## Image handling

Sight AI sends `main_image_url` and `thumbnail_image_url` as URLs hosted on our CDN. You have two reasonable strategies:

### Strategy 1 — reference the URL directly (simplest)

Just store the URL string and render it as `<img src="{main_image_url}">`. No download, no copy, no media library bookkeeping. Recommended for most custom sites unless you have a specific reason to host the image yourself.

### Strategy 2 — download and host the image yourself

Common when you're feeding a CMS that has a Media collection (Payload, Sanity, etc.) and wants every asset stored locally. The trick is **don't re-download on every webhook** — Sight AI re-sends the article (with the same image URL) on every edit, every autopilot run, every manual re-sync. If you naively re-download each time, you'll pile up duplicates.

A safe rule of thumb:

* **First time you see this `article.id`** → download and store the image, save a reference on the article record.
* **Article exists but has no stored image yet** → download.
* **Article already has a stored image** → leave it alone, even if `main_image_url` looks slightly different. CDN cache-buster query params drift cosmetically without the underlying asset changing.
* **User wants to swap the image** → expose a manual "clear stored image" admin action, then trigger a re-send from Sight AI. The next webhook will see "no stored image" and download fresh.

If your CMS has its own asset deduplication (e.g. content-hash-keyed storage), you can skip this state machine and just upload every time — the CMS will collapse duplicates on its end.

## Cache invalidation

If your site uses ISR, SSG, edge caching, or any kind of build-time rendering, the live page won't reflect the new content until the cache expires. Bust the cache for the affected paths inside your handler so users see the update immediately.

For Next.js App Router that means calling `revalidatePath` for every page that displays this article — the article page, the index/listing, and any topic/category page it appears on. Other frameworks have equivalents (`unstable_revalidate` for SvelteKit, `purge` for Astro, etc.).

```ts theme={null}
import { revalidatePath, revalidateTag } from 'next/cache';

revalidatePath(`/blog/${article.slug}`);
revalidatePath('/blog');
if (article.category) {
  revalidatePath(`/topics/${slugify(article.category)}`);
}
revalidateTag(`article:${article.id}`);
```

<Tip>
  Wrap revalidation in a `try/catch`. The database write is your source of truth — if revalidation throws for some transient reason, your ISR backstop will pick up the change within a minute, so it's not worth failing the webhook over.
</Tip>

## Worked example: Next.js App Router

A complete, copy-paste-ready route handler for a Next.js site. Drop this at `app/api/webhooks/sight-ai/route.ts`, set `SIGHT_AI_WEBHOOK_SECRET` in your environment, and you're done.

```ts theme={null}
// app/api/webhooks/sight-ai/route.ts
import { NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';
import crypto from 'node:crypto';
import { db } from '@/lib/db'; // your DB client (Prisma, Drizzle, raw, …)

const REPLAY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes

export async function GET() {
  // Useful for uptime monitors and "is this deployed?" checks.
  return NextResponse.json({ status: 'ok', endpoint: 'sight-ai' });
}

export async function POST(request: Request) {
  const secret =
    process.env.SIGHT_AI_WEBHOOK_SECRET ?? process.env.SIGHTAI_WEBHOOK_SECRET;

  if (!secret) {
    return NextResponse.json(
      { error: 'Webhook not configured' },
      { status: 503 },
    );
  }

  // 1. Read the RAW body — required for signature verification.
  const rawBody = await request.text();

  // 2. Verify the signature.
  const signature = request.headers.get('x-sightai-signature') ?? '';
  if (!verifySignature(rawBody, signature, secret)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  // 3. Verify the timestamp (replay protection).
  const timestampHeader = request.headers.get('x-sightai-timestamp');
  const timestamp = Number(timestampHeader);
  if (
    !Number.isFinite(timestamp) ||
    Math.abs(Date.now() - timestamp) > REPLAY_WINDOW_MS
  ) {
    return NextResponse.json(
      { error: 'Timestamp too old or in future' },
      { status: 401 },
    );
  }

  // 4. Parse and validate the payload.
  let payload: SightAiWebhookPayload;
  try {
    payload = JSON.parse(rawBody);
  } catch {
    return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
  }

  const { article, event_id } = payload;
  if (!article?.id || !article.slug || !article.title || !article.content) {
    return NextResponse.json(
      { error: 'Missing required article fields' },
      { status: 400 },
    );
  }

  // 5. Idempotency: if we've already processed this event_id, no-op.
  if (event_id && (await db.webhookEvent.exists({ event_id }))) {
    return NextResponse.json({ ok: true, deduped: true });
  }

  // 6. Upsert by article.id (NOT slug — slugs can change).
  const existing = await db.article.findByExternalId(article.id);

  const data = {
    external_id: article.id,            // store this — it's your upsert key
    slug: article.slug,                  // may have changed since last delivery
    title: article.title,
    content_html: article.content,
    summary: article.summary ?? null,
    seo_title: article.seo_title ?? null,
    seo_description: article.seo_meta_description ?? null,
    main_image_url: article.main_image_url ?? null,
    thumbnail_image_url: article.thumbnail_image_url ?? null,
    category: article.category ?? null,
    author_name: article.author_name ?? null,
    read_time_minutes: article.read_time_minutes ?? null,
    is_featured: article.is_featured,
    published_at: article.published_at ?? new Date().toISOString(),
    sight_updated_at: article.updated_at,
  };

  const operation = existing ? 'updated' : 'created';
  if (existing) {
    await db.article.update(existing.id, data);
  } else {
    await db.article.create(data);
  }

  if (event_id) {
    await db.webhookEvent.record({ event_id, article_id: article.id });
  }

  // 7. Bust caches so the live site reflects the new content immediately.
  try {
    revalidatePath(`/blog/${article.slug}`);
    revalidatePath('/blog');
    if (existing && existing.slug !== article.slug) {
      revalidatePath(`/blog/${existing.slug}`); // old URL when slug changed
    }
    revalidateTag(`article:${article.id}`);
  } catch (err) {
    console.warn('[sight-ai] revalidation failed (ISR will recover)', err);
  }

  return NextResponse.json({
    ok: true,
    operation,
    article_id: article.id,
    slug: article.slug,
  });
}

function verifySignature(rawBody: string, header: string, secret: string) {
  if (!header.startsWith('sha256=')) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')
    .digest('hex');

  const provided = header.slice('sha256='.length);
  if (expected.length !== provided.length) return false;

  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(provided, 'hex'),
  );
}

type SightAiWebhookPayload = {
  event_id?: string;
  event: 'article.ready' | string;
  timestamp: string;
  site: { id: string; name: string; host: string };
  article: {
    id: string;
    slug: string;
    title: string;
    content: string;
    summary?: string | null;
    seo_title?: string | null;
    seo_meta_description?: string | null;
    target_keyword?: string | null;
    main_image_url?: string | null;
    thumbnail_image_url?: string | null;
    article_type: string;
    category?: string | null;
    author_name?: string | null;
    read_time_minutes?: number | null;
    is_featured: boolean;
    published_at?: string | null;
    created_at: string;
    updated_at: string;
  };
};
```

The handler covers every behavior we recommend:

* ✅ Verifies the HMAC against the **raw** body
* ✅ Rejects timestamps older than 5 minutes
* ✅ **Upserts by `article.id`** (so slug renames don't create duplicates)
* ✅ **Dedupes by `event_id`** (so retries are no-ops)
* ✅ **Revalidates** the article page, the listing, and the old slug if the URL changed
* ✅ Falls back gracefully if revalidation throws

### Setting it up

<Steps>
  <Step title="Create the route file">
    Save the code above as `app/api/webhooks/sight-ai/route.ts` in your Next.js project.
  </Step>

  <Step title="Set the webhook secret">
    Add `SIGHT_AI_WEBHOOK_SECRET` to your hosting environment (Vercel → Project → Settings → Environment Variables, or your platform's equivalent). The value must match what you configure in Sight AI's webhook settings.

    <Tip>
      If you're using Vercel, use Sight AI's **Managed by Sight AI** secret mode, copy the value the first time it's shown, and paste it into Vercel as a production environment variable.
    </Tip>
  </Step>

  <Step title="Deploy">
    Deploy your site so the new route is live, then verify it responds: `curl https://yourdomain.com/api/webhooks/sight-ai` should return `{"status":"ok","endpoint":"sight-ai"}`.
  </Step>

  <Step title="Configure Sight AI">
    In Sight AI, go to **Integrations → Webhook**, enter `https://yourdomain.com/api/webhooks/sight-ai`, save, and click **Test connection**. You should see a green "Connection successful".
  </Step>

  <Step title="Set webhook as your active CMS">
    On the same page, click **Set as Active CMS**. From now on every article that ships from Sight AI — manual sync, Autopilot, or AI agents — will arrive on your endpoint.
  </Step>
</Steps>

## Configuring the webhook in Sight AI

### Webhook URL

The endpoint Sight AI will POST to. Must be HTTPS in production (HTTP is allowed for `localhost` during development). Allowed ports: 80, 443, 3000, 8080, 8443.

### Webhook secret

You have two choices:

* **Managed by Sight AI** *(recommended)* — Sight AI generates a strong random secret for you. You see the value once, after the first save. Copy it into your endpoint's environment, then save again to confirm. Rotate anytime from the Connect tab.
* **Bring your own** — paste a secret you generated. Stored AES-256 encrypted at rest. Useful if you have central secret management.

### Sign requests

On by default. When on, every delivery includes the `X-SightAI-Signature` HMAC. **Leave this on in production** unless you have a very specific reason — your endpoint URL alone is not authentication.

### Verify SSL

On by default. When on, Sight AI rejects endpoints with invalid TLS certificates. Disable only for local development against self-signed certs.

### Trigger mode

Under **Advanced settings**:

* **Manual** *(default)* — webhooks fire only when a user clicks **Send Webhook** on an article, or runs a bulk sync, or it's triggered by Autopilot / an AI agent's planner queue.
* **Automatic** — webhooks fire as soon as article generation completes, with no human in the loop.

<Tip>
  In practice, **Manual mode + Autopilot/Agents will still send webhooks automatically** because those systems trigger the same delivery pathway as the manual button. "Automatic" specifically targets the *generation completion* moment for non-Autopilot workflows.
</Tip>

### Max retries / Request timeout

Under **Advanced settings**:

| Setting         | Default    | Range           |
| --------------- | ---------- | --------------- |
| Max retries     | 3          | 0 – 10          |
| Request timeout | 30 seconds | 5 – 120 seconds |

Failed deliveries (5xx response or timeout) retry with exponential backoff. After **10 consecutive failures** across deliveries, the integration is auto-paused and you'll get an in-app notification — fix your endpoint and click **Reactivate**.

### Filters (categories)

Optional. The **Filters** tab lets you define category names that appear as options when generating an article. Each category can have an `external_id` you set, which is *not* sent in the payload today — but `article.category` (the category **name** the user picked) is, so you can map names to your downstream IDs in your handler.

## Testing your endpoint

### From inside Sight AI

The **Test connection** button on the Connect tab fires a real signed request to your URL with the `event` set to `article.ready` and the additional field `"test": true` in the payload. The article fields contain example data — your endpoint should accept it and return 2xx for the test to pass.

To distinguish test events from real ones in your handler, check for `payload.test === true` or for `event_id` starting with `test_`. We recommend short-circuiting test events:

```ts theme={null}
if (payload.test === true) {
  return NextResponse.json({ ok: true, test: true });
}
```

That way a test never writes to your real database.

### From the command line

```bash theme={null}
SECRET='your-shared-secret'
TS=$(($(date +%s) * 1000))
BODY='{"event_id":"manual-test","event":"article.ready","timestamp":"2026-05-05T14:30:15.000Z","site":{"id":"s","name":"S","host":"https://example.com"},"article":{"id":"art_local_test","slug":"hello","title":"Hello","content":"<p>Hello</p>","article_type":"explainer","is_featured":false,"created_at":"2026-05-05T14:30:15.000Z","updated_at":"2026-05-05T14:30:15.000Z"}}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')"

curl -i -X POST https://yourdomain.com/api/webhooks/sight-ai \
  -H 'Content-Type: application/json' \
  -H "X-SightAI-Signature: $SIG" \
  -H "X-SightAI-Timestamp: $TS" \
  -H 'X-SightAI-Version: 1.0' \
  --data "$BODY"
```

If your endpoint logs the request and returns 2xx, you're done.

## Response handling

Your endpoint should respond with:

| Status         | Meaning                                              | Sight AI behavior                                                   |
| -------------- | ---------------------------------------------------- | ------------------------------------------------------------------- |
| `2xx`          | Delivery accepted                                    | Marked as delivered. No retry.                                      |
| `4xx`          | Permanent failure (bad payload, bad signature, etc.) | **Not retried** — the request itself is wrong, retrying won't help. |
| `5xx`          | Transient failure                                    | Retried up to 3 times with exponential backoff.                     |
| Timeout (>30s) | Treated as transient                                 | Retried.                                                            |

Respond as quickly as you can — definitely under 30 seconds, ideally under a couple. If you need to do slow work (image processing, third-party API calls), commit the database write first, return 2xx, and finish the slow work in a background job.

## Delivery logs and monitoring

Inside Sight AI, the **Monitoring** tab on the webhook integration page shows:

* Total deliveries / success rate / failure count
* The most recent 20 deliveries with HTTP status, timing, and error messages
* A "Refresh" button that hits the same data live

If a webhook fails, click the row to see the full error response from your endpoint. The most common errors are:

* `401 Invalid signature` — see [Troubleshooting](#troubleshooting)
* `500 Internal Server Error` — your handler threw; check your server logs
* `Request timeout` — your handler took longer than the configured timeout

## Active CMS

Each Sight AI workspace can have **one active CMS at a time**. When you click **Set as Active CMS** on the webhook page, every article that ships from Sight AI for that workspace — manual, Autopilot, or AI agent — flows through your webhook instead of any previously connected CMS (WordPress, Webflow, etc.). Switching to a different CMS later is a one-click change; your webhook configuration is preserved.

## Disconnecting

To stop sending webhooks for a workspace:

1. Go to **Integrations → Webhook** for that workspace.
2. Click **Disconnect**.

Your saved categories are preserved in case you reconnect later. If you also want to wipe the secret, use the rotate flow on the Connect tab before disconnecting.

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 Invalid signature">
    Almost always one of:

    * **You hashed the wrong bytes.** Body parsers (Express `body-parser`, Hono's `c.req.json()`, etc.) consume the raw body before you see it. Capture the raw body *first*, hash that, then parse.
    * **You re-stringified the JSON.** `JSON.stringify(JSON.parse(body))` does **not** produce the same bytes — key order and whitespace can differ. Always hash the original string.
    * **Wrong secret.** Confirm the secret in your environment matches what's in Sight AI exactly. No whitespace, no quotes, no `Bearer` prefix.
    * **Missing `sha256=` prefix.** The header value is `sha256=<hex>`. Don't strip the prefix before storing it; do strip it before comparing.
    * **Hex case mismatch.** We send lowercase hex. Use a buffer comparison, not a string `===`.
  </Accordion>

  <Accordion title="401 Timestamp too old or in future">
    `X-SightAI-Timestamp` is a Unix timestamp in **milliseconds** (not seconds). It must be within 5 minutes of your server's clock.

    * If your code is reading it as seconds, multiply by 1000 (or vice versa).
    * If your server clock is drifting, enable NTP (`timedatectl status` on Linux).
    * If your queue or proxy can buffer requests longer than 5 minutes, that's a problem — by the time the request lands the timestamp is already stale. Sight AI's retry will re-sign with a fresh timestamp on the next attempt, so this usually self-corrects.
  </Accordion>

  <Accordion title="503 Webhook not configured">
    Your endpoint returned 503 because the `SIGHT_AI_WEBHOOK_SECRET` (or `SIGHTAI_WEBHOOK_SECRET`) environment variable isn't set. Add it to your hosting environment and redeploy.
  </Accordion>

  <Accordion title="400 Missing required article fields">
    Your handler asked for a field that the payload didn't include. The hard-required fields are `article.id`, `article.slug`, `article.title`, and `article.content`. Everything else can be `null` or absent — make sure your validator accepts that.
  </Accordion>

  <Accordion title="Same article keeps creating new records on my side">
    You're upserting by `article.slug` instead of `article.id`. When a user renames an article inside Sight AI, the slug changes but the `id` doesn't — so a slug-keyed receiver looks like "delete + re-create" while an `id`-keyed receiver correctly updates in place. Migrate your lookup to `article.id` (store it as a column on your articles table) and the duplicates stop.
  </Accordion>

  <Accordion title="Live page doesn't update even though the webhook succeeded">
    Your database wrote the new content, but a cached copy of the page is still being served. Two checks:

    * **Did you call `revalidatePath` (or your framework's equivalent)?** ISR/SSG will eventually pick up the change on its next regeneration window, but until then the stale page sticks.
    * **Did the field you care about actually change?** A re-send of an unedited article is intentionally a no-op visually — the database write happens, but the rendered HTML is identical.
  </Accordion>

  <Accordion title="Image collection is filling up with duplicates">
    You're re-downloading `main_image_url` on every webhook. Sight AI re-sends the same article — with the same image URL — on every edit, every Autopilot run, every manual sync. Add a guard: only download when the article is brand new on your side, or when the local image record is missing. See [Image handling](#image-handling).
  </Accordion>

  <Accordion title="Endpoint shows as auto-paused in Sight AI">
    After 10 consecutive failed deliveries, Sight AI auto-pauses the integration to stop hammering a broken endpoint. Fix the underlying issue (check delivery logs for the error message), then click **Reactivate** on the webhook page.
  </Accordion>
</AccordionGroup>

## Best practices

* **Be idempotent.** Use `event_id` as a dedupe key. Retries and at-least-once delivery should produce the same end state, not duplicate writes.
* **Upsert by `article.id`.** The single most important rule. Never key on `slug`.
* **Verify the HMAC on the raw body.** Don't parse, don't re-stringify, don't trim.
* **Acknowledge fast, work async.** A 200 within seconds is the goal; long synchronous handlers cause needless retries.
* **Use HTTPS in production.** Required for non-localhost endpoints.
* **Store secrets safely.** Treat the webhook secret like an API key — secrets manager or hosting env vars only, never in source control.
* **Bust your cache.** If your site is statically rendered or behind ISR, revalidate the affected paths inside your handler.
* **Log generously while you're integrating.** Once you're confident, dial logs back to errors only — `article.content` can be large, and you don't need it in every log line.

## Example integrations

### Zapier (Webhooks by Zapier)

1. New Zap → trigger: **Webhooks by Zapier → Catch Hook**
2. Copy the unique webhook URL Zapier generates.
3. Paste it into Sight AI under Integrations → Webhook → Webhook URL.
4. (Optional) Skip request signing in Sight AI — Zapier doesn't natively verify HMACs. Treat the URL itself as the secret.
5. Add Zap actions (Create Notion page, append to Google Sheets, post to Slack, etc.).

### Make (Integromat)

1. New scenario → **Webhooks → Custom webhook**.
2. Copy the URL Make assigns.
3. Configure in Sight AI as above.
4. Build downstream modules.

### n8n / Pipedream / others

Same pattern: create a webhook trigger node, paste the URL into Sight AI, build the rest of the workflow.

## Reference

| Concern         | Answer                                                                |
| --------------- | --------------------------------------------------------------------- |
| Endpoint method | `POST`                                                                |
| Auth            | `X-SightAI-Signature: sha256=<hmac_hex>`                              |
| Replay window   | 5 minutes (`X-SightAI-Timestamp` in ms)                               |
| Upsert key      | `article.id`                                                          |
| Dedupe key      | `event_id`                                                            |
| Image dedup     | Don't re-download once stored — gate on "is the image already saved?" |
| Cache busting   | `revalidatePath` (Next.js) or framework equivalent                    |
| Test events     | `payload.test === true` and/or `event_id` starts with `test_`         |
| Retries         | Up to 3 with exponential backoff, on 5xx / timeout only               |
| Auto-pause      | After 10 consecutive failures                                         |

## Next steps

* [Generate articles to send via webhook](/ai-content/overview)
* [Automate with Autopilot](/ai-content/autopilot)
* [Set up Bing IndexNow so new URLs are indexed instantly](/integrations/bing-indexnow)
