Cituna
AI Visibility

Publish Cituna articles to any stack with a webhook

Point Cituna at an HTTPS endpoint you control and receive each finished article as signed JSON. Payload, signature verification and the rules your endpoint must satisfy.

By Rahul AUpdated August 13, 20264 min read

See which of these you are already failing.

On this page
  1. Add an endpoint that accepts POST
  2. Connect the URL in Cituna
  3. Store the signing secret
  4. Verify every request before you trust it
  5. Map the payload to your model
  6. Send a test delivery whenever you want one
  7. Troubleshooting

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

You can add an HTTP endpoint to something you own and deploy it at a public HTTPS address. Everything else, meaning where the article is stored, when it goes live and what your URLs look like, stays entirely yours.

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 stores that as the article link so the history in the app points at the real page. 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.

Testing from a laptop? Put a tunnel in front of it (ngrok, Cloudflare Tunnel) and connect the public HTTPS address it gives you. http://localhost:3000 will always be refused.

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);
  if (event === 'article.created') {
    await saveArticle(article);                // your code
  }
  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 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": {
    "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",
    "publish": true
  }
}

6. 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, choose Webhook under "Deliver articles to", and turn on Daily article.

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.

See how AI engines see your brand

Start a free 3-day trial and see the exact buyer prompts you lose across ChatGPT, Perplexity, Gemini, Claude, Grok and Google AI Overviews, with a prioritized AEO, GEO and SEO action plan and the fixes to win them.

3-day free trial · Card required, cancel anytime · Works with ChatGPT, Perplexity, Gemini, Claude, Grok and Google AI Overviews

Start free trial