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.
1 · How it works
Publishing a piece to a webhook destination performs one synchronous HTTPS POST of the PublishPayload to your endpoint:
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:
Read the raw request body exactly as it arrives (bytes / exact string), before any JSON parsing or reformatting.
Verify the signature with your shared secret (section 3). Never trust the payload before this passes.
Upsert the content keyed by Slug (optionally guarded by ContentId/ContentVersion) so re-publishes update the existing page instead of duplicating.
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.
X-AeoSpotlight-Signature: sha256=… scheme, but delivery differs: - Publish-destination webhook (this guide) — delivers the full
PublishPayloadarticle to a webhook publish destination. Sent synchronously, once, during the publish action (30 s timeout). No automatic background retry. - 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.
Click New destination.
Type:Webhook / Custom Endpoint.
Name: a label you'll recognize (e.g. Acme blog receiver).
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.
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.
(Optional) IndexNow host + key — see Set up IndexNow.
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)
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
}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)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.
| Field | Type | Required | Description |
|---|---|---|---|
SchemaVersion | string | ✓ | Payload contract version, currently "1.0". Branch on this if it ever changes. |
ContentId | string | null | Opportunity ID. Idempotency + correlation key; also echoed in the X-AeoSpotlight-Content-Id header. | |
ContentVersion | string | null | Content revision. A re-publish keeps the same ContentId but carries a new version — use it to ignore stale deliveries. | |
Title | string | ✓ | Article title. |
Slug | string | ✓ | URL slug (already slugified). Use as the upsert key. |
ContentType | string | null | Informational. For AEO Spotlight's own-site destination: knowledge | research | news. Ignored by webhook receivers. | |
BodyHtml | string | ✓ | Article body, HTML, already humanized (a content blob — not a full <html> document). |
SchemaJsonLd | string | JSON-LD schema markup as a JSON-encoded string. JSON.parse it before embedding. | |
MetaDescription | string | null | Meta description (~160 chars). | |
CanonicalUrl | string | null | Canonical URL when one was set. | |
FeaturedImageUrl | string | null | Hero / featured image URL when available. | |
Author | string | null | Byline (user-provided or the destination default). Never auto-derived. Cap at ~200 chars defensively. | |
PublishDate | string (ISO-8601) | null | Publish timestamp, UTC. | |
UpdatedDate | string (ISO-8601) | null | Last-updated timestamp, UTC. | |
TargetEntities | string[] | Entity names the article is written to be discoverable for in AI answers. | |
Headings | string[] | Heading structure (H2…), handy for TOCs and layout. |
4.1 Example payload
{
"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
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);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)
[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.
HTTP/1.1 200 OK
Content-Type: application/json
{ "url": "https://your-site.example.com/acme-helps-law-firms-win-ai-answers" }{ "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
POSTwith 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 stableevent_idyou can dedupe on (retries keep the sameevent_id).
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(+ optionalContentId/ContentVersion) so retries and re-publishes never duplicate. - Treat content as untrusted HTML.
BodyHtmlis generated and humanized but still remote content — sanitize on the server before persisting/rendering, and escapeSchemaJsonLdwhen embedding. - Cap fields defensively (the reference receiver caps
Authorat 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.
SchemaVersionis 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
urlas 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)>.
