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 webhook event on forms that select it.
The filter is the last of the form’s spam protection layers. Rate limiting, honeypots and 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? | 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.
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.
{
"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, or with create-form and update-form over 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 fittingMust not offer SEO, marketing or web design servicesMust not ask us to buy or sell cryptocurrencyThe message must be written to us, not copied from somewhere elseJob 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. |
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.
{
"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. |
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
spamandfailed_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. |
| MCP | The review-submission tool with submission_id and spam. See MCP tools. |
Filter by either check on the submissions endpoint or the list-submissions MCP tool:
spam=trueorspam=falsefor submissions that failed or passed the base spam check.failed_rules=trueorfailed_rules=falsefor 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.enabledset tofalse TYPESAFE_API_KEYis not setQUICKFORMS_SPAM_FILTERisfalse- the submission has no text to read, for example only file fields
Live callers never learn the verdict
The submit endpoint 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.