Doorman docs

Doorman is machine-learning spam, fraud and abuse protection for your web forms: agency pitches, bots, fake sign-ups, card testing and abusive comments. 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 200 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.

  1. 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.
  2. Copy the form's action URL from the dashboard into your form:
    <form action="https://doorman.example/f/f_yourFormId" method="POST">
  3. Done. Real submissions arrive where you pointed them (email, Slack or webhook). Pitches and bots land in the dashboard with the sentence that gave them away. Optionally add the snippet for timing and honeypot signals.

Not sure yet? Turn on watch mode first: everything is judged, nothing is withheld.

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 run curl can follow it.
  • /mcp: a remote MCP server (Streamable HTTP, JSON-RPC 2.0, plain JSON responses). Protocol versions 2025-06-18, 2025-03-26 and 2024-11-05.

A focused walkthrough for people is at Set up Doorman with your agent.

Install the MCP server

Claude Code:

claude mcp add --transport http doorman https://doorman.example/mcp

Once you have a key, you can add it to the connection so tools don't need it per call:

claude mcp add --transport http doorman https://doorman.example/mcp \
  --header "Authorization: Bearer $DOORMAN_KEY"

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.

MCP tools

ToolArgumentsDoes
doorman_docsnoneReturns the same text as /llms.txt. Read-only.
doorman_signupemail, site_url, site_context (required), form_nameCreates 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_whoamiAccount, plan and this month's usage. Read-only.
doorman_list_formsAll forms with action URLs and settings. Read-only.
doorman_create_formname (required), kind, site_context, email_to, webhook_url, slack_url, redirect_urlAnother form endpoint, one per distinct form on the site.
doorman_update_formform_id (required) and any form setting, including mode, thresholds, allow_list, block_listChanges a form's settings.
doorman_get_install_snippetform_id, platform (required)Ready-to-paste code. Platforms as in install snippets. Read-only.
doorman_test_submissionform_id, message (required), emailJudges a sample. Free, not forwarded, not counted.
doorman_send_test_deliveryform_id (required)Sends a sample to the form's destinations and reports each result.
doorman_list_submissionsform_id, route, review, limit (default 20)Recent submissions with verdicts; review: true for held ones waiting for a person. Read-only.
doorman_label_submissionsubmission_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 #

POST

/f/:formId

no key

CORS 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.

RoleField names
emailemail e-mail email_address your_email from
namename full_name fullname your_name first_name firstname
messagemessage msg body comment comments details inquiry enquiry question note notes description text content how_can_we_help project
fieldsEverything 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

FieldWhat it does
_nextWhere 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.
_tPage-load time in Unix milliseconds. Becomes seconds_on_page and implies JavaScript ran. The snippet sets it.
_js1 if JavaScript ran. The snippet sets it.
_gotcha _hp _honeypotHoneypots. 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: 303 redirect to _next, else the form's redirect URL, else https://doorman.example/thanks.
  • JSON: if the request's Accept header contains json, or it sends any X-Requested-With header, the answer is 200 {"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 / APIEffect
data-doormanArm 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-ajaxSubmit 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-successThe 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
POST

/v1/signup

no key

Create an account, its first form and an API key in one call. Made for agents and scripts; people can use /signup.

FieldDescription
emailstring, requiredThe owner's email. Real submissions are forwarded here by default.
site_urlstringThe site the form is on, e.g. example.com. Used to name the form.
site_contextstringOne or two concrete sentences: what is sold, to whom. Every submission is judged against it.
form_namestringOptional, e.g. "Demo request".
agentstringOptional label for the key, e.g. "claude-code".
passwordstringOptional, 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. 409 if the email already has an account; 429 after 5 sign-ups an hour from one IP.

POST

/v1/check

Judge one submission and get a route back. Counts as one check.

FieldDescription
formstringForm id (f_…) on your account. Doorman uses that form's kind, business description, thresholds, mode and lists. Unknown or someone else's: 404.
kindstringlead (default), signup, checkout or comment. See Kinds. Overrides the form's kind if both are given.
contextstringWithout form only: a sentence about the business, e.g. "A bakery in Leeds that takes wedding cake orders."
emailstringSender's email. Any name from field-name detection works too.
namestringSender's name.
messagestringThe free text. This is what Doorman mostly reads.
fieldsobjectAny other fields, { "company": "Acme" }. Other unrecognised top-level keys land here too.
signalsobjectseconds_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.
orderobjectCheckout only: { "items": "2× Pro plan", "total_usd": 58 }.
forwardbooleanWith form only. true also sends the submission to the form's destinations according to its route, exactly like a hosted post. Default false: you get the answer and act on it yourself.

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",
  "route": "drop",
  "action": "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"
}
FieldMeaning
idSubmission id, sub_….
formForm id, or null for a form-less check.
created_atISO 8601 timestamp.
routeDoorman's judgment: deliver, hold or drop.
actionWhat actually happens to it. Usually equals route; differs in watch mode (a drop becomes deliver) and after a label. Act on this one.
p_realProbability it's genuine, 0–1. null when it wasn't judged (lists, quota, errors, floods).
reasonFor 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.
evidenceThe 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.
modelThe model version that judged it, or a mock label on a self-hosted server without a model key.
latency_msModel round trip in milliseconds; 0 when not judged.
labelreal, spam or null: a person's verdict.
errorPresent only when judging was skipped or failed.
emailThe sender email Doorman detected.

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).

