use case · ghost cms

Ghost CMS spam prevention.

Updated May 12, 2026

Ghost is built for serious bloggers, but it ships without spam filtering on member signups — and once spam members exist, they pollute your subscriber count, your email deliverability, and any member-only comments you've enabled. Pipe Ghost's webhook for new members through Siftfy and you have a calibrated probability before the member ever sees your content.

What signals to classify

Spam member signups don't fail in obvious ways — they look like ordinary users at first glance. The signal is in the combination of name, bio note, and any custom fields you collect. A real subscriber rarely writes a 200-word "note" describing their CBD-vape side hustle; a bot does. Concatenate the natural-language fields, ignore the email address itself (Siftfy classifies content, not addresses), and send the joined text to /v1/predict.

Webhook handler

Ghost fires a webhook for every Member created event. The handler below — deployable as a Vercel function, Netlify function, or anywhere you can run Node — classifies the new member and either deletes them via the Ghost Admin API (hard block) or tags for human review (queue). The Admin API call uses a 5-minute-TTL HS256 JWT minted from your Admin API key.

typescript
// Vercel / Netlify / Express handler — receives Ghost's
// member.added webhook, classifies the bio + name, suspends if spammy.
//
// Configure in Ghost Admin: Settings → Integrations → Add custom
// integration → "Add webhook" → Event: Member created.

import crypto from "node:crypto";

const SPAM_THRESHOLD = 0.85;
const QUEUE_THRESHOLD = 0.5;

const GHOST_ADMIN_KEY = process.env.GHOST_ADMIN_KEY!;     // {id}:{secret}
const GHOST_API_URL   = process.env.GHOST_API_URL!;       // https://your.ghost.io
const SIFTFY_KEY      = process.env.SIFTFY_KEY!;

export default async function handler(req, res) {
  if (req.method !== "POST") return res.status(405).end();

  // Ghost wraps the new member in member.current.
  const { member: { current: m } } = req.body;

  // Combine the fields a spammer fills in. Email is rarely useful as
  // signal — natural-language fields are.
  const text = [m.name, m.note, m.email_disabled_reason].filter(Boolean).join(" • ");
  if (!text) return res.status(200).json({ ok: true });

  let probability = 0;
  try {
    const resp = await fetch("https://api.siftfy.io/v1/predict", {
      method: "POST",
      headers: { "Content-Type": "application/json", "X-API-Key": SIFTFY_KEY },
      body: JSON.stringify({ text }),
      signal: AbortSignal.timeout(2000),
    });
    if (resp.ok) ({ spam_probability: probability } = await resp.json());
  } catch {
    return res.status(200).json({ ok: true, fell_open: true });
  }

  if (probability >= SPAM_THRESHOLD) {
    // Mint a short-lived JWT for the Ghost Admin API and remove the member.
    const token = mintGhostJWT(GHOST_ADMIN_KEY);
    await fetch(`${GHOST_API_URL}/ghost/api/admin/members/${m.id}/`, {
      method: "DELETE",
      headers: { Authorization: `Ghost ${token}` },
    });
  } else if (probability >= QUEUE_THRESHOLD) {
    // Tag for review; left as an exercise — Ghost has labels.
  }

  return res.status(200).json({ ok: true, probability });
}

function mintGhostJWT(key: string): string {
  const [id, secret] = key.split(":");
  const header = { alg: "HS256", typ: "JWT", kid: id };
  const now = Math.floor(Date.now() / 1000);
  const payload = { iat: now, exp: now + 5 * 60, aud: "/admin/" };
  const enc = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url");
  const data = `${enc(header)}.${enc(payload)}`;
  const sig = crypto.createHmac("sha256", Buffer.from(secret, "hex"))
    .update(data).digest("base64url");
  return `${data}.${sig}`;
}

Wiring it up in Ghost

  1. Ghost Admin → Settings → Integrations → Add custom integration.
  2. Copy the Admin API key into the deployed function's environment as GHOST_ADMIN_KEY; copy the API URL into GHOST_API_URL.
  3. In the same integration, add a webhook for the Member created event pointing at your function's public URL.
  4. Add SIFTFY_KEY to the same env. Trigger a test signup to verify.

Beyond signups

The same pattern applies to anywhere Ghost emits a webhook with user-supplied text. If you've enabled native commenting (Ghost 5+), run the comment body through Siftfy before publishing. If you use a third-party comments tool like Hyvor Talk or Cusdis, those tools typically have their own webhooks; the Siftfy hop slots in cleanly.

Don't bother classifying server-generated content (your own posts, your own newsletter blasts) — Siftfy is calibrated against user-submitted text and will sometimes flag promotional copy you wrote yourself as borderline. Use it where the input is untrusted.

Try it free

10,000 webhook events / month free. Read the /v1/predict reference, or peek at related use cases: comments, signups, headless CMS.