SFW API
Menu

Guides

Add an NSFW filter to a Discord bot (discord.js)

Last updated 2026-11-09

Image spam is the moderation problem that keeps server owners up at night, and the built-in Discord filter only covers explicit media in channels you mark as age-restricted. This guide puts a configurable filter inside your own discord.js bot: every image or GIF attachment is sent to the SFW API by URL, you get a nudity level from 0 (Everyday) to 3 (Explicit) and a blocked verdict against a policy you choose, and blocked messages are removed with a short notice. The policy engine is the same one SfwBot runs for its servers, so a policy that works there works here.

If you would rather not run the code, SfwBot is the hosted version. If you want it in your bot, read on.

Setup

npm install discord.js @sfwapi/sdk

You need two secrets in the bot's environment: DISCORD_TOKEN and SFWAPI_API_KEY from the console. The bot needs the Manage Messages permission in the channels it watches and the Message Content privileged intent, because Discord only delivers attachments to bots with that intent.

The listener

src/bot.ts
import { Client, Events, GatewayIntentBits } from 'discord.js';
import { SfwApi, SfwApiError } from '@sfwapi/sdk';

const api = new SfwApi({ apiKey: process.env.SFWAPI_API_KEY! });
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent] });

// Bare skin is fine, nudity is not; adult objects blocked; drawings judged a step more leniently.
const POLICY = { block_level: 2, block_sex_objects: true, art: 'block_from_3' } as const;

const checkable = (type: string | null) => type?.startsWith('image/') === true;

client.on(Events.MessageCreate, async (message) => {
  if (message.author.bot || !message.inGuild()) return;
  const media = [...message.attachments.values()].filter((a) => checkable(a.contentType));
  if (media.length === 0) return;

  for (const a of media) {
    try {
      const r = await api.analyzeUrl(a.url, { policy: POLICY, maxFrames: 5 });
      if (!r.policy?.blocked) continue;

      await message.delete();
      await message.channel.send({
        content: `${message.author}, that image was removed (level ${r.nudity.level}, ${r.policy.reason}).`,
        allowedMentions: { users: [message.author.id] },
      });
      console.log('removed', { guild: message.guildId, channel: message.channelId, level: r.nudity.level, requestId: r.id });
      return;
    } catch (e) {
      if (e instanceof SfwApiError) {
        console.error('sfwapi', e.status, e.code, e.requestId);
        return; // fail open: the message stays when the check itself fails
      }
      throw e;
    }
  }
});

client.login(process.env.DISCORD_TOKEN);

Three details are worth a second look.

URL mode. The bot never downloads the attachment. It hands the CDN URL to the API, which fetches the file, analyses it in memory and discards it. Attachment URLs are public for a while (they carry an expiry), so send them right away and do not store them. The bot keeps nothing but the level and the request id.

Fail open or closed. When the API is unreachable or your credits are gone, the example lets the message through and logs the error. A stricter server would delete on error instead. Pick deliberately; the code is one line either way.

Delete first, explain second. The message is removed before the notice is posted, so the image is gone within the API's response time, usually around a second. If you want a moderator log, post the level, the author and the channel to a staff channel; do not re-post the image.

Choosing the policy

block_level decides where your server draws the line:

ServerPolicy
General audience, art welcome{ block_level: 1, block_sex_objects: true, art: 'block_from_2' }
Bare skin fine, nudity not (most servers){ block_level: 2, block_sex_objects: true }
Adults, no explicit content{ block_level: 3, block_sex_objects: false }

art matters on Discord because so much of what gets posted is drawn. same_as_photos judges anime and illustrations like photos; block_from_3 blocks only explicit drawings while still blocking implied nudity in photos. Every option is on the policies page. To make the level configurable per server, store it in your bot's settings and build the policy object per guild.

Reading the response

GIFs, stickers and videos

GIFs go through the same call. Frames are sampled evenly over the clip, first and last included, and the level is the maximum over the frames. The default is 5 frames; maxFrames: 5 in the example makes that explicit and caps the cost at five credits per GIF. Set it higher for long GIFs where a brief frame matters. Stickers are images too: message.stickers gives you their URLs, and the same analyzeUrl call works.

Videos (video/mp4, video/webm and so on) are analysed frame by frame as well, but video needs a paid account; free accounts cover images and GIFs up to 8 MB. If your server shares a lot of clips, a 10,000 credit pack is $10 and never expires. See GIF and video.

Rate limits and busy servers

The free tier allows 1 request per second with a burst of 3 and 2 requests in flight; paid accounts get 3 per second with a burst of 15 and 8 in flight. A busy server can exceed that during a raid. The SDK retries 429 and 503 twice honouring Retry-After for URL requests, which absorbs short bursts. For sustained load, put attachments in a small queue and process them sequentially instead of in parallel; the message is deleted a second later, which is still faster than a human moderator. The rate limits page has the exact numbers.

Errors you will see

Cost

One credit per image, five per GIF at the default sampling. A server posting 300 images and 40 GIFs a day uses about 15,000 credits a month, which is $15 on the smallest pack, or $12 on the 50,000 pack. Quiet servers stay within the 500 free credits. The pricing page has the packs.

Frequently asked questions

Do I need the Message Content intent?
Yes. Since 2022 attachments on guild messages are only delivered to bots with the privileged Message Content intent. Turn it on in the developer portal; bots in more than 100 servers need Discord's approval for it.
Can I use this without writing a bot?
Yes. SfwBot is the hosted bot on the same model and policy engine, with per-server settings. This guide is for people who already run their own bot and want the filter inside it.
Does the bot send images anywhere else?
The attachment URL goes to the SFW API, which downloads the file, analyses it in memory and discards it. Nothing is stored. The bot itself never downloads the file.
What about videos and stickers?
Stickers and emoji are static images and work the same way through their URL. Videos are analysed frame by frame but need a paid account; free accounts cover images and GIFs.

Related

Next steps

Read the quickstart to get your first result in two minutes, see pricing for the free tier and prepaid packs, or get a free API key and start with your own images.