Spam detection

Spam reads like a sentence. The shape gives it away

A message that is mostly links, a shortened address, a chat invite, a domain written with spaces round the dot, the same text arriving for the fortieth time this hour. ToxicFilter reads those shapes in every comment and form, with no model, and says which one it found.

How it works

  1. Every link is read

    Addresses with a scheme, bare domains and the shorteners and chat invites written with none, like bit.ly/abc or discord.gg/abc. Each one counts, and so does where it sits.

  2. The shape is scored

    How much of the message is links, whether it opens with one, whether the destination is hidden or parked on a chat invite, whether the domain is spaced out to dodge a filter. Each is a separate finding with its own score.

  3. Repeats are counted

    The same text arriving again on your account, in the same project, is counted over the last fifteen minutes, and so are copies rewritten with a word or a link changed.

  4. Your line decides

    Each finding lands in spam or evasion, and your policy's line turns the score into allow, review or block. None of this needs the model, so it settles in about a millisecond and costs one credit.

See it decide

  1. 01 Three shortened links
  2. 02 A message that opens with a link
  3. 03 A domain written with spaces
  4. 04 Keyboard mashing
  5. 05 A comment with a useful link

Three shortened links POST /v1/text

Free followers! bit.ly/fol1 tinyurl.com/fol2 bit.ly/fol3

block 2 ms
  • 3 links in about 11 words: mostly links, barely a message.
  • Uses a link shortener, which hides where the link goes.

Three links in eleven words and every one hiding where it goes: 0.75 on spam. The contact form template refuses from 0.65; the shipped lines would hold it for review instead, since they block spam from 0.80.

The answer, abridged
{
  "decision": "block",
  "flagged": [
    "spam"
  ],
  "scores": {
    "spam": 0.75
  },
  "signals": [
    {
      "category": "spam",
      "score": 0.75,
      "reason": "3 links in about 11 words: mostly links, barely a message.",
      "evidence": [
        "bit.ly/fol1",
        "tinyurl.com/fol2",
        "bit.ly/fol3"
      ]
    },
    {
      "category": "spam",
      "score": 0.7,
      "reason": "Uses a link shortener, which hides where the link goes.",
      "evidence": [
        "bit.ly/fol1",
        "tinyurl.com/fol2",
        "bit.ly/fol3"
      ]
    }
  ],
  "model": {
    "read": false
  },
  "took_ms": 2
}

A message that opens with a link POST /v1/text

https://cheap-followers.example.com best prices, order today and grow fast

block 8 ms
  • Opens with a link. People answering a question write first and link after.

People answering a question write first and link after. Somebody leaving a backlink leads with it, and the words after it are filler.

The answer, abridged
{
  "decision": "block",
  "flagged": [
    "spam"
  ],
  "scores": {
    "spam": 0.75
  },
  "signals": [
    {
      "category": "spam",
      "score": 0.75,
      "reason": "Opens with a link. People answering a question write first and link after.",
      "evidence": [
        "https://cheap-followers.example.com best prices, order today and grow fast"
      ]
    }
  ],
  "model": {
    "read": false
  },
  "took_ms": 8
}

A domain written with spaces POST /v1/text

Real followers, real growth. Visit growfast . xyz and ask for the welcome pack.

review 8 ms
  • Domain written with spaces around the dot, which only makes sense to dodge a filter.

Nobody writes a dot with spaces round it except to get past a filter, so it is filed under evasion rather than spam. Held for a person, not refused.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "evasion"
  ],
  "scores": {
    "evasion": 0.65
  },
  "signals": [
    {
      "category": "evasion",
      "score": 0.65,
      "reason": "Domain written with spaces around the dot, which only makes sense to dodge a filter.",
      "evidence": [
        "growfast . xyz"
      ]
    }
  ],
  "model": {
    "read": false
  },
  "took_ms": 8
}

Keyboard mashing POST /v1/text

asdkjfh qwrtzplmnbvcx sdfghjklqwrtz xcvbnmplkjh gfdsaqwrt

review 1 ms
  • Does not read as language: vowels are 4% of the letters, a run of 13 consonants ("qwrtzplmnbvcx"), a run of adjacent keys on the keyboard.

Too few vowels and a run of consonants no language sustains. Gibberish is held and never refused, because one odd sentence is not worth losing a real message over.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "gibberish"
  ],
  "scores": {
    "gibberish": 0.85
  },
  "signals": [
    {
      "category": "gibberish",
      "score": 0.85,
      "reason": "Does not read as language: vowels are 4% of the letters, a run of 13 consonants (\"qwrtzplmnbvcx\"), a run of adjacent keys on the keyboard.",
      "evidence": [
        "asdkjfh qwrtzplmnbvcx sdfghjklqwrtz xcvbnmplkjh gfdsaqwrt"
      ]
    }
  ],
  "model": {
    "read": false
  },
  "took_ms": 1
}

