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

# Sanity CMS Integration

> Sync AI-generated articles from Sight AI into Sanity using the webhook integration and a custom receiver.

## Overview

Sight AI does not have a native Sanity plugin today. Instead, you connect the two platforms with Sight AI's **[Webhook integration](/integrations/webhook)** plus a small custom API route that writes incoming articles into your Sanity dataset.

This is the same pattern Sanity recommends for syncing external systems: Sight AI POSTs a signed `article.ready` payload to your endpoint, and your handler creates or updates Sanity documents via the Content API.

<Note>
  This integration requires a developer. If you don't have one in-house, your Sanity agency can usually set this up in half a day. Non-technical teams can also use [Zapier](/integrations/zapier) as a no-code middle layer, though HTML and image handling are more limited.
</Note>

## How it works

<Steps>
  <Step title="Sight AI ships an article">
    When you sync manually, Autopilot runs, or an AI agent publishes, Sight AI POSTs the full article (HTML, SEO fields, images, metadata) to your webhook URL.
  </Step>

  <Step title="Your handler verifies and upserts">
    Your API route verifies the HMAC signature, looks up an existing Sanity document by `article.id`, and creates or updates it.
  </Step>

  <Step title="Your frontend reads from Sanity">
    Your Next.js site (or other frontend) continues to fetch content from Sanity with GROQ — nothing changes on the read path.
  </Step>
</Steps>

```mermaid theme={null}
flowchart LR
  SA[Sight AI] -->|POST article.ready| WH[Your webhook handler]
  WH -->|Content API| S[Sanity dataset]
  FE[Your site] -->|GROQ| S
```

## Requirements

* **Sanity project** — With a dataset and a `post` (or equivalent) schema
* **Sanity write token** — Editor or custom role with create/update permissions
* **Public HTTPS endpoint** — Vercel, Netlify, Cloudflare Workers, etc. (local dev via ngrok works for testing)
* **Developer access** — To add a schema field, deploy the webhook route, and configure environment variables
* **Sight AI workspace** — Owner or admin role to configure the webhook and set it as the active CMS

No approval from Sanity is required. You use Sanity's standard public APIs with your own project credentials.

## Step 1: Add a Sight AI ID to your Sanity schema

Store Sight AI's stable `article.id` on every document so re-syncs update in place instead of creating duplicates. **Never upsert by slug alone** — users can rename slugs inside Sight AI.

Add a hidden, read-only field to your post schema:

```ts theme={null}
// schemas/post.ts
defineField({
  name: 'sightAiId',
  title: 'Sight AI ID',
  type: 'string',
  readOnly: true,
  hidden: true,
}),
```

Also make sure you have fields for the content Sight AI sends. A typical blog schema includes:

