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

Sight AI does not have a native Sanity plugin today. Instead, you connect the two platforms with Sight AI’s Webhook integration 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.
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 as a no-code middle layer, though HTML and image handling are more limited.

How it works

1

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

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

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.

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:
// 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 fieldSight AI sourceNotes
titlearticle.titleRequired
slugarticle.slugUse { _type: 'slug', current: article.slug }
bodyHtml or bodyarticle.contentSee Body content options
excerptarticle.summaryOptional
seo.titlearticle.seo_titleOptional
seo.descriptionarticle.seo_meta_descriptionOptional
mainImagearticle.main_image_urlUpload to Sanity assets — see Image handling
categoryarticle.categoryString or reference — map in your handler
authorNamearticle.author_nameOptional 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 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:
VariableExample
SANITY_PROJECT_IDabc123de
SANITY_DATASETproduction
SANITY_WRITE_TOKENsk...

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.
For the full webhook contract (headers, payload fields, retry behavior, and security details), see the Webhook Integration reference.

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 if you need Portable Text instead.
// 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:
npm install @sanity/client
Deploy, then verify the route responds:
curl https://yourdomain.com/api/webhooks/sight-ai
# → {"status":"ok","endpoint":"sight-ai"}

Step 4: Configure Sight AI

1

Open webhook settings

In Sight AI, go to Integrations → Webhook for your site.
2

Enter your endpoint URL

Paste https://yourdomain.com/api/webhooks/sight-ai and save.
3

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

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

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

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: Add a string field to your schema and render it with dangerouslySetInnerHTML (or an HTML sanitizer) in your frontend:
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.
Convert HTML to Portable Text in your handler using @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 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 for details.

Field reference

Quick mapping from Sight AI webhook payload to Sanity:
Sight AI pathSanity field (example)
article.idsightAiId (upsert key)
article.slugslug.current
article.titletitle
article.contentbodyHtml or body (Portable Text)
article.summaryexcerpt
article.seo_titleseo.title
article.seo_meta_descriptionseo.description
article.main_image_urlmainImage (asset reference)
article.categorycategory (string or ref)
article.author_nameauthorName
article.target_keywordtargetKeyword (optional custom field)
article.is_featuredfeatured (boolean)
article.published_atpublishedAt (datetime)
Full payload spec: Webhook Integration → The payload.

Troubleshooting

Same causes as any webhook integration — see Webhook Troubleshooting → 401 Invalid signature. The most common mistake is hashing a re-parsed JSON body instead of the raw request string.
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.
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.
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.
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).
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.
Use 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.

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