Docs / Publishing & integrations / Webhook

Receive published content via webhook

When AEO Spotlight's Content Studio publishes to a webhook destination, it POSTs a single signed, versioned JSON document to your endpoint. This guide shows you how to receive it, verify it, and turn it into a live page — idempotently.

For site ownersFor CMS & developer teamsFor agencies
Working reference implementation AEO Spotlight publishes its own site (News / Research / Knowledge) through this exact customer path — signature verification, delivery, persistence. The examples below mirror that receiver.

1 · How it works

Publishing a piece to a webhook destination performs one synchronous HTTPS POST of the PublishPayload to your endpoint:

Requesthttp
POST https://your-site.example.com/_aeo/webhook
Content-Type: application/json
X-AeoSpotlight-Signature: sha256=<hex-hmac-of-raw-body>
X-AeoSpotlight-Event: content.published
X-AeoSpotlight-Content-Id: <opportunity-id>

{ "SchemaVersion": "1.0", "Title": "...", "BodyHtml": "<article>…", ... }

As the receiver you must, in order:

1

Read the raw request body exactly as it arrives (bytes / exact string), before any JSON parsing or reformatting.

2

Verify the signature with your shared secret (section 3). Never trust the payload before this passes.

3

Upsert the content keyed by Slug (optionally guarded by ContentId/ContentVersion) so re-publishes update the existing page instead of duplicating.

4

Return a 2xx response that includes the live URL (section 6) so the platform records the real page URL and can later match it against AI citations.

Two webhook channels — don't confuse them AEO Spotlight has two separate outbound webhook systems. Both share the same X-AeoSpotlight-Signature: sha256=… scheme, but delivery differs:
  1. Publish-destination webhook (this guide) — delivers the full PublishPayload article to a webhook publish destination. Sent synchronously, once, during the publish action (30 s timeout). No automatic background retry.
  2. Event webhook subscriptions (the Public API) — small event notifications (content.published, content.cited, mention.detected, …) to an endpoint you register against an API key. Delivered asynchronously with retries and dead-lettering. See Public API → Webhook subscriptions.

2 · Create a “webhook” publish destination

Publish destinations are managed in the dashboard under Content Studio → Publish destinations.

1

Click New destination.

2

Type:Webhook / Custom Endpoint.

3

Name: a label you'll recognize (e.g. Acme blog receiver).

4

Endpoint URL: your receiver's public HTTPS URL. It must be an absolute http(s) URL resolving to a public host — AEO Spotlight SSRF-guards every endpoint and rejects private / loopback / link-local / CGNAT hosts. For local development, expose localhost with a tunnel (ngrok / cloudflared) and use the tunnel URL here.

5

Secret: a shared signing secret. Always set one. Secrets are write-only — the UI never shows the value back, only whether one is set. If you leave it blank the platform sends requests unsigned, which you should reject.

6

(Optional) IndexNow host + key — see Set up IndexNow.

7

Save, then publish a piece to this destination to test the end-to-end flow.

3 · Signing & signature verification

3.1 What the sender sends

Every request carries:

X-AeoSpotlight-Signature: sha256=<hex>

where <hex> is the lowercase hex encoding of an HMAC-SHA256 computed over the exact raw request body, keyed with the destination's secret:

signature_hex = lowercase( hex( HMAC-SHA256( key = secret, message = raw_body ) ) )
header_value  = "sha256=" + signature_hex
  • Hash: SHA-256. Secret bytes = UTF-8 encoding of the secret string.
  • Message = the raw body exactly as received. Do not re-encode, re-pretty-print, or parse-and-reserialize — any whitespace change breaks the signature.
  • The digest is lowercase hex. The sha256= prefix match is case-insensitive; the hex comparison must be constant-time.

3.2 Verify on your side (constant time)

Node.js — verify before trustingjs
const crypto = require('crypto');

