API reference · v1
ShieldMX API
Check a signup email before the account exists. Each check returns ALLOW, REVIEW or REJECT, with a reason and a risk score, in one request.
Overview
The API has two endpoints: one to check a single address, and one to check up to 100 at once. Both accept JSON over HTTPS and return JSON. Everything lives under /v1.
- Base URL:
https://shieldmx-core-api.slotboard-v.workers.dev - Format: UTF-8 JSON in and out. Send
Content-Type: application/json. - Auth: a bearer key from your dashboard.
- Addresses are not stored: the verify call does not save the address. Usage counts are kept per key.
There are no SDKs to install. Any HTTP client works. Examples below use curl, Node.js and Python.
Quickstart
Create a key in the dashboard under API keys, then run one request.
# Check one address
curl -X POST https://shieldmx-core-api.slotboard-v.workers.dev/v1/verify \
-H "Authorization: Bearer $SHIELDMX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "someone@relay.firefox.com"}'// Node 18+ or any runtime with fetch
const res = await fetch("https://shieldmx-core-api.slotboard-v.workers.dev/v1/verify", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SHIELDMX_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "someone@relay.firefox.com" }),
});
const verdict = await res.json();
console.log(verdict.recommendation, verdict.reason);# pip install requests
import os
import requests
res = requests.post(
"https://shieldmx-core-api.slotboard-v.workers.dev/v1/verify",
headers={"Authorization": f"Bearer {os.environ['SHIELDMX_API_KEY']}"},
json={"email": "someone@relay.firefox.com"},
timeout=3,
)
res.raise_for_status()
verdict = res.json()
print(verdict["recommendation"], verdict["reason"])The response is a verdict. The alias check returns REVIEW, because privacy aliases belong to real people:
{
"valid_syntax": true,
"email": "someone@relay.firefox.com",
"domain": "relay.firefox.com",
"disposable": false,
"alias": true,
"matched_domain": "relay.firefox.com",
"mx_valid": true,
"recommendation": "REVIEW",
"reason": "privacy_alias",
"risk_score": 50,
"suggestion": null,
"mode": "live",
"evaluated_at": 1792841446
}Try it in the browser
Paste a test key (it starts with smx_test_) and an address, then run the check. The request goes straight from this page to the API. The key stays in this tab and is not saved anywhere.
Run a check to see the verdict here.
Authentication
Send your key as a bearer token on every request:
Authorization: Bearer smx_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
- Live keys start with
smx_live_. They count toward your plan's monthly quota. - Test keys start with
smx_test_. They run the same checks and do not count toward quota. See Test mode. - A key is shown once, when you create it. If you lose it, revoke it and create another.
- Revoking a key stops it working on the next call.
Verify one address
Checks one address and returns one verdict.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | The address to check. 5 to 254 characters. Trimmed and lowercased before the check. No other fields are allowed. |
Query parameters
| Parameter | Default | Description |
|---|---|---|
mx | true | Set to false to skip the mail-server lookup. The verdict then comes from the list and your rules only. |
Response fields
| Field | Type | Meaning |
|---|---|---|
valid_syntax | boolean | Always true on a 200. Malformed input returns a 400 instead. |
email | string | The address, trimmed and lowercased. |
domain | string | The part after the @. |
disposable | boolean | true when the domain or one of its parents is on the disposable list. |
alias | boolean | true for privacy alias services such as Firefox Relay or SimpleLogin. |
matched_domain | string or null | The list entry that matched, for example mailinator.com for a.mailinator.com. |
mx_valid | boolean or null | Whether the domain can receive mail. null when unknown, skipped, or already decided by a rule or the list. |
recommendation | string | ALLOW, REVIEW or REJECT. See Verdicts and reasons. |
reason | string | Why the recommendation was made. |
risk_score | integer | 0 to 100. Higher is riskier. |
suggestion | string or null | A likely intended provider, for example gmail.com for gmial.com. |
mode | string | live or test, matching the key. |
evaluated_at | integer | Unix time in seconds when the verdict was made. |
Verify a batch
Checks up to 100 addresses in one request. Use it for signup backfills, imports and re-checks.
Request body
{
"emails": [
"a@relay.firefox.com",
"b@mailinator.com",
"c@example.com"
]
}emailsis required and holds 1 to 100 strings.- Results come back in the same order as the input.
- An address with bad syntax gets its own error object. The rest of the batch still runs.
- The batch is refused with
429 QUOTA_EXCEEDEDif the well-formed addresses in it are more than your remaining monthly quota. Nothing is checked in that case.
Response
{
"results": [
{ "email": "a@relay.firefox.com", "recommendation": "REVIEW", "reason": "privacy_alias", "...": "..." },
{ "email": "b@mailinator.com", "recommendation": "REJECT", "reason": "disposable_domain", "...": "..." },
{ "valid_syntax": false, "email": "c@", "error": { "code": "INVALID_SYNTAX", "message": "..." } }
],
"summary": {
"submitted": 3,
"checked": 2,
"rejected": 1,
"reviewed": 1,
"allowed": 0,
"invalid": 1
}
}Each domain is looked up once per batch, so many addresses on the same provider cost one mail-server check.
Verdicts and reasons
Every verdict is decided in this order. The first rule that matches wins:
| Order | Condition | Recommendation | Reason | Risk |
|---|---|---|---|---|
| 1 | A customer deny rule matches the domain | REJECT | customer_denied | 100 |
| 2 | A customer allow rule matches the domain | ALLOW | customer_allowed | 0 |
| 3 | The domain or a parent is on the disposable list | REJECT | disposable_domain | 100 |
| 4 | The domain has no mail server (including a null MX) | REJECT | no_mail_server | 90 |
| 5 | A privacy alias service | REVIEW | privacy_alias | 50 |
| 6 | Nothing matched | ALLOW | clean | 0 |
What to do with each recommendation
- ALLOW: let the signup through.
- REVIEW: let the signup through only after an extra step, such as a card on file or a captcha. Real people use alias services, so don't refuse them outright.
- REJECT: refuse the signup, with a generic message. Log the reason, not the address.
mailinator.com, an address there returns ALLOW with customer_allowed, even though disposable is still true.Errors
Errors use one shape:
{
"error": {
"code": "INVALID_SYNTAX",
"message": "The provided value fails standardized RFC 5322 parsing evaluations."
}
}| Status | Code | When it happens | What to do |
|---|---|---|---|
| 400 | MALFORMED_JSON | The body is not JSON, or it has extra or missing fields. | Fix the request. Don't retry unchanged. |
| 400 | INVALID_SYNTAX | The address fails RFC 5322 checks. | Show the user a clear message. |
| 400 | INVALID_BATCH | The batch is empty or has more than 100 addresses. | Split the batch. |
| 401 | UNAUTHORIZED | The key is missing, malformed, invalid or revoked. | Check the key. Don't retry. |
| 404 | NOT_FOUND | No route matches. | Check the path. |
| 429 | RATE_LIMITED | More than 60 requests a minute on one key. | Wait for Retry-After seconds, then retry. |
| 429 | QUOTA_EXCEEDED | Your monthly quota is used up. | Upgrade, or wait for the next month. |
| 503 | BLOCKLIST_UNAVAILABLE | The disposable list is not available yet. | Retry shortly. Never treat this as an allow. |
Rate limits and quotas
Rate limit
Each key may make 60 requests a minute. Going over returns 429 RATE_LIMITED with a Retry-After: 60 header. The limit is counted per data center, so it is approximate. It protects against bursts, it is not an exact global quota.
Monthly quota
| Plan | Checks a month | Price |
|---|---|---|
| Free | 500 | $0 |
| Pro | 50,000 | $9 a month (planned, may change before general availability) |
- Every well-formed live-mode address counts once. A batch counts one per well-formed address.
- Requests with bad syntax, and requests refused by the rate limit, do not count.
- The quota resets at the start of each calendar month, in UTC.
Test mode
Test keys start with smx_test_. They call the same endpoints and get the same response shape, with "mode": "test". They don't count toward quota, and they don't appear in your analytics.
Use a test key in CI, staging, and the tests for your signup flow. Switch to a live key only in production.
Domain rules
Rules let you decide the outcome for specific domains. You manage them in the dashboard under Domain rules. Each rule has a domain and an action.
- Allow a partner domain, so it always returns ALLOW.
- Deny a domain you never want, so it always returns REJECT.
- A rule covers its subdomains. The most specific rule wins:
ops.partner.comcan be denied whilepartner.comis allowed. - You can have up to 500 rules.
Rules apply to the next check. A change applies at once in the region where you made it, and everywhere within about a minute.
Blocklist and mail checks
Disposable list
- More than 9,900 known disposable domains, refreshed once a day from a public source.
- Matching is by domain and its parents. If
mailinator.comis listed, so isanything.mailinator.com. - If a refresh fails, the last good copy keeps serving.
Mail-server check
- We ask DNS whether the domain has an MX record. If it has none, we check for an A record, which mail servers fall back to.
- A null MX (
0 .) or a domain that does not exist means the domain cannot receive mail, so the verdict is REJECT. - Results are cached for an hour.
- If the lookup times out or fails,
mx_validisnulland the address is not blocked on that basis. - Turn the check off per request with
?mx=false.
Integration recipes
Every recipe does the same three things: call the API with a server-side key, act on recommendation, and fail closed when the check cannot run. None of them send the key to the browser.
Next.js (App Router)
// app/api/signup/route.ts
const BASE = "https://shieldmx-core-api.slotboard-v.workers.dev";
export async function POST(req: Request) {
const { email } = await req.json();
let verdict;
try {
const res = await fetch(BASE + "/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SHIELDMX_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
verdict = await res.json();
} catch {
// Fail closed: no account is created while the check cannot run.
return Response.json({ error: "We could not verify your email right now. Please try again." }, { status: 503 });
}
if (verdict.recommendation === "REJECT") {
console.log("signup rejected", verdict.reason, verdict.domain);
return Response.json({ error: "We couldn't create an account with that address." }, { status: 422 });
}
// REVIEW: create the account, but start the trial only after a card or captcha.
return Response.json({ ok: true, review: verdict.recommendation === "REVIEW" }, { status: 201 });
}Express
const BASE = "https://shieldmx-core-api.slotboard-v.workers.dev";
app.post("/signup", express.json(), async (req, res) => {
let verdict;
try {
const r = await fetch(BASE + "/v1/verify", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.SHIELDMX_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ email: req.body.email }),
signal: AbortSignal.timeout(3000),
});
if (!r.ok) throw new Error("HTTP " + r.status);
verdict = await r.json();
} catch {
return res.status(503).json({ error: "We could not verify your email right now. Please try again." });
}
if (verdict.recommendation === "REJECT") {
console.log("signup rejected", verdict.reason, verdict.domain);
return res.status(422).json({ error: "We couldn't create an account with that address." });
}
// REVIEW: create the account, but start the trial only after a card or captcha.
res.status(201).json({ ok: true, review: verdict.recommendation === "REVIEW" });
});Cloudflare Worker
// src/index.ts, deployed with: wrangler secret put SHIELDMX_API_KEY
export interface Env {
SHIELDMX_API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { email } = (await request.json()) as { email: string };
const res = await fetch("https://shieldmx-core-api.slotboard-v.workers.dev/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${env.SHIELDMX_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
return Response.json({ error: "We could not verify your email right now." }, { status: 503 });
}
const verdict = (await res.json()) as { recommendation: string; reason: string };
if (verdict.recommendation === "REJECT") {
return Response.json({ error: "We couldn't create an account with that address." }, { status: 422 });
}
return Response.json({ ok: true, review: verdict.recommendation === "REVIEW" }, { status: 201 });
},
};Supabase Edge Function
// supabase/functions/signup/index.ts
// Deploy with: supabase secrets set SHIELDMX_API_KEY=smx_live_...
Deno.serve(async (req) => {
const { email } = await req.json();
const res = await fetch("https://shieldmx-core-api.slotboard-v.workers.dev/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${Deno.env.get("SHIELDMX_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
return Response.json({ error: "We could not verify your email right now." }, { status: 503 });
}
const verdict = await res.json();
if (verdict.recommendation === "REJECT") {
return Response.json({ error: "We couldn't create an account with that address." }, { status: 422 });
}
return Response.json({ ok: true, review: verdict.recommendation === "REVIEW" }, { status: 201 });
});FastAPI
# pip install fastapi httpx
import os
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
BASE = "https://shieldmx-core-api.slotboard-v.workers.dev"
app = FastAPI()
class Signup(BaseModel):
email: str
@app.post("/signup", status_code=201)
async def signup(body: Signup):
try:
async with httpx.AsyncClient(timeout=3) as client:
r = await client.post(
f"{BASE}/v1/verify",
headers={"Authorization": f"Bearer {os.environ['SHIELDMX_API_KEY']}"},
json={"email": body.email},
)
r.raise_for_status()
verdict = r.json()
except httpx.HTTPError:
raise HTTPException(503, "We could not verify your email right now. Please try again.")
if verdict["recommendation"] == "REJECT":
raise HTTPException(422, "We couldn't create an account with that address.")
# REVIEW: create the account, but start the trial only after a card or captcha.
return {"ok": True, "review": verdict["recommendation"] == "REVIEW"}Agent setup prompt
Paste the prompt below into your coding agent, such as Claude Code, Cursor or Codex, in the project where your signup lives. It tells the agent where the contract is, what to build, and how to handle every verdict and failure.
You are integrating ShieldMX into this project to stop free-trial abuse at signup. Read these first: - https://shieldmx-core-api.slotboard-v.workers.dev/openapi.json (the API contract) - https://shieldmx-core-api.slotboard-v.workers.dev/llms.txt (behaviour summary) Then do this: 1. Add SHIELDMX_API_KEY to the server environment. Never expose it to the browser, a mobile bundle, or a commit. 2. Create one server-side function, checkEmail(email), that calls POST https://shieldmx-core-api.slotboard-v.workers.dev/v1/verify with headers "Authorization: Bearer" and "Content-Type: application/json", and the body {"email": email}. Use the platform fetch with a 3 second timeout. Do not add a dependency. 3. Call checkEmail in the signup handler, before the account or the free trial is created. 4. Map the verdict on "recommendation": - ALLOW: continue. - REVIEW: continue, but require one extra step before the free trial starts (a card on file or a captcha). Do not refuse these. - REJECT: refuse the signup with a generic message such as "We couldn't create an account with that address." Log the "reason" field and the domain. Never log the full address. 5. Failures: on 429, 503, a timeout or a network error, retry twice with backoff (500 ms, then 1500 ms). If it still fails, show "We couldn't verify your email right now. Please try again." Do not let the signup start a free trial. On 401, stop and report it: the key is wrong or revoked. 6. Add tests for the three verdicts. Use a test key (it starts with smx_test_) so tests do not use quota. Stub the HTTP call in unit tests. 7. Keep the change small. Tell me which files you touched and how to set the environment variable.
Give your agent the contract
Agents work best from the machine-readable files. Point them at these:
- openapi.json: the full OpenAPI 3.1 contract, for codegen and client generation.
- llms.txt: a short behaviour summary written for language models.
- This page: the reference you are reading now.
Accuracy
We test the verdict logic against a labeled set of known cases, and the set runs as part of the test suite on every build. Here are the current results.
| Check | Result | Cases |
|---|---|---|
| Disposable domains rejected | 100% | 200 |
| Well-known providers wrongly rejected or reviewed | 0% | 41 |
| Privacy alias domains sent to REVIEW | 100% | 6 |
| Typos given the right suggestion | 100% | 6 |
Changelog
1.1.0
- New
REVIEWverdict for privacy alias services. - Customer allow and deny rules, with subdomain matching.
risk_score,reason,alias,matched_domainandsuggestionon every verdict.- Batch endpoint: up to 100 addresses per call.
- Test-mode keys, which do not count toward quota.
- Mail-server checks, with null MX detection and a one-hour cache.
1.0.0
- Single-address verify with syntax, disposable-domain and mail-server checks.
- Per-key rate limit and monthly quotas.