---
title: "Spam filter"
description: "Classify every submission with TypeSafe's Jev model, add your own smart rules and hold flagged submissions for review."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.getquickforms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Spam filter

Every live submission is classified before anything is delivered. quickforms asks TypeSafe's Jev model, through the official `laravel/ai` package, two separate things: whether the submission is spam (the base spam check), and whether it breaks any of the form's smart rules. A submission that fails either check is stored so you can review it, but no emails or webhooks are created for it, apart from the [`submission.held`](https://docs.getquickforms.com/webhooks/#submissionheld) webhook event on forms that select it.

The filter is the last of the form's [spam protection](https://docs.getquickforms.com/spam-protection/) layers. Rate limiting, [honeypots](https://docs.getquickforms.com/spam-protection/#honeypots) and [Cloudflare Turnstile](https://docs.getquickforms.com/spam-protection/#cloudflare-turnstile) stop a submission before it is stored, so the filter only sees what gets past them.

## Two checks

| Check | Question | Submission flag |
| --- | --- | --- |
| Base spam check | Is this spam rather than a genuine message from a real person? | `spam` |
| Smart rules | Does this break any of the form's own [smart rules](#smart-rules)? | `failed_rules` |

The two are judged and recorded separately, so you can tell a spam message from a genuine one that is off-topic for the form. Either flag holds the submission's deliveries.

## What Jev reads

For each submission, Jev gets:

- the form name
- the form `description`, if it has one
- the submission answers, keyed by field label (file fields are skipped)

It then answers a set of yes/no questions: a built-in spam question, plus one question per [smart rule](#smart-rules).

## The form description

`description` says what the form is for, in up to 1000 characters. It is sent as context with every question, so a clear description helps Jev tell a genuine message from one that has nothing to do with the form.

```json title="form"
{
  "name": "Bike fit enquiry",
  "description": "Bike fit enquiries from riders in the Scottish Borders"
}
```

Set it in the dashboard, with `description` on the [forms endpoints](https://docs.getquickforms.com/api/#forms), or with `create-form` and `update-form` over [MCP](https://docs.getquickforms.com/mcp/).

## Smart rules

Smart rules are plain-English rules a submission should follow, set in `settings.spam.rules`. A form can have up to 10 rules, each up to 300 characters. Jev is asked, for each rule, whether the submission breaks it. A rule that does not apply to a submission counts as followed.

Some examples:

- `Must be about bike fitting`
- `Must not offer SEO, marketing or web design services`
- `Must not ask us to buy or sell cryptocurrency`
- `The message must be written to us, not copied from somewhere else`
- `Job applications should go to our careers page, not this form`

Write rules about what the submission says, in the same way you would brief a person reading your inbox. Sending `settings.spam.rules` replaces the whole list.

## The threshold

Jev returns a probability for each question. The base spam check fails when the spam probability is at or above the form's threshold, and a rule counts as broken when its probability is at or above the same threshold.

| Key | Default | Description |
| --- | --- | --- |
| `settings.spam.enabled` | `true` | Turn the filter on or off for this form. |
| `settings.spam.threshold` | `0.8` | Between `0.5` and `0.99`. Lower flags more submissions, higher flags fewer. |
| `settings.spam.rules` | `[]` | Up to 10 smart rules, each up to 300 characters. |

```bash
curl -X PATCH https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"description": "Bike fit enquiries from riders in the Scottish Borders", "settings": {"spam": {"threshold": 0.7, "rules": ["Must be about bike fitting", "Must not offer SEO, marketing or web design services"]}}}'
```

## The classification

Each submission has a `spam` flag (the base spam check failed), a `failed_rules` flag (it broke one or more smart rules), a `held` flag and a `classification` object. `classification` is `null` when the filter did not run. The values below are illustrative.

```json title="classification"
{
  "spam": false,
  "rules_broken": true,
  "checks": { "spam": "passed", "rules": "failed" },
  "spam_probability": 0.3127,
  "threshold": 0.8,
  "rules": [
    { "rule": "Must be about bike fitting", "probability": 0.9412, "broken": true },
    { "rule": "Must not offer SEO, marketing or web design services", "probability": 0.0815, "broken": false }
  ],
  "reasons": ["Breaks the rule \"Must be about bike fitting\""],
  "model": "jev-latest",
  "error": null,
  "duration_ms": 412
}
```

| Key | Description |
| --- | --- |
| `spam` | Whether the base spam check failed. Smart rules don't affect it. |
| `rules_broken` | Whether the submission broke at least one smart rule. |
| `checks.spam` | `passed`, `failed`, or `error` when classification failed. |
| `checks.rules` | `passed`, `failed`, `none` when the form has no smart rules, or `error` when classification failed. |
| `spam_probability` | The probability that the submission is spam. |
| `threshold` | The threshold the form had when the submission came in. |
| `rules` | One entry per smart rule with its probability and whether it counts as broken. |
| `reasons` | Plain-English reasons the submission was flagged, from either check. Empty when it was not. |
| `model` | The model that answered. |
| `error` | Why classification failed, or `null`. See [when Jev fails](#when-jev-fails). |
| `duration_ms` | How long classification took. |

## Flagged submissions

A submission that fails the base spam check is stored with `spam: true`, and one that breaks a smart rule with `failed_rules: true`. Either flag holds it: no emails or webhooks are created, and `held` is `true` while it has no deliveries. It waits for you to review it.

- **Mark as not spam** to release it. This clears both `spam` and `failed_rules`, and the notification, autoresponder and webhook deliveries are created then. For sandbox submissions they are simulated as usual.
- **Mark as spam** on a submission that was already delivered to label it. Anything already delivered stays delivered.

You can review a submission in three places:

| Where | How |
| --- | --- |
| Dashboard | Filter a form's submissions by spam or by smart rules, then mark a submission as spam or not spam. |
| REST API | `POST /api/v1/submissions/{submission}/review` with `{"spam": false}`. See [the review endpoint](https://docs.getquickforms.com/api/#post-apiv1submissionssubmissionreview). |
| MCP | The `review-submission` tool with `submission_id` and `spam`. See [MCP tools](https://docs.getquickforms.com/mcp/#tools). |

Filter by either check on the [submissions endpoint](https://docs.getquickforms.com/api/#submissions) or the `list-submissions` MCP tool:

- `spam=true` or `spam=false` for submissions that failed or passed the base spam check.
- `failed_rules=true` or `failed_rules=false` for submissions that broke or followed the form's smart rules.

## When Jev fails

If Jev returns an error or does not answer within the timeout (`QUICKFORMS_SPAM_TIMEOUT`, 5 seconds by default), the submission is delivered as normal, both `checks` are `error` and `classification.error` records why. A failure never holds a submission back.

## When the filter is skipped

The filter does not run, and `classification` is `null`, when:

- the form has `settings.spam.enabled` set to `false`
- `TYPESAFE_API_KEY` is not set
- `QUICKFORMS_SPAM_FILTER` is `false`
- the submission has no text to read, for example only file fields

## Live callers never learn the verdict

The [submit endpoint](https://docs.getquickforms.com/submit/) responds the same way to a flagged live submission as to any other, so spammers cannot use it to probe the filter.

## Testing in sandbox mode

Sandbox submissions skip the filter, so they cost nothing and are never flagged. The JSON response still includes the `spam` object, with `flagged` set to `false`. To see how the filter treats a message, send it as a live submission and look at the `classification` on the stored submission.

## Configuration

| Variable | Default | What it does |
| --- | --- | --- |
| `TYPESAFE_API_KEY` | none | TypeSafe API key, used by the `typesafe` provider in `config/ai.php`. The filter is skipped without it. |
| `QUICKFORMS_SPAM_FILTER` | `true` | Set to `false` to turn the filter off for the whole installation. |
| `QUICKFORMS_SPAM_MODEL` | `jev-latest` | The Jev model to classify with. |
| `QUICKFORMS_SPAM_TIMEOUT` | `5` | Seconds to wait for Jev before delivering the submission as normal. |
| `QUICKFORMS_SPAM_TEAM_DAILY_BUDGET` | `2000` | Classifications one team can use in a day. Past it, submissions are delivered unclassified. |

The default threshold (`0.8`), the maximum number of smart rules (10) and the answer limits (2000 characters each, 8000 in total, sent to the model) live in the `spam` section of `config/quickforms.php`.

Source: https://docs.getquickforms.com/spam-filter/index.mdx
