Personal data redaction

Keep the message, lose the phone number

People paste their phone, their bank account or a card number into a comment, a listing or a support chat without thinking. ToxicFilter finds them, checks the numbers by their check digit, and with redact on hands back the same text with each one masked, so you publish what they wrote and not what they should have kept.

How it works

  1. The text is read as it was sent

    Phones, email addresses and numbers are matched on the raw text, never on a normalised copy, so every finding knows exactly where it starts and how long it is.

  2. Numbers have to check out

    An IBAN must pass mod-97, a card must pass Luhn and start like a real issuer's, a DNI or NIE must carry the right letter. A sixteen-digit order reference is not a card, and is not reported as one.

  3. The text comes back masked

    With redact: true the answer carries redacted: your text with each finding replaced by [redacted]. Overlapping findings are merged first and replaced from right to left, so no half of a number survives.

  4. Your rules decide the rest

    By default personal data is held for review and a card number is refused. A policy can move those lines: a dating site may refuse any phone, a support desk may only want the mask.

See it decide

  1. 01 A phone and an email in a support chat
  2. 02 An email written to get past a filter
  3. 03 A bank account in a listing
  4. 04 A card number in a comment
  5. 05 A long number that is not a card

A phone and an email in a support chat POST /v1/text with redact: true

My parcel never arrived. Call me on +44 7700 900123 or write to ana.lopez@example.com, I am home all week.

review 8 ms
  • Contains an email address.
  • Contains what looks like a phone number.

Held rather than refused, and the message comes back with both masked. The complaint about the parcel is still there for whoever answers it.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "personal_data"
  ],
  "scores": {
    "personal_data": 0.6
  },
  "signals": [
    {
      "category": "personal_data",
      "score": 0.6,
      "reason": "Contains an email address.",
      "evidence": [
        "ana.lopez@example.com"
      ]
    },
    {
      "category": "personal_data",
      "score": 0.5,
      "reason": "Contains what looks like a phone number.",
      "evidence": [
        "447•••••••23"
      ]
    }
  ],
  "redacted": "My parcel never arrived. Call me on [redacted] or write to [redacted], I am home all week.",
  "model": {
    "read": false
  },
  "took_ms": 8
}

An email written to get past a filter POST /v1/text with redact: true

Not allowed to post emails here, so: ana.lopez (at) example (dot) com. Send me the photos there.

review 7 ms
  • Domain written with its dot spelled out or bracketed, which only makes sense to dodge a filter.
  • Contains an email address.
  • Email address written as "name at domain dot com" to get past a filter.

Written with (at) and (dot) precisely because a plain address gets stripped. It is still found, still masked, and the disguise is reported on its own as evasion.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "evasion",
    "personal_data"
  ],
  "scores": {
    "evasion": 0.7,
    "personal_data": 0.6
  },
  "signals": [
    {
      "category": "evasion",
      "score": 0.65,
      "reason": "Domain written with its dot spelled out or bracketed, which only makes sense to dodge a filter.",
      "evidence": [
        "example (dot) com"
      ]
    },
    {
      "category": "personal_data",
      "score": 0.6,
      "reason": "Contains an email address.",
      "evidence": [
        "ana.lopez (at) example (dot) com"
      ]
    },
    {
      "category": "evasion",
      "score": 0.7,
      "reason": "Email address written as \"name at domain dot com\" to get past a filter.",
      "evidence": [
        "ana.lopez (at) example (dot) com"
      ]
    }
  ],
  "redacted": "Not allowed to post emails here, so: [redacted]. Send me the photos there.",
  "model": {
    "read": false
  },
  "took_ms": 7
}

A bank account in a listing POST /v1/text with redact: true

Room in a shared flat, 450 a month. To book it, send the deposit to ES91 2100 0418 4502 0005 1332.

review 7 ms
  • Contains 1 bank account number(s) (IBAN). Publishing one invites fraud against whoever owns it.

The IBAN passes mod-97, so it is a bank account and not a string of digits. The listing is held and comes back with the account masked.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "personal_data"
  ],
  "scores": {
    "personal_data": 0.9
  },
  "signals": [
    {
      "category": "personal_data",
      "score": 0.9,
      "reason": "Contains 1 bank account number(s) (IBAN). Publishing one invites fraud against whoever owns it.",
      "evidence": [
        "ES••••••••••••••••••1332"
      ]
    }
  ],
  "redacted": "Room in a shared flat, 450 a month. To book it, send the deposit to [redacted].",
  "model": {
    "read": false
  },
  "took_ms": 7
}

A card number in a comment POST /v1/text with redact: true

Can someone check why this card keeps failing? 4111 1111 1111 1111, expiry 09/28.

block 7 ms
  • Contains 1 payment card number(s). This one is not a guess: the digits check out.

It passes Luhn and starts like a real issuer's card, so it is not a guess, and by default it is the one personal data finding that is refused. The evidence shows only the last four digits.

