Technical Oct 2, 2026 · 6 min read

Moderating Comments in Express with the JavaScript SDK

Build a comment wall in Express that publishes, holds or refuses each comment with its reason, keeps the key on the server, and verifies the review webhook.

Eduardo Lázaro
Eduardo Lázaro
Founder of ToxicFilter
Moderating Comments in Express with the JavaScript SDK

A comment box is the first place spam and abuse arrive on a site, and the last place anybody wants to read every entry by hand. This post builds a small comment wall in Express and moderates it with ToxicFilter: every comment is checked before it is shown, and one of three things happens.

  • Allow: it is published at once. That is nearly all of them.
  • Review: it is held, and a person decides in your ToxicFilter dashboard. A signed webhook tells the app, which publishes it or drops it.
  • Block: it is refused, and the author is told why, in words.

Three outcomes and not two on purpose. Forced to choose between publishing and deleting, a strict threshold deletes real comments and a lenient one publishes the abuse; the middle outcome is where the uncertain cases wait for a person. The whole app is in toxicfilter/examples/node.

This is one of four posts building the same app with a different client: plain PHP, Flask and Laravel with Laratox. The code of all four is in toxicfilter/examples.

Install and configure

npm install express toxicfilter-sdk

Create a key in your dashboard; the free plan is enough. A tf_test_ key costs nothing and runs every free check, which is what settles most comments, but it never asks the model: use a live key to see what the model adds. The key is a credential for the whole account, so it lives in the environment, on the server, and never in a page.

That last point matters more in JavaScript than anywhere else. The SDK runs in a browser too, and a key in a browser bundle is public: anybody who opens the developer tools can spend your allowance. Moderate on the server, as this app does, and send the page the result.

TOXICFILTER_KEY=tf_test_... TOXICFILTER_WEBHOOK_SECRET=whsec_... PORT=8000 npm start

The comments are kept in a JSON file (store.js), so there is no database to set up. Swap it for your own storage.

Moderating a comment

const id = comments.nextId()
let verdict

try {
  verdict = await tf.text(body, {
    surface: 'comment',
    reference: `comment_${id}`, // how the webhook finds this comment later
  })
} catch (error) {
  if (!(error instanceof ToxicFilterError)) throw error

  // Nobody could judge it: hold it rather than publish it unread.
  comments.add(id, name, body, 'held', null)
  return res.redirect(303, '/?held=1')
}

if (verdict.blocked) {
  return res.status(422).send(page({ name, body, error: verdict.reason ?? 'This comment cannot be published.' }))
}

comments.add(id, name, body, verdict.needsReview ? 'held' : 'published', verdict.id)
res.redirect(303, verdict.needsReview ? '/?held=1' : '/')
  • surface says where the text appears, so a policy can treat a comment and a profile differently.
  • reference is the app's own id for the comment, and it comes back with every webhook about it.
  • The verdict answers with blocked, needsReview and allowed, never one "toxic" boolean.
  • If the API cannot answer, the comment is held. Only ToxicFilterError is caught: a bug in the app itself should still fail loudly, not be filed as "held".

Telling the author why

verdict.reason is the first reason, in words written for the author; verdict.reasons has them all. The page is built with a template string, so everything that comes from the author or from the API goes through an escape() first: a comment is the oldest way into a page.

When a person decides: the webhook

app.post('/webhooks/toxicfilter', express.raw({ type: '*/*' }), async (req, res) => {
  const event = await webhookEvent(
    req.body,
    req.get('X-ToxicFilter-Signature') ?? '',
    process.env.TOXICFILTER_WEBHOOK_SECRET ?? '',
  )

  if (!event) return res.sendStatus(400)

  const match = /^comment_(\d+)$/.exec(event.data?.reference ?? '')

  if (event.event === 'moderation.resolved' && match) {
    event.data.action === 'approved' ? comments.publish(Number(match[1])) : comments.remove(Number(match[1]))
  }

  res.sendStatus(204)
})

express.raw() on that one route keeps the body as the exact bytes that were signed; a JSON parser in front of it would hand over an object, and an object encoded again is a different string. webhookEvent() is async because it uses WebCrypto, the one HMAC that exists in every runtime the SDK supports, and it returns null for anything that does not verify.

To try it on your machine, expose the app with a tunnel (cloudflared tunnel --url http://localhost:8000, for instance), add the tunnel's address followed by /webhooks/toxicfilter as an endpoint in Webhooks, and put the signing secret it shows you in TOXICFILTER_WEBHOOK_SECRET. Then post a comment that lands in review, approve it from the review queue, and watch it appear.

Two details make the handler correct rather than merely working. The signature is checked over the raw body, because a body parsed and encoded again is a different string and would never verify. And the comment is found by its reference, the id the app sent with the check, because the webhook carries the verdict and never the comment: ToxicFilter does not keep what it moderates.

What the client does for you

It retries 429 and 5xx with a growing wait and never retries QuotaExhausted; every call carries an idempotency key; the timeout covers the body as well as the headers, so a stalled response does not hang the request; and only a 2xx with a decision in it is a verdict. It has no dependencies (fetch does the HTTP) and runs on Node 20+, Deno, Bun and Cloudflare Workers. Everything is in the JavaScript SDK docs.

Where to go from here

  • Your own rules: a policy moves the thresholds per category, adds your own banned words, or measures subjects like gambling or crypto that are not harmful but may not belong on your site. Name it in the call.
  • Several sites in one account: give each one a project, with its own activity, review queue and webhooks.
  • More than text: the same client checks images, usernames, whole signups and conversations, where a pile-on or an approach that no single message shows becomes visible.

Put it in front of your real traffic

Allow, review or block, and the reason in words. On the free plan: 2,000 credits a month, no card. A check costs 1 credit, about 8 if the model reads it, about 10 for an image.