Skip to main content

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.

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

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

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

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

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

ItemValue
MethodPOST
Content-Typeapplication/json
AuthHMAC-SHA256 signature in X-SightAI-Signature
Replay window5 minutes (X-SightAI-Timestamp in milliseconds)
Expected response2xx within 30 seconds
Retry policyUp to 3 retries with exponential backoff on 5xx / timeout
Auto-pauseAfter 10 consecutive failed deliveries

Headers Sight AI sends

HeaderValueNotes
Content-Typeapplication/jsonAlways.
X-SightAI-Signaturesha256=<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-Version1.0Payload schema version.
User-AgentSightAI-Webhook/1.0Helpful 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.
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.

The payload

{
  "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

PathTypeRequiredWhat it’s for
event_idstringYesIdempotency key. Same logical event → same event_id. Use it to dedupe retries on your side.
eventstringYesCurrently always "article.ready". See Event types.
timestampISO-8601 stringYesWhen the event was generated. (Replay protection uses the X-SightAI-Timestamp header, not this.)
site.id / site.name / site.hoststringYesThe Sight AI workspace this article belongs to. site.host is whatever the user entered in their site settings, including the protocol.
article.idstringYesThe upsert key. Stable for the article’s lifetime.
article.slugstringYesURL slug — use this to construct /blog/<slug>. Can change between deliveries if a user renames the article.
article.titlestringYesDisplay title.
article.contentstring (HTML)YesFull article body, ready to render.
article.summarystringOptionalShort excerpt. May be null.
article.seo_titlestringOptional≤60 chars recommended. May be null.
article.seo_meta_descriptionstringOptional≤160 chars recommended. May be null.
article.target_keywordstringOptionalThe primary keyword the article targets.
article.main_image_urlstring (URL)OptionalFeatured image. May be null. See Image handling.
article.thumbnail_image_urlstring (URL)OptionalSmaller version of the featured image.
article.article_typestringYese.g. "explainer", "listicle", "how-to".
article.categorystringOptionalThe 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_namestringOptionalDisplay name.
article.read_time_minutesnumberOptionalEstimated read time.
article.is_featuredbooleanYesWhether the user marked it as a featured article.
article.published_atISO-8601 string | nullOptionalWhen the article was published in Sight AI. null if unpublished.
article.created_atISO-8601 stringYesWhen the article was first created in Sight AI.
article.updated_atISO-8601 stringYesWhen the article was last edited in Sight AI. Useful for “is this newer than what I have?” checks.
Optional fields may be null or absent from the JSON entirely — your parser must accept both. Don’t assume every key is present.

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

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()).
2

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

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

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

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 eventarticle.ready covers both cases.
6

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.

Verifying the signature

The HMAC signature is the only thing standing between your endpoint and an attacker who knows the URL. Get this right.
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'),
  );
}
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.

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.).
import { revalidatePath, revalidateTag } from 'next/cache';

revalidatePath(`/blog/${article.slug}`);
revalidatePath('/blog');
if (article.category) {
  revalidatePath(`/topics/${slugify(article.category)}`);
}
revalidateTag(`article:${article.id}`);
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.

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

1

Create the route file

Save the code above as app/api/webhooks/sight-ai/route.ts in your Next.js project.
2

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

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"}.
4

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

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.

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

Max retries / Request timeout

Under Advanced settings:
SettingDefaultRange
Max retries30 – 10
Request timeout30 seconds5 – 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:
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

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:
StatusMeaningSight AI behavior
2xxDelivery acceptedMarked as delivered. No retry.
4xxPermanent failure (bad payload, bad signature, etc.)Not retried — the request itself is wrong, retrying won’t help.
5xxTransient failureRetried up to 3 times with exponential backoff.
Timeout (>30s)Treated as transientRetried.
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
  • 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

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

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

ConcernAnswer
Endpoint methodPOST
AuthX-SightAI-Signature: sha256=<hmac_hex>
Replay window5 minutes (X-SightAI-Timestamp in ms)
Upsert keyarticle.id
Dedupe keyevent_id
Image dedupDon’t re-download once stored — gate on “is the image already saved?”
Cache bustingrevalidatePath (Next.js) or framework equivalent
Test eventspayload.test === true and/or event_id starts with test_
RetriesUp to 3 with exponential backoff, on 5xx / timeout only
Auto-pauseAfter 10 consecutive failures

Next steps