CiteFlow can push every article to your own HTTPS endpoint as a signed JSON payload with retries and idempotency keys.
Custom webhook publishing
The webhook adapter delivers every published article (and every "Test connection" call) as a signed HTTP POST to a URL you control. This is the right choice when no native adapter matches your stack: headless CMSs, custom platforms, content lakes, Slack relays, archival systems.
For the full byte-level specification, see Webhook contract. This page focuses on setup and operations.
How does the publishing webhook work?
When you publish to a webhook endpoint, CiteFlow:
- Builds a canonical JSON body with keys sorted alphabetically (so you can recompute the signature byte for byte).
- Signs the body with HMAC-SHA256 using your shared secret, scoped by a Unix timestamp to prevent replay.
- Sends a POST to your
target_urlwith three headers. - Retries up to three times (1 second, 5 seconds, 15 seconds) on non-2xx responses.
How do you set up the webhook?
1. Decide what your receiver will do
Common patterns:
- Translate the JSON payload into a write against your headless CMS API.
- Save the payload to object storage for later ingestion.
- Post a summary to a Slack channel for editorial visibility.
- Append to a content lake or warehouse.
Your receiver must accept HTTPS POST requests, parse JSON, verify an HMAC-SHA256 signature, and respond with 2xx within thirty seconds.
2. Add the endpoint in CiteFlow
- Dashboard → Settings → Publishing destinations → Add endpoint.
- Pick Custom webhook.
- Enter your
target_url(must behttps://). - CiteFlow generates a signing secret and shows it once. Copy it into your receiver's configuration immediately. If you lose it, you will need to regenerate, which invalidates the previous secret.
3. Verify the signature in your receiver
CiteFlow sends three headers on every request:
| Header | Example | Notes |
|---|---|---|
Content-Type | application/json | Always JSON. |
X-CiteFlow-Timestamp | 1748005200 | Unix seconds at signing time. Reject requests older than 5 minutes. |
X-CiteFlow-Signature | sha256=<hex> | HMAC-SHA256 over ${timestamp}.${body} using your shared secret. |
Reference implementation (Node.js):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyCiteFlowSignature(req, secret) {
const ts = req.headers["x-citeflow-timestamp"];
const sig = req.headers["x-citeflow-signature"];
if (!ts || !sig || !sig.startsWith("sha256=")) return false;
// Replay window: 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${ts}.${req.rawBody}`)
.digest("hex");
const a = Buffer.from(sig.slice("sha256=".length), "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
Always use a constant-time comparison (timingSafeEqual in Node, or
hmac.compare_digest in Python) when comparing signatures. A naive
string equality check leaks timing information that can be exploited
to forge signatures.
4. Test the connection
Once your receiver is live, return to the endpoint in CiteFlow and click Test connection. CiteFlow sends a small test payload to your URL:
{
"event": "test",
"timestamp": 1748005200000
}
If your receiver responds 2xx within thirty seconds, the test passes and you can start publishing real articles.
What's the difference between test and production events?
Test events have event: "test" and a numeric timestamp field. They
are still signed exactly like production payloads, so your verification
code does not need a special branch. Production payloads have
event: "article.published" and include the full article and tenant
objects.
Always handle unknown event names gracefully (return 200 and log) so your receiver stays forward-compatible.
How do retries and idempotency work?
- CiteFlow retries up to three times with 1s / 5s / 15s linear backoff on any non-2xx response or network error.
- After three consecutive failures, the job is marked
failedand surfaces in your dashboard for manual retry. - The same article can be re-delivered if you manually retry. Use
article.idfrom the payload as your idempotency key.
What are the tier limitations?
Webhook endpoints are available on every tier and count against the overall publishing endpoint cap:
| Tier | Publishing endpoints |
|---|---|
| Trial | 1 endpoint |
| Standard | 3 endpoints |
| Enhanced | 10 endpoints |
| Marketplace | Unlimited (soft cap 50) |
How do you implement a receiver?
Most receivers fall into one of these shapes. Pick the one closest to your stack and adapt.
1. Simple pass-through
Receive, verify, acknowledge with 200, push onto a queue for async processing. Lowest latency and the safest default: CiteFlow gets a timely 2xx, your real work happens off the request path.
2. Headless CMS push
Receive, transform the payload into your CMS's document schema, write via its API. Works for Sanity, Contentful, Strapi, Storyblok, Payload, and anything else with an authoring API.
Sanity example (Node + @sanity/client):
import { createClient } from "@sanity/client";
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 pushToSanity(payload) {
const { article, tenant } = payload;
await sanity.createOrReplace({
_id: `citeflow-${article.id}`,
_type: "post",
title: article.title,
slug: { _type: "slug", current: article.slug },
excerpt: article.excerpt,
body: article.body_md,
metaDescription: article.meta_description,
heroImage: article.hero_image_url,
heroImageAlt: article.hero_image_alt,
keywords: article.target_keywords,
status: "draft",
tenantId: tenant.id,
});
}
3. Static site commit
Receive, write a markdown file into a git repository, trigger a CI rebuild. Works for Hugo, Jekyll, Astro, Next.js, Eleventy, Gatsby, and any other static site generator.
GitHub Contents API example:
const owner = "your-org";
const repo = "your-site";
const branch = "main";
export async function commitArticle(payload) {
const { article } = payload;
const path = `content/posts/${article.slug}.md`;
const frontmatter = [
"---",
`title: ${JSON.stringify(article.title)}`,
`slug: ${article.slug}`,
`description: ${JSON.stringify(article.meta_description)}`,
`heroImage: ${article.hero_image_url}`,
`keywords: ${JSON.stringify(article.target_keywords)}`,
"draft: true",
"---",
"",
article.body_md,
].join("\n");
const content = Buffer.from(frontmatter, "utf-8").toString("base64");
await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
},
body: JSON.stringify({
message: `Add ${article.slug} from CiteFlow`,
branch,
content,
}),
});
}
Your CI provider (Vercel, Netlify, Cloudflare Pages, GitHub Actions) rebuilds on the commit and the article appears on the next deploy.
4. Workflow automation
Point the webhook at Zapier, Make.com, n8n, or Pipedream. Build a
multi-step workflow visually: parse JSON, branch on event, run any
downstream action. Useful when non-engineers own the integration.
5. Multi-platform fan-out
One webhook receiver pushes the article to several places in parallel: for example WordPress for the canonical post, an ESP for the newsletter, and LinkedIn for the social card. Fan-out at the receiver keeps CiteFlow's delivery model simple (one endpoint, one secret) while letting you broadcast as widely as you like.
6. Review queue
Store the incoming article as pending, surface it in an internal
approvals UI, and only call your production CMS once a human approves.
Adds a second editorial gate on top of CiteFlow's own draft-by-default
behaviour, for regulated industries.
7. Translation pipeline
Receive the article, send the body through a translation service
(DeepL, Google Translate, an LLM), and publish localised versions to
per-locale CMSs. Use article.id plus a locale suffix as the
idempotency key.
How do you troubleshoot webhook delivery?
401 invalid signature returned by your receiver, but you believe it
is correct. Recompute the signature against ${timestamp}.${rawBody}
(no spaces, no decoding). Common bugs: parsing the JSON before signing
(use the raw body), trimming whitespace from the body, using the wrong
encoding (must be the bytes the server received).
Timeouts. CiteFlow waits thirty seconds per attempt. If your receiver runs heavy work, return 200 immediately and process the payload in a background job.
Self-signed TLS certificate on a staging URL. CiteFlow only accepts valid public certificates. Use a real certificate (Let's Encrypt is free) or test against the production URL.
Receiver behind IP allowlist. Contact us and we will share the egress IP ranges so you can whitelist CiteFlow.
References
Related
- Webhook contractHeaders, signed payload, signature verification, retries and idempotency.
- OverviewPick the adapter that matches your stack. All adapters create drafts so your team reviews before publishing.