A comment with a useful link POST /v1/text

Good question. The queue docs explain retries well: https://laravel.com/docs/queues

allow 8 ms

One link after a sentence is how people answer questions. Nothing about its shape is spam, and it passes.

The answer, abridged
{
  "decision": "allow",
  "flagged": [],
  "signals": [],
  "model": {
    "read": false
  },
  "took_ms": 8
}

See it decide

Three shortened links POST /v1/text

Free followers! bit.ly/fol1 tinyurl.com/fol2 bit.ly/fol3

block 2 ms
  • 3 links in about 11 words: mostly links, barely a message.
  • Uses a link shortener, which hides where the link goes.

Three links in eleven words and every one hiding where it goes: 0.75 on spam. The contact form template refuses from 0.65; the shipped lines would hold it for review instead, since they block spam from 0.80.

The answer, abridged
{
  "decision": "block",
  "flagged": [
    "spam"
  ],
  "scores": {
    "spam": 0.75
  },
  "signals": [
    {
      "category": "spam",
      "score": 0.75,
      "reason": "3 links in about 11 words: mostly links, barely a message.",
      "evidence": [
        "bit.ly/fol1",
        "tinyurl.com/fol2",
        "bit.ly/fol3"
      ]
    },
    {
      "category": "spam",
      "score": 0.7,
      "reason": "Uses a link shortener, which hides where the link goes.",
      "evidence": [
        "bit.ly/fol1",
        "tinyurl.com/fol2",
        "bit.ly/fol3"
      ]
    }
  ],
  "model": {
    "read": false
  },
  "took_ms": 2
}

A message that opens with a link POST /v1/text

https://cheap-followers.example.com best prices, order today and grow fast

block 8 ms
  • Opens with a link. People answering a question write first and link after.

People answering a question write first and link after. Somebody leaving a backlink leads with it, and the words after it are filler.

The answer, abridged
{
  "decision": "block",
  "flagged": [
    "spam"
  ],
  "scores": {
    "spam": 0.75
  },
  "signals": [
    {
      "category": "spam",
      "score": 0.75,
      "reason": "Opens with a link. People answering a question write first and link after.",
      "evidence": [
        "https://cheap-followers.example.com best prices, order today and grow fast"
      ]
    }
  ],
  "model": {
    "read": false
  },
  "took_ms": 8
}

A domain written with spaces POST /v1/text

Real followers, real growth. Visit growfast . xyz and ask for the welcome pack.

review 8 ms
  • Domain written with spaces around the dot, which only makes sense to dodge a filter.

Nobody writes a dot with spaces round it except to get past a filter, so it is filed under evasion rather than spam. Held for a person, not refused.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "evasion"
  ],
  "scores": {
    "evasion": 0.65
  },
  "signals": [
    {
      "category": "evasion",
      "score": 0.65,
      "reason": "Domain written with spaces around the dot, which only makes sense to dodge a filter.",
      "evidence": [
        "growfast . xyz"
      ]
    }
  ],
  "model": {
    "read": false
  },
  "took_ms": 8
}

Keyboard mashing POST /v1/text

asdkjfh qwrtzplmnbvcx sdfghjklqwrtz xcvbnmplkjh gfdsaqwrt

review 1 ms
  • Does not read as language: vowels are 4% of the letters, a run of 13 consonants ("qwrtzplmnbvcx"), a run of adjacent keys on the keyboard.

Too few vowels and a run of consonants no language sustains. Gibberish is held and never refused, because one odd sentence is not worth losing a real message over.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "gibberish"
  ],
  "scores": {
    "gibberish": 0.85
  },
  "signals": [
    {
      "category": "gibberish",
      "score": 0.85,
      "reason": "Does not read as language: vowels are 4% of the letters, a run of 13 consonants (\"qwrtzplmnbvcx\"), a run of adjacent keys on the keyboard.",
      "evidence": [
        "asdkjfh qwrtzplmnbvcx sdfghjklqwrtz xcvbnmplkjh gfdsaqwrt"
      ]
    }
  ],
  "model": {
    "read": false
  },
  "took_ms": 1
}

A comment with a useful link POST /v1/text

Good question. The queue docs explain retries well: https://laravel.com/docs/queues

allow 8 ms

One link after a sentence is how people answer questions. Nothing about its shape is spam, and it passes.

The answer, abridged
{
  "decision": "allow",
  "flagged": [],
  "signals": [],
  "model": {
    "read": false
  },
  "took_ms": 8
}

Detecting spam, explained

What gives a spam message away, how repeats are counted, and how to set the lines.

What makes a message spam