The answer, abridged
{
  "decision": "block",
  "flagged": [
    "personal_data"
  ],
  "scores": {
    "personal_data": 0.95
  },
  "signals": [
    {
      "category": "personal_data",
      "score": 0.95,
      "reason": "Contains 1 payment card number(s). This one is not a guess: the digits check out.",
      "evidence": [
        "••••••••••••1111"
      ]
    }
  ],
  "redacted": "Can someone check why this card keeps failing? [redacted], expiry [redacted].",
  "model": {
    "read": false
  },
  "took_ms": 7
}

A long number that is not a card POST /v1/text

Still waiting on order 4000 1234 5678 9011, placed last Monday. Any news?

allow 8 ms

Sixteen digits, but they fail the checksum, so nothing is reported. Without the check digit this would fire on every support ticket and be switched off in a week.

The answer, abridged
{
  "decision": "allow",
  "flagged": [],
  "signals": [],
  "model": {
    "read": false
  },
  "took_ms": 8
}

See it decide

A phone and an email in a support chat POST /v1/text with redact: true

My parcel never arrived. Call me on +44 7700 900123 or write to ana.lopez@example.com, I am home all week.

review 8 ms
  • Contains an email address.
  • Contains what looks like a phone number.

Held rather than refused, and the message comes back with both masked. The complaint about the parcel is still there for whoever answers it.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "personal_data"
  ],
  "scores": {
    "personal_data": 0.6
  },
  "signals": [
    {
      "category": "personal_data",
      "score": 0.6,
      "reason": "Contains an email address.",
      "evidence": [
        "ana.lopez@example.com"
      ]
    },
    {
      "category": "personal_data",
      "score": 0.5,
      "reason": "Contains what looks like a phone number.",
      "evidence": [
        "447•••••••23"
      ]
    }
  ],
  "redacted": "My parcel never arrived. Call me on [redacted] or write to [redacted], I am home all week.",
  "model": {
    "read": false
  },
  "took_ms": 8
}

An email written to get past a filter POST /v1/text with redact: true

Not allowed to post emails here, so: ana.lopez (at) example (dot) com. Send me the photos there.

review 7 ms
  • Domain written with its dot spelled out or bracketed, which only makes sense to dodge a filter.
  • Contains an email address.
  • Email address written as "name at domain dot com" to get past a filter.

Written with (at) and (dot) precisely because a plain address gets stripped. It is still found, still masked, and the disguise is reported on its own as evasion.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "evasion",
    "personal_data"
  ],
  "scores": {
    "evasion": 0.7,
    "personal_data": 0.6
  },
  "signals": [
    {
      "category": "evasion",
      "score": 0.65,
      "reason": "Domain written with its dot spelled out or bracketed, which only makes sense to dodge a filter.",
      "evidence": [
        "example (dot) com"
      ]
    },
    {
      "category": "personal_data",
      "score": 0.6,
      "reason": "Contains an email address.",
      "evidence": [
        "ana.lopez (at) example (dot) com"
      ]
    },
    {
      "category": "evasion",
      "score": 0.7,
      "reason": "Email address written as \"name at domain dot com\" to get past a filter.",
      "evidence": [
        "ana.lopez (at) example (dot) com"
      ]
    }
  ],
  "redacted": "Not allowed to post emails here, so: [redacted]. Send me the photos there.",
  "model": {
    "read": false
  },
  "took_ms": 7
}

A bank account in a listing POST /v1/text with redact: true

Room in a shared flat, 450 a month. To book it, send the deposit to ES91 2100 0418 4502 0005 1332.

review 7 ms
  • Contains 1 bank account number(s) (IBAN). Publishing one invites fraud against whoever owns it.

The IBAN passes mod-97, so it is a bank account and not a string of digits. The listing is held and comes back with the account masked.

The answer, abridged
{
  "decision": "review",
  "flagged": [
    "personal_data"
  ],
  "scores": {
    "personal_data": 0.9
  },
  "signals": [
    {
      "category": "personal_data",
      "score": 0.9,
      "reason": "Contains 1 bank account number(s) (IBAN). Publishing one invites fraud against whoever owns it.",
      "evidence": [
        "ES••••••••••••••••••1332"
      ]
    }
  ],
  "redacted": "Room in a shared flat, 450 a month. To book it, send the deposit to [redacted].",
  "model": {
    "read": false
  },
  "took_ms": 7
}

A card number in a comment POST /v1/text with redact: true

Can someone check why this card keeps failing? 4111 1111 1111 1111, expiry 09/28.

block 7 ms
  • Contains 1 payment card number(s). This one is not a guess: the digits check out.

It passes Luhn and starts like a real issuer's card, so it is not a guess, and by default it is the one personal data finding that is refused. The evidence shows only the last four digits.

The answer, abridged
{
  "decision": "block",
  "flagged": [
    "personal_data"
  ],
  "scores": {
    "personal_data": 0.95
  },
  "signals": [
    {
      "category": "personal_data",
      "score": 0.95,
      "reason": "Contains 1 payment card number(s). This one is not a guess: the digits check out.",
      "evidence": [
        "••••••••••••1111"
      ]
    }
  ],
  "redacted": "Can someone check why this card keeps failing? [redacted], expiry [redacted].",
  "model": {
    "read": false
  },
  "took_ms": 7
}