function verifySignature(secret, rawBody, signatureHeader) {
  if (!signatureHeader || !signatureHeader.toLowerCase().startsWith('sha256=')) {
    return false;
  }
  const providedHex = signatureHeader.slice('sha256='.length).trim();
  const expectedHex = crypto
    .createHmac('sha256', secret)
    .update(rawBody, 'utf8')   // raw body string — exactly as received
    .digest('hex');            // lowercase hex

  const a = Buffer.from(providedHex, 'utf8');
  const b = Buffer.from(expectedHex, 'utf8');
  if (a.length !== b.length) return false;   // compare lengths first
  return crypto.timingSafeEqual(a, b);       // constant-time compare
}
Pythonpython
import hashlib, hmac

def verify_signature(secret: str, body: bytes, signature: str) -> bool:
    if not signature or not signature.lower().startswith("sha256="):
        return False
    provided = signature[len("sha256="):].strip()
    expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + provided, expected)
Sign over the raw body bytes If your framework reads the body as a string, keep that exact string (no normalization). If you read it as a stream/buffer, hash the buffer.

4 · The PublishPayload contract

SchemaVersion: "1.0". Property names are serialized PascalCase on the wire. Parse case-insensitively so you're robust to future serialization changes.

FieldTypeRequiredDescription
SchemaVersionstringPayload contract version, currently "1.0". Branch on this if it ever changes.
ContentIdstring | nullOpportunity ID. Idempotency + correlation key; also echoed in the X-AeoSpotlight-Content-Id header.
ContentVersionstring | nullContent revision. A re-publish keeps the same ContentId but carries a new version — use it to ignore stale deliveries.
TitlestringArticle title.
SlugstringURL slug (already slugified). Use as the upsert key.
ContentTypestring | nullInformational. For AEO Spotlight's own-site destination: knowledge | research | news. Ignored by webhook receivers.
BodyHtmlstringArticle body, HTML, already humanized (a content blob — not a full <html> document).
SchemaJsonLdstringJSON-LD schema markup as a JSON-encoded string. JSON.parse it before embedding.
MetaDescriptionstring | nullMeta description (~160 chars).
CanonicalUrlstring | nullCanonical URL when one was set.
FeaturedImageUrlstring | nullHero / featured image URL when available.
Authorstring | nullByline (user-provided or the destination default). Never auto-derived. Cap at ~200 chars defensively.
PublishDatestring (ISO-8601) | nullPublish timestamp, UTC.
UpdatedDatestring (ISO-8601) | nullLast-updated timestamp, UTC.
TargetEntitiesstring[]Entity names the article is written to be discoverable for in AI answers.
Headingsstring[]Heading structure (H2…), handy for TOCs and layout.

4.1 Example payload

Example bodyjson
{
  "SchemaVersion": "1.0",
  "ContentId": "a1b2c3d4-0000-0000-0000-000000000000",
  "ContentVersion": "638000000000000000",
  "Title": "How Acme Helps Law Firms Win AI Answers",
  "Slug": "acme-helps-law-firms-win-ai-answers",
  "ContentType": null,
  "BodyHtml": "<p>When someone asks an AI assistant which legal-tech vendor to use…</p><h2>Why AI answers matter</h2><p>…</p>",
  "SchemaJsonLd": "{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"headline\":\"How Acme Helps Law Firms Win AI Answers\"}",
  "MetaDescription": "See how Acme makes law firms appear in AI-generated answers — and the playbook that earns citations.",
  "CanonicalUrl": null,
  "FeaturedImageUrl": "https://cdn.example.com/acme-hero.png",
  "Author": "AEO Spotlight",
  "PublishDate": "2026-09-09T12:00:00Z",
  "UpdatedDate": "2026-09-09T12:00:00Z",
  "TargetEntities": ["Acme", "legal-tech", "AI visibility"],
  "Headings": ["Why AI answers matter", "The citation playbook", "Measuring it"]
}

5 · Idempotent receiver examples

Idempotency contract: upsert by Slug. Re-publishing the same piece (same slug, possibly a new ContentVersion) must update the existing page and return the same live URL — never create a second page.

5.1 Node.js / Express

POST /_aeo/webhookjs
const express = require('express');
const crypto = require('crypto');

const app = express();
const SECRET = process.env.AEO_WEBHOOK_SECRET;   // same secret as the destination

