# spamex documentation Complete documentation for the spamex API (https://api.spamex.dev), rendered as one markdown file for coding agents. Index: https://spamex.dev/llms.txt # Get started ## Documentation Source: https://spamex.dev/docs spamex gives every form submission a verdict and a reason over one HTTP call. Start here. spamex classifies form submissions as **spam** or **ham** through a single request to `https://api.spamex.dev`. You send the fields your form collected plus the visitor's IP from your backend; back comes a label, a score, the stage that decided, and stable reason codes you can act on or show to your users. - [Quickstart A key, one request, one verdict. Five minutes in any language.](https://spamex.dev/docs/quickstart) - [Form platforms One platform key, a managed key per customer form, verdicts in your own UI.](https://spamex.dev/docs/platforms) - [API reference Every endpoint, field, response and status code.](https://spamex.dev/docs/api-classify) #### One request, one answer ```json { "id": "3f0c1a9e2b7d4e1f9c3a5b6d7e8f9a0b", "label": "spam", "score": 0.97, "stage": "ml", "reasons": ["NAME_EMAIL_MISMATCH", "SPAM_PHRASE", "ML_SPAM"], "latency_ms": 1.4 } ``` | field | what it tells you | |---|---| | `label` | `spam` or `ham`. Always one of the two. | | `score` | How confident the verdict is, as a probability of spam from 0 to 1. | | `stage` | Which part of the pipeline decided: `rules`, `ml` or `llm`. | | `reasons` | Stable codes such as `HONEYPOT_FILLED` or `DUP_KNOWN_SPAM`. Never empty. | | `id` | Keep it. Feedback and deletion refer to it. | [How verdicts work](https://spamex.dev/docs/how-it-works) explains each stage. [Handling verdicts](https://spamex.dev/docs/handling-verdicts) turns a response into a policy for your inbox. #### Where things are - **Get started** covers the request, the response, and what to do with it. - **Guides** hold copy-paste integrations for Node, PHP and WordPress, and Python, the guide for form platforms, and the data and privacy details your own policy may need. - **API reference** documents each customer endpoint. **Platform API** covers the endpoints that manage keys for your customers' forms. - **Reference** lists every reason code, the limits for each plan, the changelog, and enterprise self-hosting. > **Using a coding agent?.** The whole documentation is available as plain markdown at [/llms.txt](https://spamex.dev/llms.txt) and [/llms-full.txt](https://spamex.dev/llms-full.txt). Point Claude Code, Cursor or any agent at it; see [For coding agents](https://spamex.dev/docs/for-agents). #### Conventions in these docs - Base URL: `https://api.spamex.dev`. All examples use it. - `$SPAMEX_KEY` stands for a site key, `$PLATFORM_KEY` for a platform key. - Requests and responses are JSON. Timestamps are Unix seconds. Months are `YYYY-MM`. --- ## Quickstart Source: https://spamex.dev/docs/quickstart Get a key, send one submission, act on the verdict. Five minutes in any language. #### 1. Get a key [Sign in](https://spamex.dev/signin) with your email. The dashboard creates a key on the Free plan with 1,000 checks a month. Copy it once. Only a hash is stored on our side, so a lost key is rotated, not recovered. > **Keys stay on the server.** Call the API from your backend, never from a browser or a mobile app. A key in client code is a key anyone can use. For a static site, put a small serverless function between the form and the API. #### 2. Send a submission Forward the fields your form collected plus the visitor's IP address. **curl** ```bash curl https://api.spamex.dev/v1/classify \ -H "Authorization: Bearer $SPAMEX_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Rachel Green", "email": "rachel.green@gmail.com", "subject": "Leaking kitchen faucet", "message": "My kitchen faucet has been dripping for a week. Could someone come out this week? What are your rates?", "ip": "198.51.100.7" }' ``` **Node** ```js const res = await fetch("https://api.spamex.dev/v1/classify", { method: "POST", headers: { Authorization: `Bearer ${process.env.SPAMEX_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: fields.name, email: fields.email, subject: fields.subject, message: fields.message, ip: req.ip, }), signal: AbortSignal.timeout(5000), }); const verdict = res.ok ? await res.json() : null; // null: deliver (fail open) ``` **Python** ```python r = httpx.post( "https://api.spamex.dev/v1/classify", headers={"Authorization": f"Bearer {SPAMEX_KEY}"}, json={ "name": fields["name"], "email": fields["email"], "subject": fields["subject"], "message": fields["message"], "ip": request.client.host, }, timeout=5.0, ) verdict = r.json() if r.status_code == 200 else None # None: deliver (fail open) ``` **PHP** ```php $ch = curl_init('https://api.spamex.dev/v1/classify'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SPAMEX_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'name' => $_POST['name'] ?? '', 'email' => $_POST['email'] ?? '', 'subject' => $_POST['subject'] ?? '', 'message' => $_POST['message'] ?? '', 'ip' => $_SERVER['REMOTE_ADDR'] ?? null, ]), ]); $res = curl_exec($ch); $verdict = curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200 ? json_decode($res, true) : null; // null: deliver ``` The response comes back in a few milliseconds for most submissions: ```json { "id": "1dab208f9c8043d8b016acd9343deb71", "label": "ham", "score": 0.0018, "stage": "ml", "reasons": ["ML_HAM"], "latency_ms": 2.97 } ``` #### 3. Act on the verdict The simplest policy that works well: | verdict | do | |---|---| | `spam` with `HONEYPOT_FILLED`, `FAST_SUBMIT`, `DUP_KNOWN_SPAM` or a `*_REPUTATION_BAD` code | Drop silently. Return the usual "thanks" page without sending mail. These are bots. | | `spam` from stage `ml` or `llm` | Route to a review folder instead of the inbox. Show the visitor a fallback email address. | | `ham` | Deliver normally. | Store the `id` next to the message. If a verdict turns out wrong, one call fixes it and teaches your site's memory: ```bash curl -X POST https://api.spamex.dev/v1/feedback \ -H "Authorization: Bearer $SPAMEX_KEY" -H "Content-Type: application/json" \ -d '{"id": "1dab208f9c8043d8b016acd9343deb71", "label": "ham"}' ``` [Handling verdicts](https://spamex.dev/docs/handling-verdicts) goes deeper: what to show users, when to fail open, how to wire a "not spam" button. #### 4. Add the free signals Two signals from your own form catch a large share of bots before any model runs. - **A hidden trap field.** Add an input bots will fill in and humans never see. Name it something a bot wants, such as `website`, hide it with CSS rather than `type="hidden"`, and tell spamex the field name in your key settings. Send its value as `honeypot`. - **Timing.** Put the page-load time in a hidden field and note the submit time on your server. Send them as `form_load_ts` and `submit_ts`. A form completed in under two seconds is a hard spam verdict. ```json { "message": "...", "honeypot": "", "form_load_ts": 1757000000, "submit_ts": 1757000041, "ip": "198.51.100.7" } ``` That is the whole integration. For a complete handler in your language, see [Node.js](https://spamex.dev/docs/integrate-node), [PHP and WordPress](https://spamex.dev/docs/integrate-php) or [Python](https://spamex.dev/docs/integrate-python). Running a form service? Read [For form platforms](https://spamex.dev/docs/platforms). --- ## How verdicts work Source: https://spamex.dev/docs/how-it-works The stages a submission passes through, what the score means, and how your feedback changes future verdicts. Every submission goes through the same pipeline. Each stage can end the request with a verdict; the response tells you which one did. #### The four stages 1MemorySeen this before? Answer instantly. 2RulesHard evidence, deterministic. 3ModelText classifier, about a millisecond. 4Second opinionLanguage model for the gray zone. ##### 1. Memory Two lookups run before anything else. - **Near-duplicate index.** A fingerprint of the message text is compared with messages already classified across every site on spamex. A match with known spam returns `DUP_KNOWN_SPAM`. A match with a message *you* marked legitimate returns `DUP_KNOWN_HAM`, and that one is always ham, whatever the text contains. - **Sender reputation.** The visitor's IP, email address and email domain carry decayed counters of past spam and ham. A sender with a clear recent history of spam is answered with a `*_REPUTATION_BAD` code. A sender with a history of legitimate messages on your site has the score scaled down and the response carries `EMAIL_REPUTATION_GOOD`. Reputation is tenant-scoped by default: what one site learns about a sender only affects that site until at least two independent sites agree. Counters halve every 14 days, so an address is not marked forever. ##### 2. Rules Deterministic checks on the submission itself. Some are decisive on their own and end the request as `spam` at stage `rules`: a filled honeypot, a form submitted under two seconds after it loaded, a URL in the name field, three or more links. Others are soft signals that only nudge the score: a disposable email provider, a risky top-level domain, a name that does not match the address, machine-looking identifiers, learned spam phrases, and text in a script that does not match the site's locale. ##### 3. Model A compact text classifier trained on form submissions scores the message together with the soft signals. Its output is calibrated against two thresholds. Below the lower threshold the verdict is `ham` with `ML_HAM`; above the upper one it is `spam` with `ML_SPAM`. Both zones are held to at least 99 percent precision, which is why the model is allowed to decide alone there. The model reads the text as it is. Submissions in languages other than English are scored the same way; the script-mismatch rule is the only place where locale matters. ##### 4. Second opinion Scores between the two thresholds, roughly one submission in twenty, go to a language model together with your `site_description`. It is asked one question: is this a message the site would want? The answer arrives as `LLM_SPAM` or `LLM_HAM` with the model's confidence as the score. The call has a three-second timeout. If it fails or times out, the response carries `LLM_UNAVAILABLE` and `ML_GRAY_FALLBACK`, and the classifier's nearest label is used. > **Latency budget.** Stages 1 to 3 answer in a few milliseconds. Only stage 4 waits on an external call. If your request is on a path that answers the visitor's browser, use [fast mode](https://spamex.dev/docs/fast-mode) and re-check the gray-zone cases from a queue. #### Reading the response | field | meaning | |---|---| | `label` | The decision. Always `spam` or `ham`; there is no "maybe", because your code needs a branch to take. | | `score` | Probability of spam in `[0, 1]` after rules and model are blended. For memory and reputation verdicts it is pinned near 0 or 1. For a language-model verdict it is the model's stated confidence. | | `stage` | `rules` when memory, reputation or a hard rule decided, `ml` for the classifier, `llm` for the second opinion. | | `reasons` | Every code that contributed, in evaluation order. The first code is usually the one worth showing a user. | Reason codes are stable strings. New ones may be added; none are renamed or reused. The full list, with suggested handling and user-facing wording, is in the [reason code reference](https://spamex.dev/docs/reason-codes). #### What your feedback changes A `POST /v1/feedback` call with the verdict id and the correct label does three things. 1. **Your site's memory.** A message you mark `ham` is remembered for your site and answered `DUP_KNOWN_HAM` when it recurs. A message you mark `spam` is remembered as spam for your site. 2. **Sender reputation.** The sender's IP, email and domain receive a strong vote in the direction you gave. 3. **The shared index.** A message you mark `ham` is removed from the shared spam index, so a false positive is not repeated elsewhere. No single site can blacklist a sender for everyone. Cross-site effects need two independent sites to agree. #### What is stored By default spamex keeps a hash and a fingerprint of the text, hashed sender identifiers, the verdict and its reasons. The message itself is discarded once the verdict is returned. Each key can opt into retaining raw text for review. The details, and the deletion endpoint, are in [Data and privacy](https://spamex.dev/docs/data-and-privacy). --- ## Handling verdicts Source: https://spamex.dev/docs/handling-verdicts Turn a label and a reason into a policy: what to drop, what to review, what to show the visitor, and how to fail safely. A verdict is only useful once your code does something with it. This page is the policy most integrations end up with. #### Three buckets, not two Treat `spam` verdicts differently depending on how they were reached. | bucket | condition | action | |---|---|---| | **Bot** | `spam` with any of `HONEYPOT_FILLED`, `FAST_SUBMIT`, `URL_IN_NAME`, `DUP_KNOWN_SPAM`, `IP_REPUTATION_BAD`, `EMAIL_REPUTATION_BAD`, `EMAIL_DOMAIN_REPUTATION_BAD` | Drop. Show the normal "thanks" page and send nothing. | | **Review** | any other `spam` verdict (`ML_SPAM`, `LLM_SPAM`, `MANY_URLS`, `ML_GRAY_FALLBACK`) | Store it in a review folder with the reasons. Tell the visitor how to reach you if they do not hear back. | | **Deliver** | `ham` | Deliver as normal. | ```js const BOT = new Set(["HONEYPOT_FILLED", "FAST_SUBMIT", "URL_IN_NAME", "DUP_KNOWN_SPAM", "IP_REPUTATION_BAD", "EMAIL_REPUTATION_BAD", "EMAIL_DOMAIN_REPUTATION_BAD"]); function bucket(verdict) { if (!verdict) return "deliver"; // API unreachable: fail open if (verdict.label === "ham") return "deliver"; return verdict.reasons.some((r) => BOT.has(r)) ? "bot" : "review"; } ``` The review bucket is small (about one verdict in twenty is a gray-zone case) and is where false positives live if you ever have any. Giving it a home means nobody loses a customer to a filter they cannot see. > **Silence is a feature.** Do not tell a bot it was caught. An error message teaches the operator what to change. Return the same page a human would see. #### Keep the id Store the verdict `id`, `label` and `reasons` next to the message. The id is what you send to [`/v1/feedback`](https://spamex.dev/docs/api-feedback) when a verdict was wrong, and to [`DELETE /v1/submissions/{id}`](https://spamex.dev/docs/api-submissions) when a person asks for their data to be removed. #### Give people a button The single most effective way to reduce support load is a "Not spam" button that works. Wire it to `/v1/feedback` with `label: "ham"`. The message is delivered next time, the sender gets a good mark, and your memory learns the pattern. A "Mark as spam" button on the inbox side does the reverse. Both can be batched with [`/v1/feedback/batch`](https://spamex.dev/docs/api-feedback#bulk-corrections). #### Show the reason Next to every filtered message, show the reason in plain words. People trust a filter they can see. The [reason code reference](https://spamex.dev/docs/reason-codes) has suggested wording for every code; a few examples: | code | wording | |---|---| | `HONEYPOT_FILLED` | A hidden field was filled in. | | `DUP_KNOWN_SPAM` | The same message was sent to other sites recently. | | `ML_SPAM` | Looks like an unsolicited pitch. | | `LLM_SPAM` | Not the kind of message this site is for. | #### Fail open If spamex is slow, unreachable, or your quota is exhausted, deliver the message and log the failure. Losing a real enquiry costs more than one spam email. In code, that means every non-200 response and every timeout maps to "deliver". ```text 200 use the label 401 / 403 configuration problem: deliver, alert yourself 402 quota exhausted: deliver, alert yourself 413 / 422 your payload: deliver, fix the request 429 back off per Retry-After, deliver 5xx / timeout deliver ``` Fail closed only for forms that trigger an expensive action on submit, such as creating an account or sending an SMS. #### Timeouts Set a five-second client timeout. Rules and model verdicts take a few milliseconds; a gray-zone verdict can take up to three seconds while the language model answers. If your form handler answers the visitor's browser directly and you cannot afford the wait, use [fast mode](https://spamex.dev/docs/fast-mode). #### Do not retry a 200 Classification is not idempotent by design. The same message sent twice comes back `DUP_KNOWN_SPAM` the second time if the first verdict was confident spam, and the second call counts against your quota. Retry only on network errors and `5xx`. --- # Guides ## For form platforms Source: https://spamex.dev/docs/platforms Add spam filtering to your own form service. One platform key, a managed key per customer form, verdicts and reasons in your own UI. If you run a form backend or a form builder, your customers' spam is your support queue. spamex is built to sit inside a service like that. You hold one **platform key**, create a key per customer form with one call, ask for a verdict before you deliver each submission, and show the reason in your own interface. #### 1. Get a platform key Choose the Platform or Scale plan on the [dashboard](https://spamex.dev/dashboard); your key gains platform mode automatically. On another plan, [ask us](https://spamex.dev/contact) to enable it for an evaluation. #### 2. Create a key per customer form When a customer creates a form, create a site key for it. Each site key is fully isolated: its own settings, quota, usage, and its own memory of what that customer marked legitimate. A message one customer marks as spam never changes another customer's verdicts. **curl** ```bash curl -X POST https://api.spamex.dev/v1/partner/keys \ -H "Authorization: Bearer $PLATFORM_KEY" -H "Content-Type: application/json" \ -d '{ "name": "form_8f2a (acme-plumbing.com)", "site_description": "Family-owned plumbing company in Columbus, Ohio.", "honeypot_field": "website", "locale": "en" }' ``` **Node** ```js const r = await fetch("https://api.spamex.dev/v1/partner/keys", { method: "POST", headers: { Authorization: `Bearer ${process.env.SPAMEX_PLATFORM_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: `form_${form.id} (${form.domain})`, site_description: form.description, honeypot_field: "website", locale: form.locale ?? "en", }), }); const site = await r.json(); await forms.update(form.id, { spamexKeyId: site.id, spamexKey: encrypt(site.key) }); // shown once ``` ```json { "id": 42, "name": "form_8f2a (acme-plumbing.com)", "key": "sk_Ljq2ji7x…", "key_prefix": "sk_Ljq2ji7x", "quota": 250000, "rate_limit": 60, "locale": "en", "honeypot_field": "website", "site_description": "Family-owned plumbing company in Columbus, Ohio.", "retain_raw_text": false, "revoked": false, "created_at": "2026-09-05T09:12:44" } ``` Store the returned `key` encrypted against the form. It is shown once. Rotate, update and revoke site keys through the same API when forms change or customers leave; the [Platform API reference](https://spamex.dev/docs/platform-keys) lists every call. > **Site description matters.** The `site_description` is what the second-opinion stage reads to judge whether a borderline message belongs on that form. A sentence or two about what the site does and who writes in is enough. Let customers edit it. #### 3. Classify before you deliver In your delivery pipeline, right before the email or webhook goes out, call [`/v1/classify`](https://spamex.dev/docs/api-classify) with the **site's** key. Send the submission fields, the visitor's IP, and the honeypot and timing fields if your embed collects them. Set a five-second timeout and deliver on failure. If the call sits on a path that answers the visitor's browser, use [fast mode](https://spamex.dev/docs/fast-mode). It always returns in a few milliseconds and tags the few gray-zone cases with `LLM_SKIPPED`; re-run only those ids in full mode from your delivery queue before connectors fan out. Store the verdict `id`, `label` and `reasons` on the submission record. #### 4. Show your customer the verdict The response is yours to display. Two patterns work well. - **A Spam tab.** Deliver ham. Put spam in a Spam tab of the customer's submissions with the reason underneath: "a hidden field was filled in", "the same message was sent to other sites this week", "looks like a cold pitch". Customers trust a filter they can see. - **A Not spam button.** Wire it to [`/v1/feedback`](https://spamex.dev/docs/api-feedback) with the site's key. The message is delivered, the sender gets a good mark for that form, and the customer fixed their own problem without emailing you. Batch both directions with `/v1/feedback/batch`. Suggested wording for every code is in the [reason code reference](https://spamex.dev/docs/reason-codes). #### 5. Bill and monitor [`GET /v1/partner/usage`](https://spamex.dev/docs/platform-usage) returns per-form and total usage for a month, split by stage. Use it for your own billing, dashboards and alerts. Per-form quotas default to your platform quota; set a smaller one per form if you sell tiers. #### What you can promise your customers - No CAPTCHA and nothing visible on the form. - A reason for every filtered message, and a one-click way to overrule it. - Submissions are stored as fingerprints, not text, unless the customer opts in; sender details are hashed. The same sentence can go in your privacy policy. Details: [Data and privacy](https://spamex.dev/docs/data-and-privacy). - If spamex is unreachable, submissions are delivered as normal. #### Volume and terms Platform is $99 a month for 500,000 checks, Scale is $299 for 2,000,000, both with a data-processing agreement. Volumes in between, or above, come with a private deployment in your region on the Dedicated plan; see the slider on the pricing page. See [pricing](https://spamex.dev/pricing). Above that, or if your procurement needs a conversation first, [request a demo](https://spamex.dev/demo). --- ## Integrations Source: https://spamex.dev/docs/integrations A WordPress plugin for Contact Form 7 and WPForms, and form handlers for Netlify and Vercel. Install, paste a key, done. Three ready-made integrations for the most common places a form lives. Each one adds the hidden trap field and the timing signal for you, calls the API with a five-second timeout, and delivers the message if spamex cannot be reached. #### WordPress A plugin for Contact Form 7 and WPForms. 1. [Download the plugin](https://spamex.dev/downloads/spamex-wordpress.zip) and install it from Plugins → Add New → Upload Plugin. 2. Settings → spamex: paste a key from [your dashboard](https://spamex.dev/dashboard/keys). 3. On the key's settings page in the dashboard, set the honeypot field to the name the plugin shows (default `website`). What happens to spam is your choice: reject it with a neutral sentence, drop it silently behind the usual thanks message, or deliver it tagged `[spamex: review]` for a look later. Bots are always dropped silently. Tools → spamex log lists the last 200 verdicts; one click on a wrong one sends the correction. Other form plugins can call `spamex_classify($fields)` from a hook of their own, and the `spamex_cf7_fields` and `spamex_wpforms_fields` filters change how fields are mapped. #### Netlify and Vercel A serverless form handler for a static site. Point the form at the function; it checks the submission, drops bots, and forwards real messages as JSON to the webhook of your choice (Zapier, Make, Slack, your own API) with the verdict attached. Borderline messages arrive marked `review: true`. - [Netlify Functions template](https://spamex.dev/downloads/spamex-netlify.zip): copy `netlify/` and `netlify.toml` into your site. - [Vercel Functions template](https://spamex.dev/downloads/spamex-vercel.zip): copy `api/` into your project. Set `SPAMEX_KEY` and `FORWARD_URL` in the hosting settings, deploy, and give the form the two free signals: ```html document.currentScript.previousElementSibling.value = Math.floor(Date.now() / 1000); Send ``` On Vercel the action is `/api/contact`. The README in each download lists every setting. > **A dedicated deployment.** Both integrations take an endpoint setting. Paste your deployment's address from the dashboard and everything else stays the same. #### Anything else The API is one request per submission; the [quickstart](https://spamex.dev/docs/quickstart) has it in curl, Node, Python and PHP, and the language guides ([Node.js](https://spamex.dev/docs/integrate-node), [PHP](https://spamex.dev/docs/integrate-php), [Python](https://spamex.dev/docs/integrate-python)) show complete handlers. Running a form service? See [For form platforms](https://spamex.dev/docs/platforms). --- ## Node.js Source: https://spamex.dev/docs/integrate-node An Express handler that classifies, then delivers or quarantines. The shape is the same in Next.js, Hono, Fastify or SvelteKit. Works with any Node 18+ runtime (native `fetch`). Swap Express for Next.js route handlers, Hono, Fastify or SvelteKit actions; only the request plumbing changes. #### The handler ```js const app = express(); app.use(express.urlencoded({ extended: false })); app.set("trust proxy", true); // so req.ip is the visitor behind your proxy, not the proxy const BOT = new Set(["HONEYPOT_FILLED", "FAST_SUBMIT", "URL_IN_NAME", "DUP_KNOWN_SPAM", "IP_REPUTATION_BAD", "EMAIL_REPUTATION_BAD", "EMAIL_DOMAIN_REPUTATION_BAD"]); async function classify(fields, req) { try { const r = await fetch("https://api.spamex.dev/v1/classify", { method: "POST", headers: { Authorization: `Bearer ${process.env.SPAMEX_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: fields.name, email: fields.email, subject: fields.subject, message: fields.message, honeypot: fields.website, // your hidden trap field form_load_ts: Number(fields.form_load_ts) || undefined, submit_ts: Math.floor(Date.now() / 1000), ip: req.ip, user_agent: req.get("user-agent"), }), signal: AbortSignal.timeout(5000), }); return r.ok ? await r.json() : null; // null = fail open } catch { return null; // network error or timeout: fail open } } app.post("/contact", async (req, res) => { const v = await classify(req.body, req); if (v?.label === "spam") { if (v.reasons.some((c) => BOT.has(c))) return res.status(200).send("Thanks!"); // drop silently await quarantine(req.body, v); // review folder; keep v.id for feedback return res.status(200).send("Thanks! If you do not hear back, email hello@example.com."); } await deliver(req.body, v); // send the email; store v?.id next to it res.status(200).send("Thanks!"); }); ``` > **Visitor IP, not proxy IP.** Behind a load balancer or Cloudflare, `req.ip` is the proxy unless Express trusts it. Set `trust proxy` to the proxy's address range rather than `true` on a public host, or read `cf-connecting-ip` when Cloudflare is in front. #### Feedback from your inbox If your mail client or admin has a "not spam" action that reaches your backend, wire it to feedback with the stored id: ```js await fetch("https://api.spamex.dev/v1/feedback", { method: "POST", headers: { Authorization: `Bearer ${process.env.SPAMEX_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ id: storedVerdictId, label: "ham" }), }); ``` #### Next.js route handler ```ts // app/api/contact/route.ts export async function POST(req: Request) { const fields = Object.fromEntries(await req.formData()); const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim(); const r = await fetch("https://api.spamex.dev/v1/classify", { method: "POST", headers: { Authorization: `Bearer ${process.env.SPAMEX_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ ...fields, honeypot: fields.website, ip, user_agent: req.headers.get("user-agent") }), signal: AbortSignal.timeout(5000), }).catch(() => null); const verdict = r?.ok ? await r.json() : null; // deliver / quarantine as above return Response.json({ ok: true }); } ``` #### Fail open or closed? Fail **open** (deliver when the API is unreachable) for contact forms: a lost customer message costs more than one spam email. Fail closed only for forms that trigger an expensive action. --- ## PHP and WordPress Source: https://spamex.dev/docs/integrate-php A plain PHP handler, and WordPress filters for Contact Form 7 and WPForms. #### Plain PHP ```php $f['name'] ?? '', 'email' => $f['email'] ?? '', 'subject' => $f['subject'] ?? '', 'message' => $f['message'] ?? '', 'honeypot' => $f['website'] ?? '', // your hidden trap field 'form_load_ts' => isset($f['form_load_ts']) ? (float) $f['form_load_ts'] : null, 'submit_ts' => time(), 'ip' => $_SERVER['REMOTE_ADDR'] ?? null, 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, ]); $ch = curl_init('https://api.spamex.dev/v1/classify'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SPAMEX_KEY'), 'Content-Type: application/json'], ]); $res = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return ($code === 200 && $res) ? json_decode($res, true) : null; // null = fail open } $verdict = spamex_classify($_POST); if ($verdict && $verdict['label'] === 'spam') { // quarantine; keep $verdict['id'] for feedback } else { // deliver } ``` > **Visitor IP behind a proxy.** Behind Cloudflare use `$_SERVER['HTTP_CF_CONNECTING_IP']`. Behind your own proxy, take the first address in `X-Forwarded-For` only if you trust that proxy; otherwise anyone can spoof it. > **There is a plugin.** If you would rather not write the hooks yourself, the [spamex WordPress plugin](https://spamex.dev/docs/integrations) covers Contact Form 7 and WPForms, adds the hidden field and the timing signal, and logs verdicts with one-click corrections. The filters below are what it does under the hood. #### WordPress: Contact Form 7 Add to a small plugin or your theme's `functions.php`, together with the `spamex_classify` function above. Define `SPAMEX_KEY` in `wp-config.php` and read it with `getenv` or `constant`. ```php add_filter('wpcf7_spam', function ($spam, $submission) { if ($spam) { return $spam; } $d = $submission->get_posted_data(); $v = spamex_classify([ 'name' => $d['your-name'] ?? '', 'email' => $d['your-email'] ?? '', 'subject' => $d['your-subject'] ?? '', 'message' => $d['your-message'] ?? '', 'website' => $d['website'] ?? '', ]); return $v && $v['label'] === 'spam'; }, 10, 2); ``` Contact Form 7 keeps spam submissions out of the mail and, with Flamingo installed, in a reviewable list where you can act on the reasons. #### WordPress: WPForms ```php add_filter('wpforms_process_honeypot', '__return_false'); // let spamex judge instead add_action('wpforms_process', function ($fields, $entry, $form_data) { $text = implode("\n", array_map( fn($f) => is_array($f['value'] ?? null) ? '' : (string) ($f['value'] ?? ''), $fields )); $v = spamex_classify(['message' => $text, 'email' => $entry['fields'][1] ?? '']); if ($v && $v['label'] === 'spam') { wpforms()->process->errors[$form_data['id']]['header'] = 'Your message could not be sent. Please email us instead.'; } }, 10, 3); ``` Adjust the field ids to your form. A hidden trap field and load/submit timestamps in the form add two free signals; the [quickstart](https://spamex.dev/docs/quickstart#4-add-the-free-signals) shows how. #### Feedback ```php $ch = curl_init('https://api.spamex.dev/v1/feedback'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode(['id' => $storedVerdictId, 'label' => 'ham']), CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SPAMEX_KEY'), 'Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, ]); curl_exec($ch); ``` --- ## Python Source: https://spamex.dev/docs/integrate-python A shared client with httpx, then FastAPI, Django and Flask handlers. #### The client ```python SPAMEX = httpx.Client( base_url="https://api.spamex.dev", headers={"Authorization": f"Bearer {os.environ['SPAMEX_KEY']}"}, timeout=5.0, ) BOT = {"HONEYPOT_FILLED", "FAST_SUBMIT", "URL_IN_NAME", "DUP_KNOWN_SPAM", "IP_REPUTATION_BAD", "EMAIL_REPUTATION_BAD", "EMAIL_DOMAIN_REPUTATION_BAD"} def classify(fields: dict, ip: str | None, user_agent: str | None) -> dict | None: try: r = SPAMEX.post("/v1/classify", json={ "name": fields.get("name", ""), "email": fields.get("email", ""), "subject": fields.get("subject", ""), "message": fields.get("message", ""), "honeypot": fields.get("website", ""), # your hidden trap field "form_load_ts": _num(fields.get("form_load_ts")), "submit_ts": time.time(), "ip": ip, "user_agent": user_agent, }) return r.json() if r.status_code == 200 else None # None = fail open except httpx.HTTPError: return None def bucket(v: dict | None) -> str: if not v or v["label"] == "ham": return "deliver" return "bot" if BOT & set(v["reasons"]) else "review" def _num(v): try: return float(v) if v not in (None, "") else None except ValueError: return None ``` #### FastAPI ```python from fastapi import FastAPI, Form, Request app = FastAPI() @app.post("/contact") async def contact(request: Request, name: str = Form(""), email: str = Form(""), subject: str = Form(""), message: str = Form(...), website: str = Form(""), form_load_ts: str = Form("")): fields = dict(name=name, email=email, subject=subject, message=message, website=website, form_load_ts=form_load_ts) v = classify(fields, request.client.host, request.headers.get("user-agent")) match bucket(v): case "bot": pass # drop silently case "review": quarantine(fields, v) # keep v["id"] for feedback case _: deliver(fields, v) return {"ok": True} ``` > **Visitor IP behind a proxy.** Run uvicorn with `--proxy-headers --forwarded-allow-ips` set to your proxy so `request.client.host` is the visitor's address. Without it every submission looks like it came from the proxy. #### Django ```python def contact(request): if request.method == "POST": ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "")).split(",")[0].strip() v = classify(request.POST.dict(), ip, request.META.get("HTTP_USER_AGENT")) match bucket(v): case "bot": pass case "review": quarantine(request.POST, v) case _: deliver(request.POST, v) return render(request, "thanks.html") ``` #### Flask ```python @app.post("/contact") def contact(): v = classify(request.form.to_dict(), request.remote_addr, request.user_agent.string) ... ``` #### Feedback ```python SPAMEX.post("/v1/feedback", json={"id": verdict_id, "label": "ham"}) ``` --- ## Fast mode and queues Source: https://spamex.dev/docs/fast-mode Keep the visitor's request under a few milliseconds and still get the second opinion before anything is delivered. Most verdicts take about a millisecond. The exception is the gray zone, roughly one submission in twenty, where a language model is asked for a second opinion and the request can take up to three seconds. Fast mode lets you choose where that wait happens. #### What fast mode does Send `"mode": "fast"` with the request. The pipeline runs memory, rules and the classifier as usual. If the score lands in the gray zone, instead of waiting on the language model the API returns the classifier's nearest label immediately with two extra codes: - `LLM_SKIPPED`: the second opinion was not requested. - `ML_GRAY_FALLBACK`: the label is the classifier's nearest side, not a confident verdict. Everything else is identical. Confident verdicts carry the same codes they would in full mode. ```json { "id": "9b1e…", "label": "spam", "score": 0.41, "stage": "ml", "reasons": ["URLS_PRESENT", "LLM_SKIPPED", "ML_GRAY_FALLBACK"], "latency_ms": 1.2 } ``` #### The two-step pattern 1. **On the ingest path** (the endpoint that answers the visitor's browser), classify with `mode: "fast"`. Accept the submission and return to the visitor at once. 2. **In your delivery worker**, before mail or webhooks go out, look for `LLM_SKIPPED` in the stored reasons. Only for those, classify again in full mode and replace the stored verdict. ```js // ingest const quick = await classify(fields, { mode: "fast" }); await submissions.insert({ ...fields, verdict: quick }); respond(200); // delivery worker const sub = await submissions.next(); let verdict = sub.verdict; if (verdict?.reasons.includes("LLM_SKIPPED")) { verdict = (await classify(sub, { mode: "full" })) ?? verdict; // fail open to the fast verdict await submissions.update(sub.id, { verdict }); } if (verdict?.label === "spam") quarantine(sub, verdict); else deliver(sub); ``` The second call counts as a classification against your quota, so about one submission in twenty costs two checks. The visitor never waits, and nothing is delivered without the full verdict. > **Which id to keep.** Keep the id from the **full** verdict when you re-check. Feedback attaches to the verdict it corrects, and the full one is the one you acted on. #### When to skip the re-check If you simply never want to wait on the language model, use fast mode everywhere and treat `ML_GRAY_FALLBACK` verdicts as the review bucket described in [Handling verdicts](https://spamex.dev/docs/handling-verdicts). Precision in the confident zones is unaffected; you only give up the second opinion on the borderline cases. --- ## Data and privacy Source: https://spamex.dev/docs/data-and-privacy What spamex stores for each submission, what it never stores, how retention is controlled, and how deletion requests work. This page is written so that you can lift it into your own privacy documentation. The legal text is in the [privacy policy](https://spamex.dev/privacy). #### What is stored by default For every classification spamex keeps: | stored | form | |---|---| | Verdict, score, stage, reason codes, timestamp | as returned to you | | The message text | a SHA-256 hash and a similarity fingerprint (SimHash); the text itself is discarded once the verdict is returned | | Sender identifiers: IP address, email address, email domain | SHA-256 hashes, used for reputation counters | | Your key id and the month | for usage and quota | A hash cannot be turned back into the message. A fingerprint allows "is this nearly the same text as one seen before" and nothing else. #### What is never stored - Raw message text, unless the key has opted in (below). - Names, subjects, user agents or any field other than the message, in any form. - Cookies, device fingerprints or anything collected from the visitor's browser. spamex never talks to the browser at all. #### Retaining raw text A key can set `retain_raw_text` to `true`. From then on the full submission text is stored with the verdict so that verdicts can be reviewed in context and corrections can improve your site's memory with the original wording. Retention is off by default, and platform keys set it per customer form. Retained text is deleted when the record is deleted. #### Deletion requests When a person asks for their data to be removed, call [`DELETE /v1/submissions/{id}`](https://spamex.dev/docs/api-submissions) with the verdict id you stored next to the message. It removes retained text, the sender identifiers and your site's fingerprints for that record. A hashed audit row remains so that usage counts and the shared index stay consistent; it contains nothing that identifies the person. Revoking a key does not delete its records. Ask us through the [contact page](https://spamex.dev/contact) for bulk deletion when a customer leaves. #### Reputation and the shared index Sender reputation and the near-duplicate index are shared across sites, which is what makes spamex better at catching campaigns than a filter that only sees one form. Two rules limit the blast radius. - Reputation earned on one site affects only that site until at least two independent sites agree. - Counters halve every 14 days, so a sender is not marked indefinitely. Feedback from you marking a message legitimate removes it from the shared spam index immediately. > **Sub-processors and location.** The operator, sub-processors and contact for data-protection questions are listed in the [privacy policy](https://spamex.dev/privacy). Platform and Scale plans include a data-processing agreement. Organisations that cannot send submissions to a third party can run spamex inside their own infrastructure; see [Enterprise self-hosting](https://spamex.dev/docs/self-hosting). --- # API reference ## Authentication Source: https://spamex.dev/docs/authentication Bearer keys, the two kinds of key, what each key carries, and rotation. Every customer endpoint takes an API key as a bearer token: ```http Authorization: Bearer sk_Ljq2ji7x… ``` Keys are 256-bit random values with an `sk_` prefix. They are shown once, on creation or rotation, and stored only as a SHA-256 hash. A lost key cannot be recovered; rotate it from the dashboard and the old key stops working immediately. #### Two kinds of key | kind | who holds it | used for | |---|---|---| | **Site key** | A developer, or a form platform on behalf of one customer form | `/v1/classify`, `/v1/feedback`, `/v1/usage`, `/v1/submissions` | | **Platform key** | A form service | Everything a site key can do, plus `/v1/partner/*` to create and manage site keys for customer forms | A platform key's site keys are children of the platform key: they inherit its rate limit and, unless set, its quota, and their usage rolls up in `/v1/partner/usage`. A site key can only see its own data. Platform mode comes with the Platform and Scale plans; see [For form platforms](https://spamex.dev/docs/platforms). #### What a key carries | setting | effect | default | |---|---|---| | `locale` | Latin locales flag Cyrillic or CJK text as a soft signal | `en` | | `honeypot_field` | Name of the hidden trap field. If it is not `honeypot`, send the value under that name and the API reads it from there | `honeypot` | | `site_description` | One paragraph about the site; the second-opinion stage uses it to judge what a legitimate message looks like for you | empty | | `retain_raw_text` | Keep full submission text for review | off | | `quota` | Classifications per month, by [plan](https://spamex.dev/pricing) | 1,000 | | `rate_limit` | Requests per minute | 60 | Site keys are configured from the dashboard, or through the [Platform API](https://spamex.dev/docs/platform-keys) when a platform manages them. #### Keep it on the server The key must never reach a browser, a mobile app bundle or a public repository. Calls come from your backend. For a form on a static site, put a small serverless function between the form and the API. > **Unauthenticated requests.** Requests without a valid key are answered `401` with a `WWW-Authenticate: Bearer` header and are rate limited per IP at 20 per minute. Missing and invalid keys return the same response, so the endpoint cannot be used to probe whether a key exists. #### Rotation Rotate a site key from the dashboard, or with [`POST /v1/partner/keys/{id}/rotate`](https://spamex.dev/docs/platform-keys#rotate-a-site-key) for platform-managed keys. Rotation is immediate: the old key returns `401` from the next request. Deploy the new key first, then rotate, if you need zero downtime. --- ## Errors Source: https://spamex.dev/docs/errors The error format, every status code the API returns, and how a form handler should react to each. #### Format Errors are JSON with a single `detail` field. For most errors it is a sentence; for validation errors it is a list of problems. ```json { "detail": "monthly quota of 1000 classifications exceeded" } ``` ```json { "detail": [ { "type": "missing", "loc": ["body", "message"], "msg": "Field required" } ] } ``` Validation errors never echo your input back. #### Status codes | code | meaning | what to do | |---|---|---| | `200` | Success. | Use the response. | | `201` | Created (site keys). | Store the returned key; it is shown once. | | `400` | Malformed JSON. | Fix the request. | | `401` | Missing or invalid key. Header `WWW-Authenticate: Bearer`. | Check the key and the header format. Alert yourself; deliver the message. | | `402` | Monthly quota exhausted. | Deliver the message. Upgrade the plan or wait for the month to roll over. `GET /v1/usage` shows the numbers. | | `403` | Key revoked, or a site key calling a platform endpoint. | Alert yourself; deliver the message. | | `404` | Unknown id, or an id that belongs to another key. | Nothing to retry. | | `413` | Request body over 32 KB. | Trim the message before sending. | | `422` | Validation error; `detail` lists the fields. | Fix the payload. Deliver the message. | | `429` | Over the per-minute rate limit. Header `Retry-After` in seconds. | Back off for `Retry-After`. Deliver the message. | | `503` | Service cannot classify right now (a deploy in progress). | Retry once after a second; then deliver the message. | #### The rule for form handlers Deliver on every failure. A lost enquiry costs more than one spam email. The only exceptions are forms whose submit triggers an expensive action, where failing closed is the safer default. ```text 200 use the label 401 / 403 configuration problem: deliver, alert yourself 402 quota exhausted: deliver, alert yourself 413 / 422 your payload: deliver, fix the request 429 back off per Retry-After, deliver 5xx / timeout deliver ``` > **Retries.** Retry network errors and `5xx` once. Never retry a `200`: classification is not idempotent, and the second call both counts against your quota and may return `DUP_KNOWN_SPAM` for a message the first call already judged. #### Rate limits Limits use a token bucket, so short bursts above the per-minute figure are fine. The defaults are 60 requests per minute per key and 20 per minute per IP for unauthenticated requests. Higher limits are available on request. See [Limits and quotas](https://spamex.dev/docs/limits). --- ## Classify Source: https://spamex.dev/docs/api-classify Get a verdict for one form submission. The endpoint that does the work. `POST /v1/classify` (API key) Send the fields your form collected and the visitor's IP. Receive a label, a score, the stage that decided and the reasons. #### Request `Content-Type: application/json`. The whole body must be 32 KB or less. Unknown fields are accepted and ignored, except the key's configured honeypot field. **Body** - `message` (string, required): The message body. Up to 20,000 characters. The only required field. - `name` (string, default ""): The visitor's name as typed. Up to 200 characters. - `email` (string, default ""): The visitor's email address. Up to 320 characters. Used for identity checks and sender reputation. - `subject` (string, default ""): Subject line, if the form has one. Up to 500 characters. - `honeypot` (string): Value of your hidden trap field. Any non-empty value is a hard spam verdict. If the key's `honeypot_field` is set to another name (say `website`), send the value under that name instead and the API reads it from there. - `form_load_ts` (number): Unix time in seconds when the form was rendered. - `submit_ts` (number): Unix time in seconds when the form was submitted. Under two seconds after `form_load_ts` is a hard spam verdict. - `ip` (string): The visitor's IP address as your server saw it. Never your server's own address. Enables IP reputation. - `user_agent` (string): The visitor's User-Agent header, up to 512 characters. Accepted for forward compatibility; not stored and not used by the model today. - `locale` (string): Overrides the key's locale for this request, for example `de`. Latin locales treat Cyrillic or CJK text as a soft signal. - `mode` ("full" | "fast", default "full"): `full` may wait up to three seconds on the language model for gray-zone cases. `fast` never waits: gray-zone cases return the classifier's nearest label in a few milliseconds with `LLM_SKIPPED` and `ML_GRAY_FALLBACK`. See [Fast mode](https://spamex.dev/docs/fast-mode). **curl** ```bash curl https://api.spamex.dev/v1/classify \ -H "Authorization: Bearer $SPAMEX_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Mike", "email": "mike@seo-rank-boost.com", "message": "I noticed your site is not on page 1 of Google. We can fix that, reply for a free audit.", "ip": "203.0.113.9", "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "honeypot": "", "form_load_ts": 1757000000, "submit_ts": 1757000041 }' ``` **Node** ```js const r = await fetch("https://api.spamex.dev/v1/classify", { method: "POST", headers: { Authorization: `Bearer ${process.env.SPAMEX_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: "Mike", email: "mike@seo-rank-boost.com", message: "I noticed your site is not on page 1 of Google. We can fix that, reply for a free audit.", ip: "203.0.113.9", honeypot: "", form_load_ts: 1757000000, submit_ts: 1757000041, }), signal: AbortSignal.timeout(5000), }); const verdict = await r.json(); ``` **Python** ```python r = httpx.post( "https://api.spamex.dev/v1/classify", headers={"Authorization": f"Bearer {SPAMEX_KEY}"}, json={ "name": "Mike", "email": "mike@seo-rank-boost.com", "message": "I noticed your site is not on page 1 of Google. We can fix that, reply for a free audit.", "ip": "203.0.113.9", "honeypot": "", "form_load_ts": 1757000000, "submit_ts": 1757000041, }, timeout=5.0, ) verdict = r.json() ``` **PHP** ```php $ch = curl_init('https://api.spamex.dev/v1/classify'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SPAMEX_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Mike', 'email' => 'mike@seo-rank-boost.com', 'message' => 'I noticed your site is not on page 1 of Google. We can fix that, reply for a free audit.', 'ip' => '203.0.113.9', 'honeypot' => '', 'form_load_ts' => 1757000000, 'submit_ts' => 1757000041, ]), ]); $verdict = json_decode(curl_exec($ch), true); ``` #### Response ```json { "id": "3f0c1a9e2b7d4e1f9c3a5b6d7e8f9a0b", "label": "spam", "score": 0.97, "stage": "ml", "reasons": ["NAME_EMAIL_MISMATCH", "SPAM_PHRASE", "ML_SPAM"], "latency_ms": 1.4 } ``` **Fields** - `id` (string): Verdict id. Keep it: [feedback](https://spamex.dev/docs/api-feedback) and [deletion](https://spamex.dev/docs/api-submissions) refer to it. - `label` ("spam" | "ham"): The decision. Always one of the two. - `score` (number): Probability of spam in `[0, 1]` after rules and model are blended. For a language-model verdict, the model's stated confidence. - `stage` ("rules" | "ml" | "llm"): Which stage decided: memory, reputation or a hard rule; the classifier; or the language model. - `reasons` (string[]): Stable [reason codes](https://spamex.dev/docs/reason-codes) in evaluation order. Never empty. - `latency_ms` (number): Server-side time spent on this request. #### Status codes | code | when | |---|---| | `200` | Verdict returned. | | `401` | Missing or invalid key. | | `402` | Monthly quota exhausted. | | `403` | Key revoked. | | `413` | Body over 32 KB. | | `422` | Validation error; `detail` lists the fields. | | `429` | Over the per-minute rate limit; honour `Retry-After`. | | `503` | Service cannot classify right now; retry once, then deliver. | The [errors page](https://spamex.dev/docs/errors) has the format and the recommended handling for each. #### Behaviour worth knowing - **Deterministic first.** Memory and reputation lookups run before anything else. A message you marked legitimate through feedback comes back `ham` even if it contains three links. - **The language model never blocks you.** Gray-zone calls have a three-second timeout. On timeout or error the response carries `LLM_UNAVAILABLE` and `ML_GRAY_FALLBACK` and the classifier's label is used. - **Not idempotent.** The same message sent twice returns `DUP_KNOWN_SPAM` the second time if the first verdict was confident spam. Do not retry a `200`. - **Nothing raw is stored by default.** A hash and a fingerprint of the text, hashed sender identifiers, the verdict. Retention is opt-in per key. See [Data and privacy](https://spamex.dev/docs/data-and-privacy). > **Testing.** Fill the honeypot with any value to get a guaranteed `spam` verdict with `HONEYPOT_FILLED`. Send `form_load_ts` and `submit_ts` one second apart for `FAST_SUBMIT`. Both are hard rules and cost one classification each. --- ## Feedback Source: https://spamex.dev/docs/api-feedback Correct a verdict, one at a time or in bulk. Feedback trains your site's memory and the sender's reputation. #### Correct a verdict `POST /v1/feedback` (API key) **Body** - `id` (string, required): The verdict id returned by `/v1/classify`. - `label` ("spam" | "ham", required): The correct label. ```bash curl -X POST https://api.spamex.dev/v1/feedback \ -H "Authorization: Bearer $SPAMEX_KEY" -H "Content-Type: application/json" \ -d '{"id": "3f0c1a9e2b7d4e1f9c3a5b6d7e8f9a0b", "label": "ham"}' ``` ```json { "ok": true, "id": "3f0c1a9e2b7d4e1f9c3a5b6d7e8f9a0b", "label": "ham" } ``` Only ids from your own key are accepted; anything else is `404`. Sending feedback for an id twice replaces the earlier label. ##### What happens next - **`ham`.** The message is removed from the shared spam index and remembered as *your* legitimate message: an identical or near-identical submission to your site returns `DUP_KNOWN_HAM` at once. The sender's IP, email and domain receive a strong ham vote. - **`spam`.** The message is remembered as spam for your site. The sender's reputation receives a strong spam vote. Other sites see that vote only once a second, independent site agrees, so no single customer can blacklist a sender for everyone. #### Bulk corrections `POST /v1/feedback/batch` (API key) For a "mark all as not spam" action. Up to 100 items per call. Unknown ids are reported, not fatal. **Body** - `items` (object[], required): A list of `{ "id": "...", "label": "spam" | "ham" }` objects, 1 to 100 entries. ```bash curl -X POST https://api.spamex.dev/v1/feedback/batch \ -H "Authorization: Bearer $SPAMEX_KEY" -H "Content-Type: application/json" \ -d '{"items": [ {"id": "3f0c1a9e2b7d4e1f9c3a5b6d7e8f9a0b", "label": "ham"}, {"id": "9b1e4c2d7a0f4b3e8d6c5a4f3e2d1c0b", "label": "ham"}, {"id": "0000000000000000000000000000dead", "label": "spam"} ]}' ``` ```json { "ok": true, "applied": 2, "missing": ["0000000000000000000000000000dead"] } ``` > **Where feedback comes from.** The best source is the people reading the inbox. A "Not spam" button next to a filtered message, and a "Spam" button next to a delivered one, wired to these endpoints, is worth more than any tuning. See [Handling verdicts](https://spamex.dev/docs/handling-verdicts). --- ## Usage Source: https://spamex.dev/docs/api-usage Monthly classifications by stage, quota and remaining checks for a key. `GET /v1/usage` (API key) **Query** - `month` (string, default current month): Month as `YYYY-MM`. ```bash curl "https://api.spamex.dev/v1/usage?month=2026-09" -H "Authorization: Bearer $SPAMEX_KEY" ``` ```json { "tenant": "acme-plumbing.com", "month": "2026-09", "by_stage": { "rules": 312, "ml": 4021, "llm": 187 }, "total": 4520, "quota": 25000, "remaining": 20480, "feedback_count": 9 } ``` **Fields** - `by_stage` (object): Classifications decided by memory, reputation and rules (`rules`), by the classifier (`ml`), and by the language model (`llm`). - `total` (integer): All classifications this month. Fast-mode requests count once; a full-mode re-check counts again. - `quota` (integer): Monthly allowance for the key. `0` means unlimited. - `remaining` (integer | null): `quota` minus `total`, never below zero. `null` when the key has no quota. - `feedback_count` (integer): Verdicts for this key that have received feedback, all time. - `account` (object | null): On a dedicated deployment, the volume shared by every key on it: `total`, `quota` and `remaining` for the month. `null` on the shared service, where each key has its own allowance. The stage split tells you what your traffic looks like. `llm` is the only stage that involves an external call, and the share of gray-zone cases is a useful health signal: a sudden rise usually means a new kind of message, legitimate or not, that the memory has not seen yet. For usage across all of a platform's site keys, see [Platform usage](https://spamex.dev/docs/platform-usage). --- ## Submissions Source: https://spamex.dev/docs/api-submissions Delete what spamex holds for one submission, for data-deletion requests. `DELETE /v1/submissions/{id}` (API key) **Path** - `id` (string, required): The verdict id returned by `/v1/classify`. ```bash curl -X DELETE https://api.spamex.dev/v1/submissions/3f0c1a9e2b7d4e1f9c3a5b6d7e8f9a0b \ -H "Authorization: Bearer $SPAMEX_KEY" ``` ```json { "ok": true, "id": "3f0c1a9e2b7d4e1f9c3a5b6d7e8f9a0b" } ``` Removes the retained text (if the key retains raw text), the hashed sender identifiers, and your site's fingerprints for that record. A hashed audit row remains so that usage counts and the shared index stay consistent; it contains nothing that identifies a person. Only ids from your own key are accepted; anything else is `404`. Deleting an id twice returns `404` the second time. > **Bulk deletion.** For a customer who leaves, or a form that is removed, ask us through the [contact page](https://spamex.dev/contact) and we delete every record for that key. What is stored, and why, is described in [Data and privacy](https://spamex.dev/docs/data-and-privacy). --- ## Health Source: https://spamex.dev/docs/api-health An unauthenticated probe for your own uptime checks. `GET /health` (no authentication) ```bash curl -i https://api.spamex.dev/health ``` ```json { "status": "ok" } ``` Returns `200` with `"ok"` when the service can classify, and `503` with `"degraded"` when it cannot (for example during a deploy, before the model has loaded). Point your uptime monitor at it. When it reports `degraded`, your form handler should already be delivering messages unfiltered under the fail-open rule in [Errors](https://spamex.dev/docs/errors). --- ## For coding agents Source: https://spamex.dev/docs/for-agents The whole documentation as plain markdown, so Claude Code, Cursor, Copilot and friends can integrate spamex without scraping. Two files follow the [llms.txt](https://llmstxt.org) convention: | file | contents | |---|---| | [`/llms.txt`](https://spamex.dev/llms.txt) | A short index: what spamex is, the five facts an integration needs, and a link to every page with its summary. | | [`/llms-full.txt`](https://spamex.dev/llms-full.txt) | Every documentation page, in order, as one markdown file. About the size of a long README. | Both always match the pages on this site. #### Point your agent at it **Claude Code** Add the URL to your project's `CLAUDE.md`, or paste it into the prompt: ```markdown Spam filtering uses spamex. Read https://spamex.dev/llms-full.txt before touching the contact form handler. ``` **Cursor** Add it as a docs source (`@Docs`, Add new doc) with the URL `https://spamex.dev/llms-full.txt`, or reference it in `.cursor/rules`. **Any agent** ```text Fetch https://spamex.dev/llms-full.txt and use it as the reference for the spamex API. Integrate spam filtering into our contact form handler: classify on the server, fail open, store the verdict id, and add a "not spam" action that calls the feedback endpoint. ``` > **What an agent gets right with the full file.** The details that usually go wrong in a first integration are all in there: the key stays on the server, the visitor's IP is the one to send (not the proxy's), delivery must not depend on the API being up, and the `id` must be stored for feedback. A model that has read the file will do all four without being told. #### One topic at a time If an agent only needs one topic, the `Source:` line under every section of the full file gives that page's URL. --- # Platform API ## Site keys Source: https://spamex.dev/docs/platform-keys Create, list, update, rotate and revoke the keys a platform issues for its customers' forms. All endpoints on this page take a **platform key**. A site key calling them receives `403`. Site keys belong to the platform key that created them; another platform's site keys are invisible (`404`). Every response describing a site key has this shape. The `key` itself appears only in the create and rotate responses. ```json { "id": 42, "name": "form_8f2a (acme-plumbing.com)", "key_prefix": "sk_Ljq2ji7x", "revoked": false, "quota": 250000, "rate_limit": 60, "locale": "en", "honeypot_field": "website", "site_description": "Family-owned plumbing company in Columbus, Ohio.", "retain_raw_text": false, "created_at": "2026-09-05T09:12:44" } ``` #### Create a site key `POST /v1/partner/keys` (Platform key) **Body** - `name` (string, required): A label you will recognise, 1 to 120 characters. The form id and domain work well. - `quota` (integer, default platform quota): Classifications per month for this form. Defaults to the platform key's quota. `0` means unlimited. - `locale` (string, default "en"): The form's language. Latin locales treat Cyrillic or CJK text as a soft signal. - `honeypot_field` (string, default "honeypot"): Name of the hidden trap field in the customer's form. - `site_description` (string, default ""): One paragraph about the site, up to 2,000 characters. Read by the second-opinion stage for borderline messages. - `retain_raw_text` (boolean, default false): Keep full submission text for review. See [Data and privacy](https://spamex.dev/docs/data-and-privacy). ```bash curl -X POST https://api.spamex.dev/v1/partner/keys \ -H "Authorization: Bearer $PLATFORM_KEY" -H "Content-Type: application/json" \ -d '{"name": "form_8f2a (acme-plumbing.com)", "site_description": "Family-owned plumbing company in Columbus, Ohio.", "honeypot_field": "website"}' ``` Returns `201` with the site object plus `"key": "sk_…"`. Store the key encrypted against the form; it is not shown again. The site key inherits the platform key's rate limit. #### List site keys `GET /v1/partner/keys` (Platform key) ```bash curl https://api.spamex.dev/v1/partner/keys -H "Authorization: Bearer $PLATFORM_KEY" ``` Returns an array of site objects (prefixes only, never keys), including revoked ones, ordered by id. #### Update a site key `PATCH /v1/partner/keys/{id}` (Platform key) Partial update: only the fields present in the body change. Accepts the same fields as create, all optional. ```bash curl -X PATCH https://api.spamex.dev/v1/partner/keys/42 \ -H "Authorization: Bearer $PLATFORM_KEY" -H "Content-Type: application/json" \ -d '{"site_description": "Plumbing and heating company in Columbus, Ohio. Customers write in for quotes and emergencies.", "quota": 50000}' ``` Returns the updated site object. #### Rotate a site key `POST /v1/partner/keys/{id}/rotate` (Platform key) ```bash curl -X POST https://api.spamex.dev/v1/partner/keys/42/rotate -H "Authorization: Bearer $PLATFORM_KEY" ``` Returns the site object plus a new `key`. The old key stops working immediately. Rotating a revoked key un-revokes it. #### Revoke a site key `DELETE /v1/partner/keys/{id}` (Platform key) ```bash curl -X DELETE https://api.spamex.dev/v1/partner/keys/42 -H "Authorization: Bearer $PLATFORM_KEY" ``` ```json { "ok": true, "id": 42 } ``` The key returns `403` from the next request. Its usage history and records are kept; ask us for bulk deletion when a customer's data must go. > **Isolation.** Each site key has its own settings, quota, usage and memory of legitimate messages. A message one customer marks as spam never changes another customer's verdicts. Reputation earned on one form affects other forms only once two independent sites agree. --- ## Platform usage Source: https://spamex.dev/docs/platform-usage Monthly usage for every site key under a platform key, for billing and dashboards. `GET /v1/partner/usage` (Platform key) **Query** - `month` (string, default current month): Month as `YYYY-MM`. ```bash curl "https://api.spamex.dev/v1/partner/usage?month=2026-09" -H "Authorization: Bearer $PLATFORM_KEY" ``` ```json { "platform": "Acme Forms", "month": "2026-09", "sites": [ { "id": 7, "name": "Acme Forms", "key_prefix": "sk_A1b2C3d4", "revoked": false, "by_stage": { "rules": 0, "ml": 12, "llm": 1 }, "total": 13, "quota": 250000 }, { "id": 42, "name": "form_8f2a (acme-plumbing.com)", "key_prefix": "sk_Ljq2ji7x", "revoked": false, "by_stage": { "rules": 312, "ml": 4021, "llm": 187 }, "total": 4520, "quota": 250000 }, { "id": 43, "name": "form_91c0 (northside-dental.com)", "key_prefix": "sk_Qz8yW3vK", "revoked": true, "by_stage": { "rules": 40, "ml": 610, "llm": 22 }, "total": 672, "quota": 250000 } ], "by_stage": { "rules": 352, "ml": 4643, "llm": 210 }, "total": 5205 } ``` The first entry is the platform key's own traffic; the rest are its site keys in id order, revoked ones included so that last month's invoices still add up. `by_stage` and `total` at the top level sum everything. Use `total` per site for your billing, the `llm` share as a health signal, and `quota` to warn customers who are near their allowance. Per-key detail for one form is also available with that form's own key through [`GET /v1/usage`](https://spamex.dev/docs/api-usage). --- # Reference ## Reason codes Source: https://spamex.dev/docs/reason-codes Every code the API can return, what it means, what to do about it, and wording you can show to people. Codes are stable strings. New ones may be added; none are renamed or reused. `reasons` is never empty, and codes appear in evaluation order, so the first one is usually the one to show. #### Hard rules Stage `rules`, always `spam`. These are bots; drop them silently. | code | meaning | wording for people | |---|---|---| | `HONEYPOT_FILLED` | The hidden trap field had a value. Humans cannot see it. | A hidden field was filled in. | | `FAST_SUBMIT` | The form was submitted under two seconds after it loaded. | The form was submitted faster than a person can type. | | `URL_IN_NAME` | The name field contains a URL. | The name field contained a web address. | | `MANY_URLS` | Three or more URLs in the message. Legitimate messages occasionally hit this; a review folder is safer than dropping. | The message contained several links. | #### Memory Stage `rules`. | code | meaning | wording for people | |---|---|---| | `DUP_KNOWN_SPAM` | Near-identical to a message already classified as spam on any site. Always `spam`. | The same message was sent to other sites recently. | | `DUP_KNOWN_HAM` | Identical to a message you marked legitimate. Always `ham`. | You marked this message as legitimate before. | #### Sender reputation Stage `rules` when decisive, otherwise a soft signal alongside a model code. | code | meaning | wording for people | |---|---|---| | `IP_REPUTATION_BAD` | The visitor's IP address has a recent history of spam. | This sender's address has sent spam recently. | | `EMAIL_REPUTATION_BAD` | The email address has. | This email address has sent spam recently. | | `EMAIL_DOMAIN_REPUTATION_BAD` | The address's domain has. Free-mail domains are never scored this way. | This sender's domain has sent spam recently. | | `EMAIL_REPUTATION_GOOD` | The address has a recent history of legitimate messages; the score was scaled down. | This sender has written legitimately before. | Reputation from one site affects only that site until two independent sites agree. Counters halve every 14 days. #### Soft signals Contribute to the score and appear next to a model or language-model code. None of them is spam on its own. | code | meaning | |---|---| | `URLS_PRESENT` | One or two URLs in the message. | | `SCRIPT_MISMATCH` | Cyrillic or CJK text on a site whose locale uses the Latin script. | | `DISPOSABLE_EMAIL` | Address at a known throwaway provider. | | `RISKY_TLD` | A top-level domain with a high spam share, such as `.ru`, `.xyz`, `.top`, `.click`. | | `RANDOM_EMAIL`, `RANDOM_NAME` | Machine-looking identifiers: digit density, consonant runs, case switching. | | `NAME_EMAIL_MISMATCH` | No part of the name appears in the address and the domain is not a free-mail provider. | | `SPAM_PHRASE` | One or more learned high-precision spam phrases matched. | | `KEYWORD_STUFFING` | Very low vocabulary diversity in a long message. | #### Classifier and language model | code | stage | meaning | wording for people | |---|---|---|---| | `ML_SPAM` | `ml` | The classifier decided with at least 99 percent zone precision. | Looks like an unsolicited pitch. | | `ML_HAM` | `ml` | The classifier is confident the message is legitimate. | | | `LLM_SPAM` | `llm` | Gray-zone case; the language model judged it unwanted for this site. | Not the kind of message this site is for. | | `LLM_HAM` | `llm` | Gray-zone case; the language model judged it legitimate. | | | `LLM_UNAVAILABLE` | `ml` | The language-model call failed or timed out. | | | `LLM_SKIPPED` | `ml` | The request used `mode: "fast"`, so the language model was not consulted. Re-classify in full mode if you want its opinion. | | | `ML_GRAY_FALLBACK` | `ml` | Gray-zone case labelled by the classifier's nearest side because the language model was skipped or unavailable. Treat as the review bucket. | Borderline; worth a look. | > **Grouping for your UI.** Three buckets are enough for most inboxes: **Bot** (hard rules, `DUP_KNOWN_SPAM`, `*_REPUTATION_BAD`), **Review** (`ML_SPAM`, `LLM_SPAM`, `MANY_URLS`, `ML_GRAY_FALLBACK`) and **Delivered**. The policy is spelled out in [Handling verdicts](https://spamex.dev/docs/handling-verdicts). --- ## Limits and quotas Source: https://spamex.dev/docs/limits Request limits, monthly allowances by plan, payload sizes and timeouts. #### Request limits | limit | default | error | |---|---|---| | Requests per minute, per key | 60 | `429` with `Retry-After` | | Requests per minute, per IP, without a valid key | 20 | `429` | | Request body | 32 KB | `413` | | `message` | 20,000 characters | `422` | | `name` / `email` / `subject` | 200 / 320 / 500 characters | `422` | | Feedback batch | 100 items | `422` | | Language-model wait, full mode | 3 seconds | never an error; the response carries `LLM_UNAVAILABLE` | Rate limits use a token bucket, so short bursts above the per-minute figure are fine. Higher per-minute limits are available on request through the [contact page](https://spamex.dev/contact). #### Monthly quotas by plan | plan | checks per month | keys | price | |---|---|---|---| | Free | 1,000 | 1 | $0 | | Pro | 25,000 per key | unlimited | $19 per month, or $190 a year | | Platform | 500,000 | managed keys per customer form | $99 per month, or $990 a year | | Scale | 2,000,000 | managed keys per customer form | $299 per month, or $2,990 a year | | Dedicated | 510,000 to 5,000,000, in steps of 10,000 | managed keys per customer form | a private deployment in your region, $2.50 per 10,000; see the slider on the pricing page | A quota exhausted mid-month returns `402` until the first of the next month, or until the plan is upgraded on the [dashboard](https://spamex.dev/dashboard). Fast-mode requests count once; a full-mode re-check of a gray-zone case counts again. `GET /v1/usage` shows the running total. Above five million checks a month, or for custom rate limits and terms, [request a demo](https://spamex.dev/demo). #### Timeouts to set | call | typical | worst case | client timeout | |---|---|---|---| | `/v1/classify`, full mode | about a millisecond | 3 seconds (language model) | 5 seconds | | `/v1/classify`, fast mode | about a millisecond | a few milliseconds | 2 seconds | | Everything else | a few milliseconds | | 5 seconds | --- ## Changelog Source: https://spamex.dev/docs/changelog What changed in the API, newest first. Fields and codes are only ever added; removals get a new major version. #### September 2026 **API 1.0.** The first public version. - `POST /v1/classify` with `mode: "fast"` for latency-sensitive paths; gray-zone cases come back with `LLM_SKIPPED` and `ML_GRAY_FALLBACK`. - `POST /v1/feedback/batch` for bulk corrections, up to 100 per call. - Platform API: `POST`, `GET`, `PATCH`, `DELETE /v1/partner/keys`, `POST /v1/partner/keys/{id}/rotate` and `GET /v1/partner/usage`. `PATCH` is a partial update. - Sender reputation for IP, email and email domain, with `IP_REPUTATION_BAD`, `EMAIL_REPUTATION_BAD`, `EMAIL_DOMAIN_REPUTATION_BAD` and `EMAIL_REPUTATION_GOOD`. - `DELETE /v1/submissions/{id}` for data-deletion requests. - `GET /health` reports only `status` on the public endpoint. - The documentation is published as plain markdown at [spamex.dev/llms.txt](https://spamex.dev/llms.txt) and [/llms-full.txt](https://spamex.dev/llms-full.txt) for coding agents. #### Compatibility promise - Response fields, reason codes and endpoints are added, never renamed or removed, within a major version. - New reason codes may appear at any time. Treat unknown codes as soft signals and never fail on one. - Unknown request fields are ignored, except a key's configured honeypot field. - Breaking changes ship as a new base path (`/v2/`) with at least six months of overlap. --- ## Enterprise self-hosting Source: https://spamex.dev/docs/self-hosting Run spamex inside your own infrastructure, with the same model, updates and support as the hosted service. The hosted API at api.spamex.dev is the right choice for almost everyone: nothing to run, and the cross-site memory that makes the filter better every day. Some organisations cannot send form submissions to a third party at all, or need the service inside a specific region or network. For them there are two options: the Dedicated plan, a private deployment in the region of your choice that we run and update (see [pricing](https://spamex.dev/pricing)), or self-hosting inside your own infrastructure as an enterprise arrangement, described below. #### What you get - The complete service as a container image: rules, the classifier, the second-opinion stage (with your own language-model key or without it), the admin panel, the API and the platform-key features. - Runs on a single small VM (1 vCPU, 1 GB RAM) with SQLite, or against your Postgres. One process, no other infrastructure. - Model updates as new image versions, on the same cadence as the hosted service. - A data-processing agreement, a security review summary, and a support contact. #### What is different from the hosted service | | hosted | self-hosted | |---|---|---| | Rules, classifier, second-opinion stage | yes | yes | | Shared near-duplicate index and sender reputation across many sites | yes | only across your own forms | | Platform API, admin panel, usage roll-up | yes | yes | | Updates | automatic | new image versions, applied by you | | Where submissions are processed | our infrastructure | yours | The cross-site memory is the main thing you give up. On your own forms the rules, the classifier and your feedback still do most of the work. #### Requirements - Docker or any OCI runtime, and a reverse proxy for TLS (Caddy, Traefik, nginx). - Optional: a Gemini API key for the second-opinion stage, and a Postgres database instead of SQLite. - A commercial agreement: self-hosting is offered on annual terms sized by volume. [Request a demo](https://spamex.dev/demo) with your expected volume and hosting environment and we will come back with terms and an evaluation image. ---