GET

/v1/me

The account: id, email, plan (free, pro, business), plan_name, usage ({ month, checks, limit }), retention_days and plans (every plan's price and checks).

Forms

GET

/v1/forms

All forms, as { "data": [ … ] }, each with submissions (count) and last_at.

POST

/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." }
GET

/v1/forms/:id

PATCH

/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"] }'
DELETE

/v1/forms/:id

Deletes the form. Its endpoint stops accepting posts (404).

Form settings

FieldDescription
nameUp to 80 characters.
kindlead, signup, checkout or comment. See Kinds.
site_contextWhat the business sells and to whom. Up to 1,000 characters.
modeenforce (default) or watch.
deliver_at drop_belowThresholds, 0–1, defaults 0.8 and 0.2. drop_below can't be above deliver_at.
forward_holdsForward held submissions, flagged (default true).
email_toEmails separated by commas or spaces, up to 300 characters.
slack_url webhook_urlFull https:// URLs. Empty string removes one.
redirect_urlWhere visitors land after a plain form post (default /thanks).
allow_list block_listArrays (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. Responses include the webhook secret, so treat them like the key.

Test & test delivery

POST

/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
POST

/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": 143 } ] }, with error on failures.

GET

/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

GET

/v1/submissions

Recent decisions, newest first, as { "data": [ … ], "next_before": "…" } with the same objects as /v1/check. Test submissions are excluded.

QueryDescription
formOnly this form id.
routedeliver, hold or drop: filter on the judged route.
reviewtrue: only held submissions nobody has labelled yet (the review queue).
beforeUnix milliseconds or an ISO timestamp; only submissions created before it. To page, pass the previous response's next_before.
limit1–200, default 50.
curl "https://doorman.example/v1/submissions?review=true&limit=20" \
  -H "Authorization: Bearer $DOORMAN_KEY"
GET

/v1/submissions/:id

One submission. 404 if it doesn't exist or isn't yours.

POST

/v1/submissions/:id/label

Tell Doorman what a submission really was. Body: { "label": "real" } or { "label": "spam" }. Returns the updated submission.

  • real sets action to deliver. If it was never forwarded and it came from a hosted form (or an API check with forward: true) whose form has destinations, it's forwarded now as submission.delivered. This is the “rescue” button in the dashboard.
  • spam sets action to drop and cancels any pending delivery retries. Nothing already sent is recalled.

Errors & limits

Errors are JSON with a human-readable message: { "error": "…" }.

StatusWhen
400Body isn't valid JSON, or isn't a JSON object.
401Missing, malformed or revoked API key.
404Unknown endpoint, form or submission.
409Sign-up for an email that already has an account.
413Body too large (256 KB for the API, 1 MB for hosted forms).
422A value is invalid, e.g. a bad URL, threshold or label.
429Over a limit: 600 API requests a minute per account, or 5 sign-ups an hour per IP.
500Our 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:

deliverp_real ≥ deliver_at (default 0.8). Forwarded to your destinations.
holdIn between. Forwarded with a “Held” flag if the form forwards holds (on by default), and queued for review.
dropp_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: ….

KindForGenuine meansStops (reason)
leadContact, demo, quote or sales formsA person describing their own need and asking about what you offer.Sales pitches (pitch), templates and bots (automated)
signupAccount sign-ups and waitlistsA 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
commentPublic comments, reviews, postsA 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
checkoutCheckouts and ordersA 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".

SignalDescription
card_countryCard's issuing country.
ip_countryCountry of the buyer's IP (falls back to country).
attemptsPayment attempts before success. Default 1.
device_orders_last_hourOrders from this device in the last hour.
account_age_daysAge of the buyer's account.
name_matchfalse if the billing name doesn't match the card.
card_typee.g. "prepaid". Default "standard".
shippinge.g. "freight forwarder". Default "none, digital".
seconds_on_pageTime 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.

EntryMatches
jess@acme.comThat address only.
acme.com or @acme.comAny address at acme.com.
*.acme.comAny 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 with held: 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 "doorman-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 P(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.

On a self-hosted server, email needs RESEND_API_KEY (see Self-hosting); without it, email attempts are logged as not configured (and not retried) while webhook and Slack still work.

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 #

PlanPriceChecks / monthEmail forwards / monthSubmissions kept
Unconfirmed account$0100030 days
Free$01,00030030 days
Pro$12 / month25,0005,000180 days
Business$49 / month250,00020,000365 days

A check is one judged submission, from a hosted form or /v1/check. 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:

SituationWhat happens
Over quotaNot 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 timeoutDoorman retries once, then holds the submission with reason: "doorman_error" and forwards it flagged. A person still sees it.
Flood from one IPHeld with reason: "rate_limited", not judged and not forwarded; waits in the review queue. See floods.
Your backend can't reach DoormanThe SDK returns route: "hold", reason: "doorman_unreachable" instead of throwing. Without the SDK, treat timeouts as hold yourself.
A destination is downRetried for about 15 hours.
Email-forward limit reachedSlack and webhooks still deliver; email resumes next month or on upgrade. The submission is in your inbox either way.
Forwarding address not confirmedNot 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

WhatLimit
Hosted form posts30 a minute per IP per form, 300 a minute per form; beyond that, stored as held without judging
API requests600 a minute per account
Test submissions100 a day per account (free, not counted as checks)
Sign-ups5 an hour per IP (web, API and MCP)
Webhook and Slack URLsPublic http(s) only; Slack must be https://hooks.slack.com/…; redirects aren't followed
Forms50 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 #

doorman-client is a zero-dependency wrapper for Node 20+, Bun, Deno, Cloudflare Workers and edge runtimes, with TypeScript types.

npm install doorman-client
import { Doorman, verifyWebhook } from "doorman-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");
APIDoes
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.

Self-hosting #

Doorman is one Node process and one SQLite file, with zero npm dependencies, MIT licensed. It needs Node 22.13 or newer (for the built-in node:sqlite).

cp .env.example .env    # everything is optional for local dev
npm start               # → Doorman on http://localhost:3000
npm test

With no TypeSafe key, judging runs in mock mode: cheap heuristics with the same response shape, and a mock model name on every decision so nobody mistakes it for the real thing. GET /health reports the mode. A maintenance loop runs every minute for delivery retries and retention.

VariableDefaultPurpose
TYPESAFE_API_KEYemptyJev key from typesafe.ai. Live judging when set.
JEV_MODELjev-latestOr pin a version, e.g. jev-1.13.0.
DOORMAN_MODEautoauto, live or mock. live without a key refuses to start.
PORT3000HTTP port.
BASE_URLhttp://localhost:PORTPublic URL, used in action URLs, webhooks, emails, llms.txt and the default redirect. An https:// URL also turns on secure cookies and HSTS.
DATABASE_PATHdata/doorman.dbSQLite file. Put it on a persistent volume.
RESEND_API_KEYemptyEnables email (forwarding, welcome, password reset, usage warnings) through Resend. Without it, emails are printed to the log.
EMAIL_FROMDoorman <forms@withdoorman.com>Sender address.
STRIPE_LINK_PRO
STRIPE_LINK_BUSINESS
emptyStripe Payment Links (metadata plan=pro|business). Without them “Upgrade” opens an email to SUPPORT_EMAIL.
STRIPE_PORTAL_URLemptyStripe customer portal link for managing a subscription.
STRIPE_WEBHOOK_SECRETemptySigning secret for POST /stripe/webhook, which applies plan changes.
SUPPORT_EMAILhello@withdoorman.comWhere upgrade requests go.
ADMIN_EMAILSemptyComma-separated accounts that see the admin view.
ALERT_WEBHOOK_URLemptySlack-compatible webhook for 500s and Jev failures.

Real environment variables win over .env.

Docker

There are no dependencies to install, so a Dockerfile is four lines:

FROM node:22-alpine
WORKDIR /app
COPY . .
CMD ["node", "server.js"]

Add .env, data/ and .git to a .dockerignore so secrets and your local database stay out of the image.

docker build -t doorman .
docker run -p 3000:3000 -v doorman-data:/data \
  -e DATABASE_PATH=/data/doorman.db \
  -e BASE_URL=https://forms.yourdomain.com \
  -e TYPESAFE_API_KEY=… \
  doorman

Behind a proxy, Doorman reads the client IP from CF-Connecting-IP, Fly-Client-IP or X-Forwarded-For, and the country from CF-IPCountry.