docs
stormdoor.dev
GATE

/v1/pii

Scans free text for personal data — email addresses, phone numbers, IBANs, payment cards, SE/NO/FI/DK/DE/NL/US/GB national IDs, and US routing numbers.

METHOD
POST
BODY CAP
64 KiB
CHECKS
13
FINDING CODES
13

POST /v1/pii scans a block of free text — a support ticket, a form field, anything you’re about to store or forward — for personal data before it lands somewhere it shouldn’t.

Request#

{ "text": "Reach me at +46 70 123 45 67 or jane@example.com", "redact": "mask" }
Field Type Required Notes
text string yes Bounded only by this gate’s 64 KB request-body cap (D-80) — no separate limit here. That’s roughly 60,000 characters of English text; fewer for text with accents or emoji.
redact string no Only "mask" is accepted; anything else is a 400 invalid_request. See “Redaction” below.

Checks this gate runs#

Every check here is pure rule-based text scanning — regexes and checksums over the text you sent, no network calls, no model inference — so all thirteen are free on every plan (CLAUDE.md design rule 4: “Never withhold a zero-marginal-cost check”).

Check What it does
pii.email_address Free-text email addresses — see pii.email_address.
pii.phone_number Phone numbers written with a + country code — see pii.phone_number.
pii.iban IBANs, mod-97 checksum plus the issuing country’s fixed length — see pii.iban.
pii.payment_card Card numbers, Luhn checksum plus a real issuer’s BIN prefix — see pii.payment_card.
pii.national_id_se Swedish personnummer / samordningsnummer — see pii.national_id_se.
pii.national_id_no Norwegian fødselsnummer / D-number — see pii.national_id_no.
pii.national_id_fi Finnish henkilötunnus — see pii.national_id_fi.
pii.national_id_dk Danish CPR number — see pii.national_id_dk.
pii.national_id_de German Steuer-ID / IdNr — see pii.national_id_de.
pii.national_id_nl Dutch BSN (burgerservicenummer) — see pii.national_id_nl.
pii.national_id_us US Social Security number — see pii.national_id_us.
pii.bank_routing_us US ABA routing number, next to a routing keyword — see pii.bank_routing_us.
pii.national_id_gb UK National Insurance number — see pii.national_id_gb.

What this gate does not cover#

  • Free-text names, postal addresses, or health details. Recognising those reliably needs a model, not a regex — that’s a later paid check, and this gate doesn’t claim to catch them.
  • Bare Danish, German, or Dutch numbers, a bare undashed US SSN, or a routing number with no keyword. CPR, Steuer-ID and BSN each have a weak or absent checksum on their own (Denmark dropped its check digit in 2007; a random 9-digit number passes the Dutch 11-test one time in 11), so those three checks also require a recognisable keyword — CPR, Steuer-ID/IdNr, BSN/burgerservicenummer, SSN/social security or routing/ABA/RTN — within about 40 characters of the number. A bare number with no such label nearby will not fire.

Overlapping matches#

If two checks match overlapping spans of the same text (rare, but possible — e.g. a payment card’s digits partially overlapping a phone number’s), only the more specific match is kept; the response never double-counts the same characters as two findings.

Redaction#

redact: "mask" adds data.redacted to the response: your input text with every match replaced by *, one asterisk per character, so offsets still line up. It’s the only redaction mode — see the response format docs for why hashing, tokenising and removal are all cut rather than offered.

Matches, truncation and offsets#

Every match also appears in data.matches as { "code", "rule", "start", "end" } — useful if you want to act on the pattern name without parsing a finding’s message. start/end are Unicode code-point offsets, not bytes — the right choice for every JS and Python caller, wrong only for code that indexes Go strings by byte. A scan is capped at 100 matches; past that, data.truncated: true is set and only the first 100 (by position) are returned.

Example#

curl -s -X POST https://api.stormdoor.dev/v1/pii \
  -H "Authorization: Bearer sk_live_..." \
  -H "content-type: application/json" \
  -d '{"text":"Contact jane@example.com or +46 70 123 45 67","redact":"mask"}'
{
  "ok": true,
  "verdict": "suspect",
  "findings": [
    { "code": "pii.email_address", "severity": "low", "message": "email address detected", "location": "8-24" },
    { "code": "pii.phone_number", "severity": "low", "message": "phone number detected", "location": "28-47" }
  ],
  "data": {
    "matches": [
      { "code": "pii.email_address", "rule": "email_address", "start": 8, "end": 24 },
      { "code": "pii.phone_number", "rule": "phone_number", "start": 28, "end": 47 }
    ],
    "redacted": "Contact ***************** or *******************"
  },
  "checks_run": ["pii.email_address", "pii.iban", "pii.national_id_de", "pii.national_id_dk", "pii.national_id_fi", "pii.national_id_no", "pii.national_id_nl", "pii.national_id_se", "pii.payment_card", "pii.phone_number"],
  "checks_skipped": [],
  "meta": { "gate": "pii", "ruleset": "reference-..." }
}

See response format for how checks_skipped reasons work, and don’t proxy that array — or the raw match offsets — to your own end users.

Finding codes#

Each code links to its own page with what it looks at, why it matters and what to do.

pii.bank_routing_usmediumpii.bank_routing_ussuspect
pii.email_addresslowpii.email_addresssuspect
pii.ibanmediumpii.ibansuspect
pii.national_id_dehighpii.national_id_deblock
pii.national_id_dkhighpii.national_id_dkblock
pii.national_id_fihighpii.national_id_fiblock
pii.national_id_gbhighpii.national_id_gbblock
pii.national_id_nlhighpii.national_id_nlblock
pii.national_id_nohighpii.national_id_noblock
pii.national_id_sehighpii.national_id_seblock
pii.national_id_ushighpii.national_id_usblock
pii.payment_cardhighpii.payment_cardblock
pii.phone_numberlowpii.phone_numbersuspect