| Sanity field         | Sight AI source                | Notes                                                           |
| -------------------- | ------------------------------ | --------------------------------------------------------------- |
| `title`              | `article.title`                | Required                                                        |
| `slug`               | `article.slug`                 | Use `{ _type: 'slug', current: article.slug }`                  |
| `bodyHtml` or `body` | `article.content`              | See [Body content options](#body-content-options)               |
| `excerpt`            | `article.summary`              | Optional                                                        |
| `seo.title`          | `article.seo_title`            | Optional                                                        |
| `seo.description`    | `article.seo_meta_description` | Optional                                                        |
| `mainImage`          | `article.main_image_url`       | Upload to Sanity assets — see [Image handling](#image-handling) |
| `category`           | `article.category`             | String or reference — map in your handler                       |
| `authorName`         | `article.author_name`          | Optional string                                                 |

Deploy your schema changes to Sanity Studio before wiring up the webhook.

## Step 2: Create a Sanity write token

1. Go to [sanity.io/manage](https://www.sanity.io/manage) and open your project
2. Navigate to **API → Tokens**
3. Click **Add API token**
4. Name it `Sight AI webhook` (or similar)
5. Set permissions to **Editor** (or a custom role with document create/update and asset upload)
6. Copy the token — you won't see it again

You'll need these values in your handler:

| Variable             | Example      |
| -------------------- | ------------ |
| `SANITY_PROJECT_ID`  | `abc123de`   |
| `SANITY_DATASET`     | `production` |
| `SANITY_WRITE_TOKEN` | `sk...`      |

## Step 3: Deploy a webhook handler

The handler lives in **your** codebase (not inside Sight AI). Below is a complete Next.js App Router example. Drop it at `app/api/webhooks/sight-ai/route.ts`.

<Tip>
  For the full webhook contract (headers, payload fields, retry behavior, and security details), see the [Webhook Integration](/integrations/webhook) reference.
</Tip>

### Environment variables

Add these to your hosting provider (e.g. Vercel → Project → Settings → Environment Variables):

```
SIGHT_AI_WEBHOOK_SECRET=...   # from Sight AI webhook settings
SANITY_PROJECT_ID=...
SANITY_DATASET=production
SANITY_WRITE_TOKEN=sk...
```

### Example handler (HTML body field)

This example stores article HTML in a `bodyHtml` text field — the fastest path to a working integration. See [Body content options](#body-content-options) if you need Portable Text instead.

```ts theme={null}
// app/api/webhooks/sight-ai/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@sanity/client';
import crypto from 'node:crypto';

const REPLAY_WINDOW_MS = 5 * 60 * 1000;

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  token: process.env.SANITY_WRITE_TOKEN!,
  apiVersion: '2024-01-01',
  useCdn: false,
});

export async function GET() {
  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 });
  }

  const rawBody = await request.text();

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

  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 out of range' }, { status: 401 });
  }

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

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

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

  const existingId = await sanity.fetch<string | null>(
    `*[_type == "post" && sightAiId == $id][0]._id`,
    { id: article.id },
  );

  const mainImage = await resolveMainImage(existingId, article.main_image_url);

  const doc = {
    _type: 'post',
    ...(existingId ? { _id: existingId } : {}),
    sightAiId: article.id,
    title: article.title,
    slug: { _type: 'slug', current: article.slug },
    bodyHtml: article.content,
    excerpt: article.summary ?? undefined,
    seo: {
      title: article.seo_title ?? undefined,
      description: article.seo_meta_description ?? undefined,
    },
    authorName: article.author_name ?? undefined,
    category: article.category ?? undefined,
    targetKeyword: article.target_keyword ?? undefined,
    featured: article.is_featured,
    publishedAt: article.published_at ?? undefined,
    ...(mainImage ? { mainImage } : {}),
  };

  const result = await sanity.createOrReplace(doc);

  return NextResponse.json({
    ok: true,
    operation: existingId ? 'updated' : 'created',
    sanityId: result._id,
    sightAiId: article.id,
    event_id,
  });
}

async function resolveMainImage(
  existingDocId: string | null,
  imageUrl: string | null | undefined,
) {
  if (!imageUrl) return undefined;

  if (existingDocId) {
    const existing = await sanity.fetch<{ mainImage?: unknown } | null>(
      `*[_id == $id][0]{ mainImage }`,
      { id: existingDocId },
    );
    if (existing?.mainImage) return undefined;
  }

  const response = await fetch(imageUrl);
  if (!response.ok) return undefined;

  const buffer = Buffer.from(await response.arrayBuffer());
  const filename = imageUrl.split('/').pop()?.split('?')[0] ?? 'featured.jpg';

  const asset = await sanity.assets.upload('image', buffer, { filename });
  return {
    _type: 'image',
    asset: { _type: 'reference', _ref: asset._id },
  };
}

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 = {
  test?: boolean;
  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;
  };
};
```

Install the Sanity client in your project:

```bash theme={null}
npm install @sanity/client
```

Deploy, then verify the route responds:

```bash theme={null}
curl https://yourdomain.com/api/webhooks/sight-ai
# → {"status":"ok","endpoint":"sight-ai"}
```

## Step 4: Configure Sight AI

<Steps>
  <Step title="Open webhook settings">
    In Sight AI, go to **Integrations → Webhook** for your site.
  </Step>

  <Step title="Enter your endpoint URL">
    Paste `https://yourdomain.com/api/webhooks/sight-ai` and save.
  </Step>

  <Step title="Copy the webhook secret">
    Use **Managed by Sight AI** (recommended). Copy the secret when shown and add it as `SIGHT_AI_WEBHOOK_SECRET` in your hosting environment, then redeploy.
  </Step>

  <Step title="Test the connection">
    Click **Test connection**. Your handler should return `2xx`. Test events include `"test": true` — the example above short-circuits those without writing to Sanity.
  </Step>

  <Step title="Set webhook as Active CMS">
    Click **Set as Active CMS**. From now on, articles that ship from Sight AI — manual sync, Autopilot, or AI agents — are delivered to your Sanity handler instead of WordPress, Webflow, or another CMS.
  </Step>
</Steps>

<Warning>
  Each Sight AI site can have **one active CMS at a time**. Setting webhook as active does not delete other integrations — you can switch back anytime.
</Warning>

## Body content options

Sight AI sends `article.content` as **HTML**. Sanity schemas usually use **Portable Text** (`array` of `block` types), not raw HTML. Pick the approach that fits your stack:

### Option A — HTML field (recommended for getting started)

Add a string field to your schema and render it with `dangerouslySetInnerHTML` (or an HTML sanitizer) in your frontend:

```ts theme={null}
defineField({
  name: 'bodyHtml',
  title: 'Body (HTML)',
  type: 'text',
}),
```

**Pros:** Fastest to implement, no conversion step, preserves Sight AI formatting exactly.\
**Cons:** You manage HTML rendering and sanitization yourself.

### Option B — Portable Text (recommended for production Studio UX)

Convert HTML to Portable Text in your handler using [`@portabletext/block-tools`](https://www.npmjs.com/package/@portabletext/block-tools) and your schema's block types. This gives editors a native Sanity editing experience after the initial import.

**Pros:** Native Studio editing, consistent with other Sanity content.\
**Cons:** More setup — conversion quality varies by HTML complexity; test with real Sight AI output.

If you go this route, replace `bodyHtml: article.content` in the example with a `htmlToBlocks()` call keyed to your block schema. Sanity's guide on [integrating external data sources](https://www.sanity.io/guides/integrating-external-data) covers the sync-plugin pattern in more depth.

## Image handling

Sanity expects images in its asset library, not external CDN URLs. Follow these rules to avoid duplicate assets:

* **First delivery for an `article.id`** — download `main_image_url` and upload to Sanity (as in the example above)
* **Subsequent deliveries** — skip re-upload if the document already has `mainImage`
* **User swaps the image in Sight AI** — clear `mainImage` in Sanity (or add an admin action), then re-sync from Sight AI

Alternatively, store the CDN URL as a plain string field and skip Sanity assets entirely if your frontend can render external images.

## Category mapping

Sight AI sends `article.category` as a **name string**, not a Sanity document ID. In your handler you can:

* **Store the name directly** on a string field (simplest)
* **Resolve to a category reference** — query `*[_type == "category" && title == $name][0]._id` and set a reference field
* **Use Sight AI Filters** — define categories in the webhook **Filters** tab with `external_id` values you map in code (the name is still what arrives in the payload today)

## Publishing workflow

### Manual sync

1. Open an article in Sight AI
2. Click **Send Webhook** (or **Sync to CMS** when webhook is active)
3. Confirm the delivery in **Integrations → Webhook → Monitoring**

### Autopilot and AI agents

When webhook is the active CMS, Autopilot and agent-driven publishes use the same delivery path automatically — no extra configuration.

### Trigger mode

Under webhook **Advanced settings**:

* **Manual** (default) — fires when you explicitly sync, via Autopilot/agents, or bulk actions
* **Automatic** — also fires immediately when non-Autopilot generation completes

See [Webhook Integration → Trigger mode](/integrations/webhook#trigger-mode) for details.

## Field reference

Quick mapping from Sight AI webhook payload to Sanity:

| Sight AI path                  | Sanity field (example)                  |
| ------------------------------ | --------------------------------------- |
| `article.id`                   | `sightAiId` (upsert key)                |
| `article.slug`                 | `slug.current`                          |
| `article.title`                | `title`                                 |
| `article.content`              | `bodyHtml` or `body` (Portable Text)    |
| `article.summary`              | `excerpt`                               |
| `article.seo_title`            | `seo.title`                             |
| `article.seo_meta_description` | `seo.description`                       |
| `article.main_image_url`       | `mainImage` (asset reference)           |
| `article.category`             | `category` (string or ref)              |
| `article.author_name`          | `authorName`                            |
| `article.target_keyword`       | `targetKeyword` (optional custom field) |
| `article.is_featured`          | `featured` (boolean)                    |
| `article.published_at`         | `publishedAt` (datetime)                |

Full payload spec: [Webhook Integration → The payload](/integrations/webhook#the-payload).

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 Invalid signature">
    Same causes as any webhook integration — see [Webhook Troubleshooting → 401 Invalid signature](/integrations/webhook#401-invalid-signature). The most common mistake is hashing a re-parsed JSON body instead of the raw request string.
  </Accordion>

  <Accordion title="Articles keep duplicating in Sanity">
    You're likely upserting by `slug` instead of `article.id`. Add the `sightAiId` field, query by it, and use `createOrReplace` with the existing `_id`. Slug renames in Sight AI must update the existing document, not create a new one.
  </Accordion>

  <Accordion title="Images are multiplying in the Media library">
    You're re-uploading on every webhook delivery. Gate uploads: only fetch and upload when the Sanity document has no `mainImage` yet. Sight AI re-sends the same URL on every edit and re-sync.
  </Accordion>

  <Accordion title="HTML looks wrong in Sanity Studio">
    You're storing HTML in a Portable Text field (or vice versa). Either use a dedicated `bodyHtml` text field, or convert HTML to blocks before writing to a `body` array field.
  </Accordion>

  <Accordion title="403 or insufficient permissions from Sanity">
    Your write token lacks create/update or asset upload permissions. Regenerate a token with Editor access (or grant `create`, `update`, and `upload` on the relevant document/asset types).
  </Accordion>

  <Accordion title="Webhook succeeds but the live site doesn't update">
    The Sanity write succeeded, but your frontend cache hasn't refreshed. If you use Next.js ISR, call `revalidatePath` for the article and listing routes in your handler after the Sanity write. See [Webhook Integration → Cache invalidation](/integrations/webhook#cache-invalidation).
  </Accordion>

  <Accordion title="I don't have a developer — can I still do this?">
    Use [Zapier](/integrations/zapier) with a **Webhooks by Zapier → Catch Hook** trigger and a Sanity action module. It's faster to prototype but harder to get right for HTML bodies and featured images. Most Sanity customers work with their agency for a one-time setup.
  </Accordion>
</AccordionGroup>

## Best practices

* **Upsert by `article.id`** — store it as `sightAiId` on every document
* **Verify HMAC on the raw body** before parsing JSON
* **Return 2xx within 30 seconds** — do slow work (image upload) only if it fits your timeout budget
* **Don't re-upload images** on every delivery
* **Use HTTPS in production** — required for non-localhost webhook URLs
* **Keep secrets in env vars** — never commit `SANITY_WRITE_TOKEN` or `SIGHT_AI_WEBHOOK_SECRET` to git
* **Monitor deliveries** in Sight AI under **Integrations → Webhook → Monitoring**

## Next steps

* [Webhook Integration](/integrations/webhook) — full payload spec, security, and retry behavior
* [Autopilot](/ai-content/autopilot) — automate article generation and delivery
* [Bing IndexNow](/integrations/bing-indexnow) — notify search engines when new URLs go live
* [Sanity: Integrating external data sources](https://www.sanity.io/guides/integrating-external-data) — Sanity's official sync-plugin guide
