DocsAPI referenceWebhook contract

The CiteFlow publishing webhook delivers an HMAC-signed JSON payload with retries and an idempotency key on every event.

Webhook contract

This page is the authoritative specification for the CiteFlow custom webhook. If you are setting up a webhook receiver, this is the document your engineers should read. For a guided walkthrough of credentials and configuration, see Custom webhook publishing.

What transport does the webhook use?

CiteFlow delivers every published article (and every "Test connection" call) as an HTTP POST to your configured target_url. The URL must be HTTPS with a valid public certificate.

Which headers are sent?

Every request carries exactly three CiteFlow-specific headers, plus the content type:

HeaderExampleNotes
Content-Typeapplication/jsonAlways JSON.
X-CiteFlow-Timestamp1748005200Unix seconds at signing time. Reject requests older than 5 minutes for replay protection.
X-CiteFlow-Signaturesha256=<hex>HMAC-SHA256 over ${timestamp}.${body} using your shared secret. The prefix sha256= is literal and required.

What does the request body look like?

The body is a canonical JSON object: keys are sorted alphabetically recursively so a verifier can rebuild the exact byte string used to compute the signature.

Published article event

{
  "article": {
    "body_html": "<h2>…</h2>…",
    "body_md": "## …",
    "canonical_url": null,
    "excerpt": "…",
    "faq_items": [],
    "hero_image_alt": "…",
    "hero_image_url": "https://…",
    "id": "29504f7c-…",
    "meta_description": "…",
    "schema_json": {},
    "slug": "post-slug",
    "target_keywords": ["…"],
    "title": "…",
    "word_count": 1937
  },
  "event": "article.published",
  "tenant": {
    "domain": "example.com",
    "id": "b1f9…-uuid"
  }
}

The tenant object lets multi-tenant receivers route the article to the correct downstream workspace. tenant.id is the CiteFlow tenant UUID and tenant.domain is the configured target site's domain.

Test event

Sent by the Test connection button on the publishing settings page:

{
  "event": "test",
  "timestamp": 1748005200000
}

Test events use the same headers and signature as production events, so your verification code does not need a special branch.

Which event names are emitted?

Event names are namespaced as article.<verb-past-tense>. Today CiteFlow emits:

EventWhen
article.publishedAn article has been published to this endpoint.
test"Test connection" button from the publishing settings UI.

Reserved for later releases, your receiver should ignore unknown event names gracefully so it stays forward-compatible:

  • article.updated, a re-publish replaces the previously published copy.
  • article.unpublished, the article has been retracted.

How do you verify the signature?

The signature is HMAC-SHA256 over the literal byte string ${timestamp}.${body} (a single ASCII dot between the two parts) using your shared secret as the key. Compare against X-CiteFlow-Signature with the sha256= prefix stripped, using a constant-time comparison.

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);
}

Reference implementation (Python):

import hmac, hashlib, time

def verify_citeflow_signature(ts: str, sig: str, raw_body: bytes, secret: str) -> bool:
    if not ts or not sig or not sig.startswith("sha256="):
        return False
    # Replay window: 5 minutes.
    if abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{ts}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(sig[len("sha256="):], expected)

Reference implementation (PHP):

<?php
$signature = $_SERVER['HTTP_X_CITEFLOW_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_CITEFLOW_TIMESTAMP'] ?? '';
$body = file_get_contents('php://input');

if (abs(time() - intval($timestamp)) > 300) {
    http_response_code(401);
    exit('Timestamp too old');
}

$expected = 'sha256=' . hash_hmac('sha256', "$timestamp.$body", $_ENV['CITEFLOW_WEBHOOK_SECRET']);
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$payload = json_decode($body, true);
// Process $payload['article'], $payload['tenant'], $payload['event']

Reference implementation (Ruby / Sinatra):

require 'openssl'

post '/webhook/citeflow' do
  signature = request.env['HTTP_X_CITEFLOW_SIGNATURE'] || ''
  timestamp = request.env['HTTP_X_CITEFLOW_TIMESTAMP'] || ''
  body = request.body.read

  if (Time.now.to_i - timestamp.to_i).abs > 300
    halt 401, 'Timestamp too old'
  end

  expected = 'sha256=' + OpenSSL::HMAC.hexdigest(
    'sha256',
    ENV['CITEFLOW_WEBHOOK_SECRET'],
    "#{timestamp}.#{body}"
  )

  unless Rack::Utils.secure_compare(expected, signature)
    halt 401, 'Invalid signature'
  end

  payload = JSON.parse(body)
  # Process payload

  '200 OK'
end

Always sign and verify against the raw request body, not a re-serialised JSON object. JSON serialisers re-order keys and normalise whitespace in ways that will break signature comparison.

How is replay protection handled?

CiteFlow's timestamp is included in the signed payload. Reject any request whose X-CiteFlow-Timestamp is more than 300 seconds (five minutes) away from your server's current time. This prevents an attacker who captures a signed request from replaying it indefinitely.

If you operate behind a load balancer with significant clock drift, synchronise your servers with NTP before relaxing this window.

How do you rotate the secret?

Your signing secret is shown exactly once when you create or regenerate the endpoint in CiteFlow. CiteFlow never displays it again. If you lose the secret, regenerate from the publishing settings page; this invalidates the previous secret immediately, so coordinate with your receiver before rotating.

What are the delivery semantics?

  • Each article-and-endpoint pair runs as its own job.
  • Up to 3 attempts with linear backoff (1 second, 5 seconds, 15 seconds).
  • Any 2xx response is treated as success.
  • 3 consecutive failures mark the job failed and surface in the CiteFlow dashboard for manual retry.
  • Per-attempt timeout is 30 seconds. If your receiver runs heavy work on receipt, return 200 immediately and process asynchronously.

How is idempotency guaranteed?

CiteFlow does not deduplicate retries on its own. If you process a payload, store article.id and reject (return 200 with a no-op) any later delivery whose article.id you have already seen. Manual retries from the dashboard also reuse the same article.id.

How is forward compatibility maintained?

Treat any unknown top-level keys, unknown event names, and unknown fields inside article as additive. Do not fail on them. CiteFlow may add new fields without bumping a version, and aggressive validation will break your receiver during minor releases.

References

Related

  • Custom webhookReceive every published article as a signed HTTP POST. HMAC-SHA256, 5 minute replay window, 3 retries.