WordPress, Shopify and GitHub each assume something about how your site is built. The webhook destination assumes nothing: Cituna POSTs the finished article to a URL you control, as JSON, signed so you can prove it came from us, and you decide what happens next. That makes it the right choice for a Next.js or Rails or Django app with its own database, for a headless CMS like Sanity or Contentful, and for anything running through Zapier, Make or n8n. The delivery is outbound only (you supply the URL, we call it) so there is nothing to open up on your side beyond a single route that accepts POST and answers 2xx.
What this assumes
1. Add an endpoint that accepts POST
Create a route in your app that accepts a POST with a JSON body and returns any 2xx status. Return the 2xx only once you have stored the article: a non-2xx tells Cituna the delivery failed, and the same article is sent again on the next run rather than being lost.
If your endpoint can tell us where the article landed, return a JSON body of { "url": "https://…" }. Cituna accepts it only when it is an HTTPS URL on the selected site, then stores it as the reader link. It is optional and never required.
2. Connect the URL in Cituna
Open Integrations → Webhook, paste the full https URL, and connect. Cituna sends a ping straight away; if your endpoint does not answer, nothing is saved, so you never end up with a connection that only looks healthy.
The URL must be HTTPS and publicly resolvable. Loopback and private addresses (localhost, 10.x, 192.168.x, 172.16–31.x, link-local and carrier-grade NAT ranges) are refused, and the check runs again at send time as well as at connect, because DNS can be repointed at a private address after a URL is approved.
3. Store the signing secret
Cituna shows a signing secret once, at connect. Copy it into an environment variable. Every request then carries two headers: x-cituna-timestamp and x-cituna-signature.
The signature is an HMAC-SHA256 of `${timestamp}.${rawBody}` using that secret, hex encoded.
4. Verify every request before you trust it
Without this check, anyone who learns your URL can publish to your site. Sign the RAW body, because parsing and re-serialising the JSON changes the bytes and every signature will fail, and compare with a timing-safe function, never ===.
Reject timestamps older than a few minutes so a captured request cannot be replayed later.
Verifying the signature (Node)
import crypto from 'node:crypto';
export async function POST(req) {
const raw = await req.text(); // RAW bytes, not the parsed body
const ts = req.headers.get('x-cituna-timestamp');
const sig = req.headers.get('x-cituna-signature');
const expected = crypto
.createHmac('sha256', process.env.CITUNA_WEBHOOK_SECRET)
.update(`${ts}.${raw}`)
.digest('hex');
const ok =
sig &&
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return new Response('bad signature', { status: 401 });
// Reject replays.
if (Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) {
return new Response('stale', { status: 401 });
}
const { event, article } = JSON.parse(raw);
// Connect and Send test post { "event": "ping" }, which carries no article.
if (event === 'ping') return Response.json({ ok: true });
if (event === 'article.created') {
// UPSERT on this stable key: Cituna retries the same article after a
// timeout or non-2xx, and a retry must not create a second page.
await saveArticle(article, { uniqueBy: article.idempotencyKey });
}
return Response.json({ url: 'https://example.com/blog/' + article.slug });
}5. Map the payload to your model
You get the article as both markdown and html, so you can store whichever your site renders. The publish flag reflects the draft-or-publish mode you chose in AutoSEO, so you can honour review in your own system. The same idempotency key is sent in the JSON and the Idempotency-Key header on every retry.
The body is deliberately boring and stable: you should be able to map it in a few lines and never have to re-read our docs because we renamed a field.
What we POST
{
"event": "article.created",
"sentAt": "2026-08-13T09:12:44.101Z",
"article": {
"idempotencyKey": "article:example.com:how-to-get-cited-by-chatgpt",
"siteDomain": "example.com",
"title": "How to get cited by ChatGPT",
"slug": "how-to-get-cited-by-chatgpt",
"markdown": "## Why citations matter\n\n...",
"html": "<h2>Why citations matter</h2>...",
"description": "A practical guide to earning citations.",
"tags": ["aeo", "citations"],
"faqs": [{ "q": "Does it work?", "a": "..." }],
"imageAlt": "A search result page with an AI answer above it",
"keyword": "how to get cited by chatgpt",
"datePublished": "2026-08-13T09:12:44.101Z",
"dateModified": "2026-08-13T09:12:44.101Z",
"publish": true
}
}6. Keep edits made on your side
AutoSEO sends an article again when it refreshes the title and description from Search Console, or links it to newer articles. That re-send carries "onlyIfUnchanged": true. If the article was changed on your side since Cituna last delivered it, keep your version and answer 409 with { "code": "site_edited" }. Cituna marks the article as edited on your site and stops re-sending it.
Tell Cituna your endpoint does this by adding "keepsEdits": true to your 2xx replies, the ping included. Until it does, AutoSEO does not re-send delivered articles on its own, because a re-send could replace edits made on your side. Pressing Update on an article in Cituna still sends it, and asks you first when your endpoint answered site_edited.
Keeping your edits
if (event === 'ping') return Response.json({ ok: true, keepsEdits: true });
const current = await findArticle(article.idempotencyKey);
// editedSinceCituna: set it whenever someone edits the article in your own system.
if (article.onlyIfUnchanged && current?.editedSinceCituna) {
return Response.json({ code: 'site_edited' }, { status: 409 });
}
await saveArticle(article, { uniqueBy: article.idempotencyKey, editedSinceCituna: false });
return Response.json({ url: 'https://example.com/blog/' + article.slug, keepsEdits: true });7. Send a test delivery whenever you want one
The Send test button on the Webhook card posts { "event": "ping" } to your endpoint on demand and reports back either that your endpoint accepted it or the exact status it answered. It is the fastest way to tell a deploy apart from a signing bug.
Then open AutoSEO, pick the Webhook tile in step 1 ("Where should the articles go?"), answer step 2, and switch on "Write articles every day" in step 3.
Troubleshooting
Every signature fails even though the secret is right.
Almost always the body was parsed and re-serialised before signing. JSON.stringify does not guarantee the original key order or spacing, so the bytes differ from what we signed. Read the raw text of the request first, verify against that, and parse afterwards.
Can I point the webhook at localhost while I develop?
No. Private and loopback addresses are refused at connect and again at every send, because a URL that resolves inside our own network is how server-side request forgery works. Use a tunnel that gives you a public HTTPS address and everything else behaves identically.
What happens if my endpoint is down when an article is written?
The article is kept. Delivery is marked failed with the status your endpoint returned, and the next run retries the stored draft rather than generating a new one, so a broken endpoint costs you nothing from your monthly article allowance.
How long do I have to respond?
Fifteen seconds. Do the minimum synchronously (verify, store, answer 2xx) and push anything slow, such as image processing or a site rebuild, onto a background job.
Drafted with AI assistance from our own research and Search Console data, and reviewed by Rahul A before publishing. Rules and prices change; check the linked official source before you act.