Skip to content

Spam filter

Classify every submission with TypeSafe's Jev model, add your own smart rules and hold flagged submissions for review.

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.

formjson
{
  "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 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.
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.

classificationjson
{
  "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 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.
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=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 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.

Type to search…

↑↓ navigate↵ selectEsc close