A long number that is not a card POST /v1/text

Still waiting on order 4000 1234 5678 9011, placed last Monday. Any news?

allow 8 ms

Sixteen digits, but they fail the checksum, so nothing is reported. Without the check digit this would fire on every support ticket and be switched off in a week.

The answer, abridged
{
  "decision": "allow",
  "flagged": [],
  "signals": [],
  "model": {
    "read": false
  },
  "took_ms": 8
}

Personal data in user content, explained

What is found, how a number is checked and what the mask does.

Why mask personal data instead of refusing the message

Refusing a comment because it contains a phone number throws away everything else the person wrote. On a marketplace or a support chat that is almost always the wrong trade: the message is fine, one part of it should not be public. Masking that part keeps the conversation and loses the number, and the author does not have to write it all again.

What it finds

Email addresses, including the forms people use because they know a plain one gets stripped: name (at) domain (dot) com, [at] and [dot], arroba and punto. Phone numbers, as runs of at least nine digits with or without separators and a country code. Bank accounts as IBAN, payment card numbers, Spanish DNI and NIE numbers, the French NIR, the Italian codice fiscale, the Dutch BSN, the Portuguese NIF and the German Steuer-ID, and Spanish plates. Each one is a signal under personal_data with a reason in a sentence, and the disguised email adds a second one under evasion.

How a number is checked before it is reported

A long number is not personal data on its own; most of them are order references, tracking codes and invoice numbers. So everything here with a check digit is verified. An IBAN is rearranged, its letters turned into numbers, and must leave a remainder of one modulo 97. A card must pass Luhn and start with a prefix a real issuer uses, which keeps a phone number with the right length from passing one time in ten. A DNI or NIE must end in the letter its number gives modulo 23, and the other national numbers carry their own check digit or letter, which is verified the same way. A plate has no check digit, so it scores lower than the rest.

What redact returns

With redact: true, the answer carries redacted: the text you sent with each finding replaced by [redacted]. The positions come from detectors that matched the raw text, so the mask lands on the right words. Overlapping findings are merged before anything is replaced, because an IBAN contains a run of digits the phone matcher also sees, and masking one while skipping the other would leave half a bank account published. Replacement runs from right to left, since every mask changes the length of the string. Positions are never capped: a message with forty addresses comes back with forty masks.

Evidence that does not repeat the number

The signal shows what was found, so a moderator can see why a message was held. Card numbers are quoted with only their last four digits and phone numbers with the middle taken out, because a detector whose job is to notice a number that should not be public must not be the thing that copies it into logs and API responses.

How the work is split

The instant checks settle the clear cases in about a millisecond, the model reads what depends on context, and your rules and your people have the last word.

  • Checked, never guessed

    Everything with a check digit is verified before it is reported: an IBAN by mod-97, a card by Luhn and a real issuer prefix, a DNI or NIE by its letter. A number that fails is not reported at all, so order references and tracking codes are left alone.

  • Your policy decides

    A phone number in a listing may be the seller's own and perfectly normal there. The detector says it is there; your policy says whether to hold it, refuse it or only mask it.

  • Nothing is stored

    The record keeps the verdict and a hash of the content, never the content. Card and phone numbers are masked even in the evidence of the answer, so the detector never copies them anywhere.

Frequently asked questions

How do I remove phone numbers and emails from user comments?

Send the comment to POST /v1/text with redact: true. The answer carries redacted, the same text with each phone number and email address replaced by [redacted], beside the decision and the signals. You publish redacted instead of the original.

Does it detect emails written as name (at) domain (dot) com?

Yes. The (at), [at] and (dot) forms, the Spanish arroba and punto, and name at gmail.com are all read as addresses, masked like any other, and reported once more as evasion, because writing it that way is an attempt to get past a filter.

How do you tell a card number from an order reference?

By the checksum. A card number has to pass the Luhn check and start with a prefix a real issuer uses; an IBAN has to pass mod-97; a Spanish DNI or NIE has to carry the letter its number gives. A number that fails is not reported at all.

Is personal data blocked or only flagged?

With the shipped lines, a phone, an email, an IBAN, a DNI or a plate is held for review, and a card number is refused. Those are lines in your policy like any other: you can refuse at a lower score, or never refuse and only use the mask.

Does ToxicFilter store the personal data it finds?

The record keeps the verdict and a hash of the content, never the content, and the stored signals carry no evidence. Card and phone numbers are masked in the evidence of the answer itself, so the detector does not copy them anywhere.

Can it find home addresses?

Addresses are left to your own rules, on purpose. Phones, accounts, cards and ID numbers have a structure or a check digit that makes a match reliable, and that is what keeps ordinary messages untouched. If addresses matter on your site, your policy's word lists can hold the street names or postcodes you care about.

Keep reading

Try it on your own traffic

2,000 credits a month on the free plan, no card. Enough to send a week of your own content and see what it says about it.