Doorman docs
Doorman is form spam protection that reads the message: agency pitches, SEO spam and bots are kept out of your contact, demo and quote forms. Point a form at it, call it from your backend, or let your coding agent do either. Each submission is judged by our models in about 100 ms and routed to deliver, hold or drop.
Quickstart #
With a coding agent: paste this, and answer its one question.
Add Doorman spam protection to this project's forms. Follow https://doorman.example/llms.txt. Ask me for the email that should receive real submissions.By hand: about three minutes, no code beyond one attribute.
- Sign up at /signup. You get a first form, an API key, and forwarding to your sign-up email. Add a sentence about your business (“We sell invoicing software to dental practices”) so Doorman knows what a real customer looks like for you.
- Copy the form's action URL from the dashboard into your form:
<form action="https://doorman.example/f/f_yourFormId" method="POST"> - Done. Real submissions arrive where you pointed them (email, Slack or webhook). Pitches and bots are kept out of your inbox; if you want to know why, each one has its reason in the dashboard. Optionally add the snippet for timing and honeypot signals.
New lead form? Start in watch mode: everything is judged, nothing is withheld. Switch to enforce after a week or two of verdicts look right.
Which integration?
- The form already posts to your own backend (an API route, Worker, server action): call
/v1/checkfrom that handler, with a 3–5 second timeout and a fallback that still delivers the lead. Act onactionordeliver, neverroute. - Static site or site builder, no backend: point the form's
actionat the hosted endpoint. - SPA, no backend: post from the client to the hosted endpoint (
snippet?platform=react).
Strict Content-Security-Policy? Add Doorman's origin to script-src for d.js, to form-action for a hosted form action, and to connect-src for AJAX or client-side posts. A backend check needs no change.
Agents & MCP #
Everything in these docs can be done without a browser, so a coding agent can set Doorman up end to end. There are two ways in:
/llms.txt: the setup guide written for agents. It covers finding the project's forms, signing up, picking an integration, setting destinations and verifying with test submissions. Any agent that can runcurlcan follow it./mcp: a remote MCP server (Streamable HTTP, JSON-RPC 2.0, plain JSON responses). Protocol versions2025-06-18,2025-03-26and2024-11-05.
A focused walkthrough for people is at Set up Doorman with your agent.
Install the MCP server
Claude Code, with your key (Settings in the dashboard):
claude mcp add --transport http doorman https://doorman.example/mcp \
--header "Authorization: Bearer $DOORMAN_KEY"No account yet? Leave the header off; doorman_signup works without one. A newly added MCP server usually loads only in the agent's next session or after a reconnect, so for setup in the current session plain HTTP (/llms.txt) is faster.
Codex (recent versions), in ~/.codex/config.toml:
[mcp_servers.doorman]
url = "https://doorman.example/mcp"Cursor (recent versions), in .cursor/mcp.json:
{ "mcpServers": { "doorman": { "url": "https://doorman.example/mcp" } } }Config formats for these clients change often; check your client's docs if one of these doesn't load. Without an Authorization header only doorman_docs and doorman_signup work; every other tool takes the key from the header or, failing that, an api_key argument. So an agent can sign up and keep going in the same session. If you already have an account, pass a key rather than signing up again.
MCP tools
| Tool | Arguments | Does |
|---|---|---|
doorman_docs | none | Returns the same text as /llms.txt. Read-only. |
doorman_signup | email, site_url, site_context (required), form_name | Creates the account, a first form and an API key. Returns api_key, the form with its action_url, and next steps. The user is emailed a link to set a password. No key needed. |
doorman_whoami | Account, plan and this month's usage. Read-only. | |
doorman_list_forms | All forms with action URLs and settings. Read-only. | |
doorman_create_form | name (required), kind, site_context, email_to, webhook_url, slack_url, redirect_url | Another form endpoint, one per distinct form on the site. |
doorman_update_form | form_id (required) and any form setting, including mode, thresholds, allow_list, block_list | Changes a form's settings. |
doorman_get_install_snippet | form_id, platform (required) | Ready-to-paste code. Platforms as in install snippets. Read-only. |
doorman_test_submission | form_id, message (required), email | Judges a sample. Free, not forwarded, not counted. |
doorman_send_test_delivery | form_id (required) | Sends a sample to the form's destinations and reports each result. |
doorman_list_submissions | form_id, route, review, limit (default 20) | Recent submissions with verdicts; review: true for held ones waiting for a person. Read-only. |
doorman_label_submission | submission_id, label (real or spam) | Same as labelling through the API. |
Every tool except doorman_docs and doorman_signup also accepts api_key. Tool errors come back as an MCP tool result with isError: true and a readable message.
Accounts made by agents
An agent signs up without a password. Doorman sets a random one and emails the owner a “set a password” link, valid for 7 days, to /app#/reset/<token>. Until then the account works fully through the API key. If the email already has an account, sign-up returns 409: create a key under Settings in the dashboard (/app#/settings) and give that to the agent instead.
The API key belongs in the project's environment (DOORMAN_KEY in .env, gitignored), never in client-side code or a commit. Hosted form endpoints don't need a key at all.
Hosted forms #
/f/:formId
no keyCORS is open, so it works from any page. Doorman accepts application/x-www-form-urlencoded, multipart/form-data (file uploads are ignored) and JSON, up to 1 MB. A GET on the URL shows a short “this is a Doorman endpoint” page.
Field-name detection
Keep your field names. Doorman matches them case-insensitively; the first field that matches each role wins.
| Role | Field names |
|---|---|
email e-mail email_address your_email from | |
| name | name full_name fullname your_name first_name firstname |
| message | message msg body comment comments details inquiry enquiry question note notes description text content how_can_we_help project |
| fields | Everything else, kept as extra fields (up to 50, 500 characters each). Repeated names are joined with , . |
- No message field? The longest other field, if it's over 40 characters, is used as the message.
- Lengths are clipped: message 8,000 characters, email 320, name 200.
- Fields whose names start with
_are never stored as data. The ones below have a meaning. - On a hosted form every other name is an ordinary field, including
kind: the form's own kind always applies, so a bot can't choose which questions it's asked.
Special fields
| Field | What it does |
|---|---|
_next | Where to send the visitor after submitting. Must be an absolute http(s):// URL on the same site: its host must match the form's redirect URL or the page the form was posted from. Anything else is ignored. |
_t | Page-load time in Unix milliseconds. Becomes seconds_on_page and implies JavaScript ran. The snippet sets it. |
_js | 1 if JavaScript ran. The snippet sets it. |
_gotcha _hp _honeypot | Honeypots. Any non-empty value marks the submission honeypot_filled. Hide them from people; the snippet adds _gotcha for you. |
What the visitor gets back
The response never reveals the verdict. A dropped bot and a real customer get the same thing:
- Plain form post:
303redirect to_next, else the form's redirect URL, elsehttps://doorman.example/thanks. - JSON: if the request's
Acceptheader containsjson, or it sends anyX-Requested-Withheader, the answer is200 {"ok": true}.
Before judging, a request can be rejected outright: 404 for an unknown form id (JSON error), 413 over 1 MB.
Floods from one address
Past 30 submissions a minute from one IP to one form, Doorman still stores each one, but as hold with reason: "rate_limited": not judged, not counted as a check, and not forwarded. They wait in the dashboard's review queue, and labelling one real forwards it. Past a further 100 such submissions in an hour from that IP, they're no longer stored. The visitor gets the normal response either way, so an office behind one IP never sees an error.
The snippet #
Optional. Forms work without it; with it, Doorman sees how long the visitor spent on the page and whether JavaScript ran, which catches most scripts.
<script src="https://doorman.example/d.js" defer></script>It finds every form whose action ends in /f/f_… (or that has data-doorman), including forms added later by a single-page app, and adds three hidden inputs: _t, _js and an off-screen _gotcha honeypot (tabindex="-1", aria-hidden, so keyboard and screen-reader users never meet it).
| Attribute / API | Effect |
|---|---|
data-doorman | Arm a form whose action doesn't point at /f/…, e.g. one that posts to your own backend, which then calls /v1/check. |
data-doorman-ajax | Submit with fetch instead of navigating. On success the form is replaced by <p class="doorman-success">. If the request fails at the network level, it falls back to a normal submit. |
data-doorman-success | The text shown in place of the form. Default: “Thanks! We'll be in touch.” |
window.Doorman.signals() | Returns { _t, _js: "1" }, to add to a body you build yourself. |
window.Doorman.arm(form) | Arm a specific form element by hand. |
<form action="https://doorman.example/f/f_yourFormId" method="POST"
data-doorman-ajax data-doorman-success="Got it. We reply within a day.">
<label>Email <input name="email" type="email" required></label>
<label>How can we help? <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
</form>
<script src="https://doorman.example/d.js" defer></script>// building the request yourself
const body = { ...Object.fromEntries(new FormData(form)), ...window.Doorman.signals() };
await fetch(form.action, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
});
// always { "ok": true }API reference #
Base URL https://doorman.example. JSON in, JSON out, request bodies up to 256 KB. CORS is open, but keep the key on your server.
Authentication
Send your API key as a bearer token. Keys look like dm_live_…. /v1/signup returns one; more live under Settings in the dashboard, where you can create several and revoke any of them.
Authorization: Bearer dm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/v1/signup
no keyCreate an account, its first form and an API key in one call. Made for agents and scripts; people can use /signup.
| Field | Description |
|---|---|
emailstring, required | The owner's email. Real submissions are forwarded here by default. |
site_urlstring | The site the form is on, e.g. example.com. Used to name the form. |
site_contextstring | One or two concrete sentences: what is sold, to whom. Every submission is judged against it. |
form_namestring | Optional, e.g. "Demo request". |
modestring | watch or enforce (default) for the first form. watch is recommended for a new lead form. |
agentstring | Optional label for the key, e.g. "claude-code". |
passwordstring | Optional, 8+ characters. Without it, the owner is emailed a link to set one. |
curl -X POST https://doorman.example/v1/signup \
-H "Content-Type: application/json" \
-d '{
"email": "owner@example.com",
"site_url": "example.com",
"site_context": "Example sells invoicing software to small businesses in the US.",
"agent": "claude-code"
}'201 with api_key, account (as /v1/me), form (as /v1/forms, with action_url), dashboard_url, claim (a sentence saying where the password link went, or null), next_steps and note. The first form is created for you (kind lead, forwarding to the sign-up email); use its id rather than creating another. Until the owner clicks the link in that email, account.verified is false: 100 checks, and nothing is emailed (Slack and webhooks work). 409 if the email already has an account; 429 after 5 sign-ups an hour from one IP.
/v1/check
Judge one submission and get a route back. Counts as one check.
| Field | Description |
|---|---|
formstring | Form id (f_…) on your account. Doorman uses that form's kind, business description, thresholds, mode and lists. Unknown or someone else's: 404. |
kindstring | lead (default), signup, checkout or comment. See Kinds. Overrides the form's kind if both are given. |
contextstring | Without form only: a sentence about the business, e.g. "A bakery in Leeds that takes wedding cake orders." |
emailstring | Sender's email. Any name from field-name detection works too. |
namestring | Sender's name. |
messagestring | The free text. This is what Doorman mostly reads. |
fieldsobject | Any other fields, { "company": "Acme" }. Other unrecognised top-level keys land here too. |
signalsobject | seconds_on_page (number), javascript_ran (bool), honeypot_filled (bool), country, ip, and the checkout signals. Top-level _t, _js and honeypot fields forwarded from a form are understood too. |
orderobject | Checkout only: { "items": "2× Pro plan", "total_usd": 58 }. |
forwardboolean | With form only. true also sends the submission to the form's destinations (email, Slack, webhook) according to its action, exactly like a hosted post; that's how the owner gets an email without you writing email code. Default false: you get the answer and act on it yourself. The response's forwarded fields say what happened. |
Without form, the defaults apply: thresholds 0.8 / 0.2, enforce mode, no lists, nothing forwarded. The decision is still stored and shows up in your dashboard.
curl https://doorman.example/v1/check \
-H "Authorization: Bearer $DOORMAN_KEY" \
-H "Content-Type: application/json" \
-d '{
"form": "f_yourFormId",
"email": "sam@growthstudio.example",
"name": "Sam",
"message": "Love what you are building. We fill SaaS pipelines with done-for-you outreach. Open to a 15 minute chat?",
"signals": { "ip": "203.0.113.7", "seconds_on_page": 38, "honeypot_filled": false }
}'Response:
{
"id": "sub_8fKq2LmZ0aVb3c",
"form": "f_yourFormId",
"created_at": "2026-09-28T14:03:11.402Z",
"action": "drop",
"deliver": false,
"route": "drop",
"p_real": 0.06,
"reason": "pitch",
"evidence": "We fill SaaS pipelines with done-for-you outreach.",
"model": "jev-1.13.0",
"latency_ms": 211,
"label": null,
"email": "sam@growthstudio.example",
"forwarded": false,
"forwarded_to": [],
"forward_why": "not_requested",
"forward_detail": "Pass forward: true (with form) to send it to the form's destinations, or act on it in your own code."
}| Field | Meaning |
|---|---|
id | Submission id, sub_…. |
form | Form id, or null for a form-less check. |
created_at | ISO 8601 timestamp. |
action | What to do with it: deliver, hold or drop. Usually equals route; differs in watch mode (a drop becomes deliver) and after a label. Act on this one. |
deliver | false only when action is drop: discard it. A hold is true: keep it, flagged for review. The simplest thing to branch on. |
route | Doorman's opinion, before watch mode and labels. Don't branch on it: in watch mode a drop here is still delivered. |
p_real | Probability it's genuine, 0–1. null when it wasn't judged (lists, quota, errors, floods). |
reason | For holds and drops: pitch, automated or fraud when judged. When not judged: allow_list, block_list, over_quota, rate_limited or doorman_error (the SDK adds doorman_unreachable). Otherwise null. |
evidence | The sentence of the message (or, for checkouts, the fact) that most shows it's not genuine. On a deliver, the sentence that best shows it is. |
model | The model version that judged it. |
latency_ms | Model round trip in milliseconds; 0 when not judged. |
label | real, spam or null: a person's verdict. |
error | Present only when judging was skipped or failed. |
email | The sender email Doorman detected. |
forwarded | true if it's going to at least one of the form's destinations (forwarded_to: email, slack, webhook). Sending is asynchronous and retried; failures show on the submission. |
forward_why | Why it isn't, or isn't fully, forwarded: not_requested, unverified (the owner hasn't clicked the confirmation link, so no email), no_destination, recipient_unconfirmed, email_limit, dropped, holds_not_forwarded or no_form. null when everything went. forward_detail says it in a sentence. |
In your handler: on drop, show the normal thank-you but don't save or notify; on hold, save it flagged for review; on deliver, the normal path. Never tell the visitor the verdict, and treat a Doorman timeout as hold, not drop (the SDK does this for you). Without the SDK:
let r;
try {
const res = await fetch("https://doorman.example/v1/check", {
method: "POST",
headers: { Authorization: `Bearer ${env.DOORMAN_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ form: "f_yourFormId", email, name, message, signals: { ip } }),
signal: AbortSignal.timeout(4000),
});
if (!res.ok) throw new Error(`Doorman ${res.status}`);
r = await res.json();
} catch {
r = { deliver: true, action: "hold", reason: "doorman_unreachable" }; // never lose a lead
}
if (r.deliver) await saveLead({ email, name, message, flagged: r.action === "hold" });Plain fetch, so it runs on Cloudflare Workers, Node 18+, Next.js, Deno and Bun. GET /v1/forms/:id/snippet?platform=fetch returns it with your form id filled in.
/v1/account
Also at /v1/me. The account: id, email, verified (false until the owner clicks the confirmation link; until then nothing is emailed), plan (free, pro, business), plan_name, effective_plan (pro while a trial runs), trial_ends_at (when the Pro trial ends, or null), usage ({ month, checks, limit }), retention_days and plans (every plan's price and checks).
Forms
/v1/forms
All forms, as { "data": [ … ] }, each with submissions (count) and last_at.
/v1/forms
Create a form: one per distinct form on the site. Body: any of the settings below (name defaults to “Contact form”). Returns 201 with the form and next_steps. Up to 50 forms per account.
{ "name": "Demo request", "kind": "lead", "site_context": "Invoicing software for dental practices in the UK." }/v1/forms/:id
One form, as saved. Read it back after a change to confirm it took.
/v1/forms/:id
Change any settings; omitted ones stay as they are.
curl -X PATCH https://doorman.example/v1/forms/f_yourFormId \
-H "Authorization: Bearer $DOORMAN_KEY" -H "Content-Type: application/json" \
-d '{ "slack_url": "https://hooks.slack.com/services/…", "mode": "watch", "allow_list": ["bigcustomer.com"] }'/v1/forms/:id
Deletes the form. Its endpoint stops accepting posts (404).
Form settings
| Field | Description |
|---|---|
name | Up to 80 characters. |
kind | lead, signup, checkout or comment. See Kinds. |
site_context | What the business sells and to whom. Up to 1,000 characters. |
mode | enforce (default) or watch. |
deliver_at drop_below | Thresholds, 0–1, defaults 0.8 and 0.2. drop_below can't be above deliver_at. |
forward_holds | Forward held submissions, flagged (default true). |
email_to | Emails separated by commas or spaces, up to 300 characters. |
slack_url webhook_url | Full https:// URLs. Empty string removes one. |
redirect_url | Where visitors land after a plain form post (default /thanks). |
allow_list block_list | Arrays (or newline/comma-separated strings) of emails or domains, up to 500 each. See lists. |
Read-only on the form object: id, action_url, webhook_secret, created_at. The API includes webhook_secret only once a webhook_url is set; treat those responses like the key.
Test & test delivery
/v1/forms/:id/test
Judge a sample message with the form's settings, as if a person spent 40 seconds on the page. Free, not counted, not forwarded, and left out of submission listings. Body: { "message": "…", "email": "…" } (email optional). Returns the same object as /v1/check.
POST /v1/forms/f_…/test { "message": "Hi, we run a 12-person dental clinic and spend a day a week chasing invoices. Can we see a demo?" } -> deliver
POST /v1/forms/f_…/test { "message": "We help SaaS founders fill their pipeline with done-for-you outreach. 15 min chat?" } -> drop/v1/forms/:id/test-delivery
Sends a sample submission to every destination the form has and returns { "log": [ { "to": "webhook"|"slack"|"email", "ok": true, "status": 200, "ms": 84 } ] }, with error on failures.
/v1/forms/:id/snippet?platform=…
Ready-to-paste integration code for a form: { "platform", "note", "code" }. Platforms: html, ajax, react, nextjs, express, curl, webflow, framer, wordpress. Unknown platform: 422.
Submissions
/v1/submissions
Recent decisions, newest first, as { "data": [ … ], "next_before": "…" } with the same objects as /v1/check. Test submissions are excluded.
| Query | Description |
|---|---|
form | Only this form id. |
route | deliver, hold or drop: filter on the judged route. |
review | true: only held submissions nobody has labelled yet (the review queue). |
before | Unix milliseconds or an ISO timestamp; only submissions created before it. To page, pass the previous response's next_before. |
limit | 1–200, default 50. |
curl "https://doorman.example/v1/submissions?review=true&limit=20" \
-H "Authorization: Bearer $DOORMAN_KEY"/v1/submissions/:id
One submission. 404 if it doesn't exist or isn't yours.
/v1/submissions/:id/label
Tell Doorman what a submission really was. Body: { "label": "real" } or { "label": "spam" }. Returns the updated submission.
realsetsactiontodeliver. If it was never forwarded and it came from a hosted form (or an API check withforward: true) whose form has destinations, it's forwarded now assubmission.delivered. This is the “rescue” button in the dashboard.spamsetsactiontodropand cancels any pending delivery retries. Nothing already sent is recalled.
Errors & limits
Errors are JSON with a human-readable message: { "error": "…" }.
| Status | When |
|---|---|
400 | Body isn't valid JSON, or isn't a JSON object. |
401 | Missing, malformed or revoked API key. |
404 | Unknown endpoint, form or submission. |
409 | Sign-up for an email that already has an account. |
413 | Body too large (256 KB for the API, 1 MB for hosted forms). |
422 | A value is invalid, e.g. a bad URL, threshold or label. |
429 | Over a limit: 600 API requests a minute per account, or 5 sign-ups an hour per IP. |
500 | Our bug. It's logged. |
A model error is not an HTTP error: you get 200 with route: "hold" and reason: "doorman_error". Hosted forms never return 429; see floods.
Routes & thresholds #
Doorman's models answer typed questions about each submission: real (a probability), pitch, automated (and fraud for checkouts), and evidence, a choice among the message's own sentences. Only real decides the route, using thresholds that live in Doorman, per form:
p_real ≥ deliver_at (default 0.8). Forwarded to your destinations.p_real < drop_below (default 0.2). The sender sees the normal thank-you. Not forwarded; kept in the dashboard with its evidence, one click to rescue.The reason on a hold or drop is whichever of pitch, automated or fraud scored highest, if it scored at least 0.5; otherwise null.
Set them equal to remove the hold lane; lower drop_below to drop only the obvious.
Kinds #
A form's kind changes what “genuine” means and what Doorman looks for. For sign-ups without a message, the evidence is the field that gave it away, such as Email: ….
| Kind | For | Genuine means | Stops (reason) |
|---|---|---|---|
lead | Contact, demo, quote or sales forms | A person describing their own need and asking about what you offer. | Sales pitches (pitch), templates and bots (automated) |
signup | Account sign-ups and waitlists | A plausible person or business who might use the product. Free email providers and short names are fine. | Fake, throwaway and scripted accounts (fake_account), bots, promotion |
comment | Public comments, reviews, posts | A person reacting to the page or discussion, however briefly, in any language. Disagreement is fine. | Harassment, hate, threats (abusive), promotion and link spam (pitch), bots |
checkout | Checkouts and orders | A person buying with their own card. Judged from the order and signals, not a message. | Card testing and stolen cards (fraud) |
Checkout signals
For checkout, send the check from your server with what your payment flow knows in signals, plus order. Missing values are fine. The evidence is one of these facts, e.g. "Payment attempts before success: 6".
| Signal | Description |
|---|---|
card_country | Card's issuing country. |
ip_country | Country of the buyer's IP (falls back to country). |
attempts | Payment attempts before success. Default 1. |
device_orders_last_hour | Orders from this device in the last hour. |
account_age_days | Age of the buyer's account. |
name_match | false if the billing name doesn't match the card. |
card_type | e.g. "prepaid". Default "standard". |
shipping | e.g. "freight forwarder". Default "none, digital". |
seconds_on_page | Time on the checkout page before paying. |
{
"kind": "checkout",
"email": "buyer@example.com",
"order": { "items": "10× $50 gift card", "total_usd": 500 },
"signals": {
"card_country": "US", "ip_country": "RO",
"attempts": 6, "device_orders_last_hour": 4,
"account_age_days": 0, "name_match": false, "card_type": "prepaid"
}
}Allow & block lists #
Each form has an allow_list (always delivered) and a block_list (always dropped). A matching submission isn't judged and doesn't use a check; its reason is allow_list or block_list and p_real is null. The allow list is checked first.
| Entry | Matches |
|---|---|
jess@acme.com | That address only. |
acme.com or @acme.com | Any address at acme.com. |
*.acme.com | Any address at a subdomain, like eu.acme.com (not acme.com itself: add both). |
Matching is on the sender email Doorman detected and is case-insensitive. Lists apply to hosted posts, /v1/check calls with a form, and tests.
Webhooks #
Set a webhook URL on a form and Doorman POSTs JSON to it for every submission it forwards. Two events:
submission.delivered: a deliver, a watch-mode drop, or a rescue.submission.held: a hold, sent withheld: true(only if the form forwards holds).
Drops never reach your webhook unless you rescue them.
POST /your/webhook
Content-Type: application/json
User-Agent: Doorman-Webhook/1
Doorman-Signature: t=1790604191,v1=5f2c0a…e41b
{
"event": "submission.held",
"id": "sub_8fKq2LmZ0aVb3c",
"created_at": "2026-09-28T14:03:11.402Z",
"form": { "id": "f_yourFormId", "name": "Contact form" },
"route": "hold",
"held": true,
"p_real": 0.46,
"reason": null,
"evidence": "Could I resell this to my own clients?",
"submission": {
"email": "ana@studio.example",
"name": "Ana",
"message": "Hi! Could I resell this to my own clients? We manage books for about 30 small shops.",
"fields": { "company": "Studio Ana" }
},
"dashboard_url": "https://doorman.example/app#/inbox/sub_8fKq2LmZ0aVb3c"
}Retries
Each attempt has an 8-second timeout; any 2xx counts as delivered. If a destination fails (webhook, Slack or email), only that destination is retried, after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: six attempts in all. The log of every attempt is on the submission in the dashboard. Labelling a submission spam cancels its pending retries. Make your handler idempotent on id, since a retry can arrive after a slow success.
Verifying the signature
Each form has its own secret (webhook_secret, whsec_…, on the form object; rotate it in the dashboard). The header is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 with your secret over "<t>.<raw body>". Verify against the raw request body, compare in constant time, and reject timestamps more than 5 minutes old.
With the SDK, verifyWebhook resolves to the parsed payload and rejects if anything is wrong:
import express from "express";
import { verifyWebhook } from "@withdoorman/client";
// keep the body raw on this route
app.post("/hooks/doorman", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await verifyWebhook(req.body, req.get("Doorman-Signature"), process.env.DOORMAN_WEBHOOK_SECRET);
} catch {
return res.sendStatus(401);
}
if (event.held) { /* flag it for a human */ }
// event.submission.email, event.submission.message, event.evidence …
res.sendStatus(200);
});Without the SDK, it's a few lines:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyDoorman(rawBody, header, secret) {
const m = /t=(\d+),v1=([a-f0-9]+)/.exec(header || "");
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) throw new Error("bad or old signature");
const expected = createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest();
const got = Buffer.from(m[2], "hex");
if (got.length !== expected.length || !timingSafeEqual(got, expected)) throw new Error("signature mismatch");
return JSON.parse(String(rawBody));
}Slack & email #
Forwarding goes to every destination a form has, in parallel, with the same rules and retries as webhooks.
- Slack: paste a Slack incoming webhook URL. Each message shows the sender, the message quoted, and an “Open in Doorman” link. Held ones are marked with the chance it’s real and why.
- Email: one or more addresses. New accounts forward to the sign-up address by default. Reply-To is set to the sender, so you can just hit reply. Held ones have a
[Held]subject prefix and a banner with the evidence.
Webflow app #
Doorman for Webflow is a panel in the Webflow Designer that protects your site's forms without leaving Webflow.
- Open it: in the Designer, open the Apps panel and choose Doorman.
- Connect: press Connect Doorman. A withdoorman.com window opens; sign in or create a free account there and press Connect. You never type a password into the panel.
- Protect a form: the panel lists the forms on the current page. Press Protect this form: Doorman creates a matching form and sets your Webflow form's action to it, method POST. A form with a message box is screened as an enquiry; one with only short fields (an email, a name) as a sign-up or waitlist.
- Publish your site, then send a test from the live page. The panel shows Connected and the time of the last submission, and refreshes on its own (or press Check now).
What protecting a form changes
- Doorman takes over the form from Webflow. Protect sets the form's action (Send to → Custom action) to Doorman, so its submissions go to Doorman instead of Webflow. Webflow no longer processes or stores them (they won't appear in your site's Forms list), doesn't send its form notification emails for them, and anything triggered by Webflow form submissions stops firing for that form. Other forms are unchanged.
- Where submissions go: real ones are forwarded to your account's email address. Change this, or add Slack or a webhook, under Forms → your form in the Doorman dashboard. Every submission, with its verdict, is in your Doorman inbox.
- What visitors see: after submitting, visitors go to a short Doorman thank-you page (“Thanks, your message is on its way.”) with a link back to your site. They see the same page whatever the verdict, so nobody is told their message was judged spam. Webflow's own success message and redirect setting aren't used for a protected form. To send visitors to a page on your site instead, set Thank-you page under Forms → your form → More options in the Doorman dashboard.
- How it screens: forms start in enforce mode. Real submissions are delivered, clear pitches and bots are dropped (kept in your inbox, not forwarded), and unsure ones are held for you. By default, held submissions are also forwarded to you, marked “held”; turn off Send me held messages straight away to keep them in your inbox instead. One click delivers anything Doorman got wrong.
- Watch mode (optional): to check Doorman's calls first, set Protection to Watch only under Forms → your form → More options. Submissions Doorman would have dropped are delivered instead, tagged with what it would have done. Unsure ones are still held and follow the form's hold setting above. Watch mode stays on until you set Protection back to On yourself; it never switches on its own.
Limits and how long submissions are kept
- Screened submissions a month: 100 on Free, 5,000 on Pro, 50,000 on Business. Pitches and bots count. Past the limit nothing is dropped: submissions are held unjudged (and forwarded, if the form sends you held messages) until the month resets or you upgrade.
- Email forwards a month are counted separately: 100 on Free, 2,000 on Pro, 10,000 on Business. Past that, Doorman stops emailing until the month resets; submissions are still screened and kept in your inbox, and Slack and webhook forwards aren't limited.
- Retention: submissions are kept in Doorman for 30 days on Free, 180 days on Pro and 365 days on Business, then deleted automatically. What's already been forwarded to your email, Slack or webhook stays there. See Privacy.
What it sends and what it can do
- Once a form is protected, its submissions (what visitors type, such as their name, email and message) go to Doorman at withdoorman.com to be screened, and the real ones are forwarded to you.
- Connecting creates an API key called “Webflow app” on your account. It can only list, create and rename forms. It can't read submissions, change where they're sent, or do anything else. Revoke it any time under Settings → API keys, or press Disconnect in the panel.
- The panel adds no code to your site; it only changes the settings of forms you protect.
Removing it (in this order)
- In the panel, press Remove on every protected form. This clears the custom action, so Webflow handles the form again.
- Publish your site. Until you publish, the live forms still send to Doorman.
- Then uninstall the app, and if you like, delete the Doorman forms or your Doorman account.
If you skip a step: uninstalling the app doesn't change your forms. A protected form keeps sending its submissions to Doorman, which keeps screening and forwarding them while your account exists. If you delete a form's Doorman form (or your account) before restoring and publishing the Webflow form, its submissions can't be delivered anywhere: visitors who submit see a page saying their message wasn't sent, so nobody thinks it arrived, but the message is lost.
HubSpot #
Doorman for HubSpot keeps spam out of your HubSpot contacts without changing your forms. HubSpot takes each submission exactly as before; Doorman reads it within a minute, against what your business sells, and tags the contact.
The free scan
At withdoorman.com/hubspot, connect HubSpot with read-only access to your forms. Doorman reads up to 500 submissions from the last 90 days and reports how many were spam, what they cost you in marketing contacts, and examples with the line that gave each one away. Nothing in HubSpot changes, and the access isn't kept.
Turning on protection
- Press Turn on protection (on the scan report, the HubSpot page, or Dashboard → HubSpot) and approve HubSpot's consent screen. If you don't have a Doorman account yet, one is created from your HubSpot login, with 14 days of Pro.
- If HubSpot shows those permissions locked (Doorman is already connected from a scan), an admin enables them first: Settings → Integrations → Connected apps → Doorman.
- Doorman adds a Doorman group of contact properties and a Doorman: spam list to your HubSpot, and starts reading new submissions to all your forms (new forms included).
- Doorman then reads the 90 days before you connected (up to 500 submissions) and tags those contacts too, so the spam already in your HubSpot is in the list. It runs once, by itself.
Each submission's contact gets:
| Property | Internal name | Value |
|---|---|---|
| Doorman verdict | doorman_verdict | Real, Not sure or Spam (internal values real, held, spam) |
| Doorman reason | doorman_reason | Sales pitch, or Bot or template (spam only) |
| Doorman: the line that gave it away | doorman_line | The sentence the verdict rests on |
| Doorman: chance it's real (%) | doorman_score | 0 to 100 |
| Doorman checked at | doorman_checked_at | When the latest submission was read |
| Doorman link | doorman_link | The submission in Doorman, with its reasoning |
Spam contacts also join the Doorman: spam list. Doorman never deletes a contact. Rescue a submission in Doorman (or mark one spam) and the contact and list are updated to match. There's also a Doorman card for the contact record's sidebar: in a contact, choose Customize, add the card from the app card library, and save.
The workflow: notifications for real leads, non-marketing for spam
HubSpot sends a form's notification email the instant someone submits, before anything can judge it, and it counts every new contact as a marketing contact. One contact-based workflow fixes both:
- Trigger: Form submission on your forms.
- Delay 5 minutes. Doorman tags the contact within one.
- If/then branch: Doorman verdict is any of Spam.
- Yes: Set marketing contact status to non-marketing.
- No (Real, Not sure, or not checked yet): your internal notification or owner rotation.
Then switch off the form's own notification email (Marketing → Forms → your form → Options). Because the branch holds back only contacts tagged Spam, a real lead, an uncertain one, or one Doorman couldn't check still reaches your team.
For the spam the clean-up tagged, open the Doorman: spam list, select all, and choose Set as non-marketing. HubSpot applies marketing-status changes at your next billing update.
Usage, pausing and disconnecting
Each HubSpot submission Doorman reads is one check, the same as any form. Pause from Dashboard → HubSpot to stop reading new submissions (contacts keep their tags). Disconnect revokes Doorman's access in HubSpot; uninstalling the app in HubSpot (Settings → Integrations → Connected apps) does the same, and Doorman stops within a minute.
Zapier, Make & Google Sheets #
Anything that can receive a webhook can receive Doorman's real submissions, already screened. Put its URL in the form's Webhook URL (Forms → your form → Destinations). Only delivered submissions are sent (and held ones, if the form forwards holds), so pitches never reach your CRM or sheet.
- Zapier: new Zap → trigger Webhooks by Zapier → Catch Hook. Copy the URL Zapier gives you into Doorman, press Send a test delivery, then map
submission.email,submission.name,submission.messageandsubmission.fieldsto the next step (HubSpot, Pipedrive, Notion, Airtable…). - Make: new scenario → Webhooks → Custom webhook → Add. Copy the address into Doorman and send a test delivery so Make learns the fields.
- Google Sheets: open the sheet → Extensions → Apps Script, replace the code with the script below, then Deploy → New deployment → Web app, with Execute as: Me and Who has access: Anyone. Copy the web app URL (it starts
https://script.google.com/macros/) into Doorman. Each real submission becomes a row.
// Doorman → Google Sheets: one row per real submission
function doPost(e) {
const p = JSON.parse(e.postData.contents), s = p.submission || {};
SpreadsheetApp.getActiveSheet().appendRow([
p.created_at, p.held ? "held" : "delivered", s.name || "", s.email || "",
s.message || "", JSON.stringify(s.fields || {}), p.dashboard_url,
]);
return ContentService.createTextOutput("ok");
}The payload is the same as any webhook; these tools skip the signature check, which is fine for a sheet or a CRM. If your own server receives it, verify the signature.
File uploads: Doorman doesn't keep files. A submission that had one notes the file's name and size, so you know to ask the sender for it.
Watch mode #
Set a form's mode to watch to try Doorman on real traffic without risk. Everything is judged and stored exactly as in enforce mode, but anything that would have been dropped is forwarded as a normal submission.delivered. Its route still says drop and its action says deliver, so you can see in the dashboard and the API what Doorman would have done.
Holds behave as usual in watch mode. When the drops look right, switch to enforce.
Plans, retention & failure #
| Plan | Price | Forms | Submissions / month | Email forwards / month | Submissions kept |
|---|---|---|---|---|---|
| Unconfirmed account | $0 | As your plan | 100 | 0 | 30 days |
| Free | $0 | 1 | 100 | 100 | 30 days |
| Pro | $29 / month or $290 / year | 10 | 5,000 | 2,000 | 180 days |
| Business | $79 / month or $790 / year | 50 | 50,000 | 10,000 | 365 days |
Every new account starts with a 14-day Pro trial, no card (30 days from a referral link), then moves to Free unless it upgrades. Until November 30, 2026, the first 100 paying customers get Pro at $19 a month or Business at $59, kept while they stay subscribed. Pricing.
A check is one judged submission, from a hosted form or /v1/check; pitches and bots count. Tests, list matches and flood-limited submissions don't count. Months are calendar months in UTC. You get an email at 80% and 100%. Submissions older than your plan's retention are deleted automatically.
Doorman fails open, into the hold lane, never into drop:
| Situation | What happens |
|---|---|
| Over quota | Not judged. Stored as hold, reason: "over_quota", p_real: null, and forwarded with the Held flag. Nothing is dropped because you forgot to upgrade. |
| Model error or timeout | Doorman retries once, then holds the submission with reason: "doorman_error" and forwards it flagged. A person still sees it. |
| Flood from one IP | Held with reason: "rate_limited", not judged and not forwarded; waits in the review queue. See floods. |
| Your backend can't reach Doorman | The SDK returns route: "hold", reason: "doorman_unreachable" instead of throwing. Without the SDK, treat timeouts as hold yourself. |
| A destination is down | Retried for about 15 hours. |
| Email-forward limit reached | Slack and webhooks still deliver; email resumes next month or on upgrade. The submission is in your inbox either way. |
| Forwarding address not confirmed | Not emailed until its owner clicks the confirmation Doorman sends them (once). Your own account email counts as confirmed once you confirm it. |
If forwarding holds is turned off on a form, held submissions still wait in the dashboard's review queue; they just aren't pushed to you.
Limits
| What | Limit |
|---|---|
| Hosted form posts | 30 a minute per IP per form, 300 a minute per form; beyond that, stored as held without judging |
| API requests | 600 a minute per account |
| Test submissions | 100 a day per account (free, not counted as checks) |
| Sign-ups | 5 an hour per IP (web, API and MCP) |
| Webhook and Slack URLs | Public http(s) only; Slack must be https://hooks.slack.com/…; redirects aren't followed |
| Forms | 50 per account |
Live service status and 90-day uptime: /status (JSON at /status.json).
Passwords & your data #
- Set a password for an agent-made account from the link in the welcome email (valid 7 days).
- Forgot it? Use /app#/forgot. Reset links are valid for an hour, and using one signs out every other session.
- API keys are shown once when created, stored only as hashes, and can be revoked in Settings.
- Export everything Doorman holds about your account as JSON, or delete the account and all its submissions, from Settings.
Submissions are sent to our machine-learning provider only to be judged and aren't used for training. See the privacy policy.
Node SDK #
@withdoorman/client is a zero-dependency wrapper for Node 20+, Bun, Deno, Cloudflare Workers and edge runtimes, with TypeScript types.
npm install @withdoorman/clientimport { Doorman, verifyWebhook } from "@withdoorman/client";
const doorman = new Doorman(process.env.DOORMAN_KEY, {
baseUrl: "https://doorman.example",
});
// same body and response as POST /v1/check; never throws on an outage
const r = await doorman.check({ form: "f_yourFormId", email, name, message });
if (r.action !== "drop") await saveLead({ email, message, held: r.action === "hold" });
return res.json({ ok: true }); // same answer for everyone
// a person said it was real after all
await doorman.label(r.id, "real");| API | Does |
|---|---|
new Doorman(key?, opts?) | key defaults to DOORMAN_KEY. Options: baseUrl (default DOORMAN_URL, else https://withdoorman.com), timeoutMs (5000), failOpen (true), fetch. |
check(body) | POST /v1/check. With failOpen, a network error, timeout, 5xx or 429 resolves to { route: "hold", action: "hold", reason: "doorman_unreachable", id: null }. Auth and validation errors still throw DoormanError. |
label(id, "real" | "spam") | POST /v1/submissions/:id/label. |
get(id) · list(query) · forms() | The submission and form endpoints. |
await verifyWebhook(rawBody, header, secret) | Named export, async (Web Crypto). Checks Doorman-Signature and the 5-minute window, resolves to the parsed payload, rejects with DoormanError otherwise. |
Act on r.action rather than r.route: it respects watch mode and labels.
Hosted, or build it yourself #
Doorman is a hosted service: you point a form at it, or call one API, and it's running. There is nothing to deploy, and no model key, server or mail setup of your own.
| With hosted Doorman | What that saves you |
|---|---|
| Live in minutes | Change one form URL, or paste one prompt into Claude Code, Codex or Cursor and it sets everything up. Typed SDK, MCP server and ready-made snippets for HTML, React, Next.js, Webflow, Framer, WordPress, Python, Ruby and Elixir. |
| Managed for you | Uptime checks every minute and a public status page, delivery retries from 1 minute to 12 hours, signed webhooks, authenticated email, backups, rate limiting and abuse controls, and updates without you lifting a finger. |
| Intelligence built in | Questions written and tested for each kind of form (contact, demo and quote forms first), the sentence behind every decision, thresholds per form, watch mode, allow and block lists. Improvements ship to every account. |
| Set it and forget it | Fails open: an outage or an over-quota month holds submissions instead of dropping them. An inbox to review, rescue and label them, and the same answer for every sender, so bots learn nothing. |
| Clear prices, models included | Free for 100 submissions a month, Pro $29, Business $79, with 14 days of Pro to start. The model costs are in the price, and every plan gets the same models. Pricing. |
Rather build it yourself?
The core idea is open source. github.com/0x4rch/doorman (MIT) is the reference implementation: how each submission becomes one request of typed questions to Jev, how the answers route to deliver, hold or drop, and how the evidence sentence is picked. It includes a demo UI, a command-line judge, 312 labelled test submissions, a public benchmark (0.992 precision on 1,956 human-labelled comments) and a Cloudflare Pages Function example. It runs in mock mode without a key.
Building on it, you bring your own TypeSafe API key, hosting, storage, email delivery and monitoring, and keep the questions tuned as spam changes. If that's you, go for it, and tell me what you build: I'm happy to help.