Most spam reads like a sentence. "Free followers, tap the link" is grammatical, polite and contains no word worth putting on a list. What gives it away is its shape: a message that is mostly links, an address that hides where it goes, a link placed first and filler after it, and the same text arriving dozens of times. ToxicFilter scores each of those as a separate finding, with a reason in words, so you can see which one decided and act on the ones that matter to your site.

Counting links is wrong on its own, because one link is how people answer questions. So each call reads every link, including bare domains and the shorteners and chat invites written with no scheme (bit.ly/abc, t.me/abc, discord.gg/abc), and scores what it finds. Several links in fewer than twenty-five words per link start at 0.65 for two and reach 0.75 for three. A message that opens with a link is 0.75. A shortener is 0.70. A chat invite or a free landing page is 0.55. A domain written with spaces round the dot (growfast . xyz) is filed under evasion at 0.65, because it is a statement about intent whatever the link turns out to be.

How to stop repeated messages and spam campaigns

No classifier reading one message can see a campaign, because the campaign is not in the message: it is the same message arriving forty times. ToxicFilter counts each text against the recent calls of the same account and project over the last fifteen minutes, and only for texts of about forty characters or more, so two hundred people writing "thanks!" stay a forum. A fingerprint of each text catches the copies rewritten with a link swapped or two words changed. These counts come from earlier calls, so a single example on a page cannot show them; they appear in the answer as soon as the second copy arrives.

Floods and gibberish

A message that is one word over and over, a key held down, or a wall of emoji scores on spam. Text that does not read as language at all, with almost no vowels and runs of consonants, scores on gibberish, which holds for review and never refuses. Gibberish is only judged on Latin-script text, because vowel counts mean nothing in Chinese or Arabic.

What a repeat costs

A spam campaign is the same message hundreds of times, and the copies are answered from a cache of verdicts kept per account and project. The count of copies is part of what the cache looks up, in steps, so a copy is judged again only when the count has grown enough to change the answer. A cached call is billed one credit, the price of a check, whatever the first one cost. None of the checks on this page uses the model: they are free detectors that settle a comment in about a millisecond.

Choosing the spam thresholds

The shipped lines review spam from 0.50 and block from 0.80, so on their own a shortened link, a message opening with a link or three short links are held for a person rather than refused. The contact form template moves the lines to 0.35 and 0.65, and those same messages are refused. Start from the one that matches the cost of a false positive on your site, then run a second policy in parallel on your own traffic to see where the two would disagree before you switch.

How the work is split

The instant checks settle the clear cases in about a millisecond, the model reads what depends on context, and your rules and your people have the last word.

  • Read, never fetched

    It reads the shape of a link and never opens it: a hidden destination, a link placed first, a chat invite. A check costs about a millisecond, and nothing your users post is ever visited from here.

  • Campaigns, counted per project

    Copies are counted per account and project over the last fifteen minutes, rewrites included, from about forty characters. Two hundred people writing thanks stay a forum, and your traffic never shapes another customer's answers.

  • Your rules decide

    With the shipped lines most of these shapes are held for a person, since spam is refused from 0.80; the contact form template refuses from 0.65. Pick the line that fits your site and try it in shadow mode first.

Frequently asked questions

How do you detect link spam in comments?

By the shape of the links, not by counting them. One link after a sentence is an answer. Several links carried by almost no text, a message that opens with a link, a shortener that hides the destination and a chat invite or link-in-bio page are each scored on their own, so a policy can act on any of them.

Can it catch shortened links written without http?

Yes. bit.ly/abc, tinyurl.com/abc, t.me/abc and discord.gg/abc are read as links even with no scheme, because their endings are not on any list of domain endings and spam learned long ago to leave the http out. A host only counts as a shortener when it is that host, so an ordinary domain that happens to contain t.co is not reported.

How do you stop the same message being posted again and again?

Every call is counted against the recent calls of the same account and project. Three copies in fifteen minutes score 0.40 on spam, five 0.55, ten 0.72, twenty 0.85. A fingerprint of each text also catches copies rewritten with a link swapped or a word changed, which is what a campaign turns into once identical copies start being caught.

Does sending the same text twice cost twice?

A repeat is usually answered from a cache of verdicts kept per account and project, and billed one credit, the price of a check, whatever the first answer cost. Each call still gets its own id and its own record.

Will it block a normal comment with a link?

No. A single link after a sentence carries no spam finding at all. What scores is a link that hides its destination, a message that leads with one, or a message that is mostly links.

Which thresholds should I use for spam?

The shipped lines hold spam from 0.50 and refuse it from 0.80, which keeps a single shortened link in review. The contact form template holds from 0.35 and refuses from 0.65, so a shortened link or a message opening with a link is refused, which suits a form where nobody needs to send one. Try a change as a second policy in parallel before switching.

Try it on your own traffic

2,000 credits a month on the free plan, no card. Enough to send a week of your own content and see what it says about it.