// Capture the raw body BEFORE any json parser touches it.
app.post('/_aeo/webhook', express.text({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body;                                  // exact raw string
  const signature = req.get('X-AeoSpotlight-Signature');

  // 1) Verify the signature before trusting anything.
  if (!verifySignature(SECRET, rawBody, signature)) {
    return res.status(401).json({ error: 'invalid signature' });
  }

  let payload;
  try {
    payload = JSON.parse(rawBody);                           // now safe to parse
  } catch {
    return res.status(400).json({ error: 'malformed json' });
  }
  if (!payload.Title || !payload.BodyHtml) {
    return res.status(400).json({ error: 'title and body required' });
  }

  // 2) Upsert by slug (DB-specific; pseudocode).
  const slug = payload.Slug;
  const existing = await db.article.findBySlug(slug);
  const fields = {
    title: payload.Title,
    bodyHtml: payload.BodyHtml,
    schemaJsonLd: payload.SchemaJsonLd,
    metaDescription: payload.MetaDescription,
    canonicalUrl: payload.CanonicalUrl,
    author: payload.Author,
    publishDate: payload.PublishDate,
    updatedDate: payload.UpdatedDate,
    headings: payload.Headings,
  };
  if (existing) {
    await db.article.update(existing.id, fields);
  } else {
    await db.article.create({ slug, ...fields, featuredImageUrl: payload.FeaturedImageUrl });
  }

  // 3) Return the live URL so the platform records the real page URL.
  const liveUrl = `https://your-site.example.com/${slug}`;
  return res.status(200).json({ url: liveUrl });
});

function verifySignature(secret, rawBody, signatureHeader) {
  if (!secret || !signatureHeader || !signatureHeader.toLowerCase().startsWith('sha256=')) return false;
  const providedHex = signatureHeader.slice('sha256='.length).trim();
  const expectedHex = crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
  const a = Buffer.from(providedHex, 'utf8');
  const b = Buffer.from(expectedHex, 'utf8');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

app.listen(8080);
Gotcha: don't put express.json() on this route — it consumes and re-serializes the body. Use express.text() (or read the raw body) so the string you hash is byte-identical to what the sender signed.

5.2 C# (concise — mirrors the reference receiver)

POST /_aeo/webhookcsharp
[ApiController]
[Route("_aeo/webhook")]
public class AeoWebhookController : ControllerBase
{
    private const string SignatureHeader = "X-AeoSpotlight-Signature";
    private const string Prefix = "sha256=";
    private readonly string _secret = /* your destination secret (config/env) */;

    [HttpPost]
    public async Task<IActionResult> Receive(CancellationToken ct)
    {
        // 1) Raw body exactly as received.
        using var reader = new StreamReader(Request.Body, Encoding.UTF8);
        var rawBody = await reader.ReadToEndAsync(ct);

        // 2) Verify the signature (constant time) before trusting anything.
        var sig = Request.Headers.TryGetValue(SignatureHeader, out var v) ? v.ToString() : null;
        if (!Verify(sig, rawBody))
            return Unauthorized(new { error = "Invalid signature." });

        var payload = JsonSerializer.Deserialize<PublishPayload>(rawBody,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
        if (payload is null ||
            string.IsNullOrWhiteSpace(payload.Title) ||
            string.IsNullOrWhiteSpace(payload.BodyHtml))
            return BadRequest(new { error = "Title and body HTML are required." });

        // 3) Upsert by slug — update existing, else create. (Pseudocode.)
        var slug = Slugify(payload.Slug);
        var item = await _repo.FindBySlugAsync(slug, ct);
        item = item is null
            ? await _repo.CreateAsync(ToEntity(payload, slug), ct)
            : await _repo.UpdateAsync(item.Id, ToEntity(payload, slug), ct);

        // 4) Return the live URL so the platform records the real page URL.
        return Ok(new { url = item.Url ?? $"{BaseUrl}/{slug}" });
    }

    private bool Verify(string? header, string rawBody)
    {
        if (string.IsNullOrWhiteSpace(header) ||
            !header.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase))
            return false;

        var providedHex = header[Prefix.Length..].Trim();
        var expectedHex = Convert.ToHexString(
                HMACSHA256.HashData(Encoding.UTF8.GetBytes(_secret),
                                    Encoding.UTF8.GetBytes(rawBody)))
            .ToLowerInvariant();

        if (providedHex.Length != expectedHex.Length) return false;
        return CryptographicOperations.FixedTimeEquals(
            Encoding.ASCII.GetBytes(providedHex),
            Encoding.ASCII.GetBytes(expectedHex));
    }
}

6 · Expected success response

Return any 2xx to signal success. Include the canonical live URL so the platform records the real page URL (not your webhook endpoint) on the opportunity — that URL is what later gets monitored and matched against AI citations.

The sender extracts the live URL in this order: a Location response header, then a JSON body field named url, location, or publishedUrl.

Recommended responsehttp
HTTP/1.1 200 OK
Content-Type: application/json

{ "url": "https://your-site.example.com/acme-helps-law-firms-win-ai-answers" }
Why not { "succeeded": true, "liveUrl": "…" }? That still counts as a 2xx success, but the sender won't find url/location/publishedUrl, so it falls back to storing your webhook endpoint URL as the live URL. Return url (or a Location header) if you want the real page tracked.

7 · Delivery, errors & retries

7.1 Publish-destination webhook (this guide's channel)

  • Delivery is synchronous and single-attempt — part of the publish action.
  • One POST with a 30-second timeout.
  • 2xx → publish succeeds; the live URL is recorded; the platform then pings IndexNow (if configured) and flips the piece to published.
  • Non-2xx or network failure/timeout → the publish action fails with the status/reason surfaced. The piece is not marked published. There is no automatic retry of the destination POST — retry the publish from the Content Studio (or Public API) once your receiver is healthy.
  • Because the sender can re-send on a manual retry, keep the receiver idempotent by Slug.

7.2 Event webhook subscriptions (the retried channel)

The separate Public API event webhook channel does have durable retry semantics (delivered by the platform's delivery worker for subscriptions created via POST /api/v1/webhooks):

  • Retries with backoff 5s → 10s → 30s → 60s → 120s, max 5 attempts, then dead-lettered and surfaced for inspection.
  • 10s timeout per attempt; any non-2xx or timeout is retried; one failing endpoint never blocks other deliveries.
  • Same X-AeoSpotlight-Signature: sha256=… HMAC, plus a stable event_id you can dedupe on (retries keep the same event_id).
Want guaranteed/retried delivery when content publishes? Subscribe to the content.published event ({ opportunityId, liveUrl, indexNowPinged }) via the Public API in addition to receiving the full article here.

8 · Security checklist

  • HTTPS only. Never send a secret-signed payload over plain HTTP.
  • Verify the signature before trusting anything, against the raw body bytes, before JSON parsing. Reject on mismatch.
  • Constant-time comparison (crypto.timingSafeEqual / CryptographicOperations.FixedTimeEquals / hmac.compare_digest) — never plain == on hex strings.
  • Always set a secret on the destination, and treat unsigned requests as invalid.
  • Idempotency by Slug (+ optional ContentId/ContentVersion) so retries and re-publishes never duplicate.
  • Treat content as untrusted HTML.BodyHtml is generated and humanized but still remote content — sanitize on the server before persisting/rendering, and escape SchemaJsonLd when embedding.
  • Cap fields defensively (the reference receiver caps Author at 200 chars) so oversized input can't break your insert.
  • Your endpoint must be public. The platform's SSRF guard rejects private/loopback hosts. During development, use a tunnel.
  • SchemaVersion is your compat switch if the contract ever evolves.

9 · Testing your receiver

  • End-to-end: publish a piece to your webhook destination in the Content Studio and confirm it arrives; verify the Content Studio shows the returned url as the live URL.
  • Reference endpoints: AEO Spotlight's own receiver runs at POST /api/site-content/webhooks/{news|research|knowledge}.
  • Local dev: expose your receiver with a public HTTPS tunnel (ngrok / cloudflared) and set the destination endpoint URL to the tunnel URL.
  • Craft a signed test request yourself: send any body and set X-AeoSpotlight-Signature: sha256=<lowercase-hex HMAC-SHA256(secret, rawBody)>.