Personal data handling and compliance for APIs

Learn to send only the fields each API needs, redact personal data in logs and caches, set retention per data type, and keep a tamper-evident audit trail.

  • 40 min
  • Expert

A multi-API system is a machine for copying data. A signup form's email address goes to a validation API, into a queue, into a worker's logs, into an error tracker when something throws, into a cache keyed by the address, into your database, and into an analytics event. Each copy is somewhere the data can leak from, somewhere a deletion request has to reach, and something a privacy review will ask you about. Architecture decides how many copies exist. This lesson is about making that number small on purpose and making every remaining copy accountable.

Minimise at the call boundary

Data minimisation is the principle that you collect and pass on only what a purpose needs. At the API boundary it becomes a concrete rule: each outbound call sends exactly the fields that API requires, and nothing else. An email validator needs an email address. It does not need the person's name, their IP address or their account id, even though all of those are sitting in the same object when you make the call.

The failure mode is convenience. Someone writes verve('emailvalidator', user) with the whole user record, and because APIVerve silently drops parameters an endpoint does not declare, nothing breaks. The extra fields still crossed the wire, still sat in a query string, and may have been logged by any proxy, HTTP client or tracing library on the way out. Make the allowed fields explicit, per API, in one place:

outbound-contract.mjs
// What each API may receive. Anything else is a bug, not a convenience.
const CONTRACT = {
  emailvalidator: ['email'],
  phonenumbervalidator: ['number', 'country'],
  vpndetector: ['ip'],
  sanctionscreening: ['name', 'lists'],
};

export function project(api, record) {
  const allowed = CONTRACT[api];
  if (!allowed) throw new Error(`No outbound contract for ${api}`);
  const out = {};
  for (const k of allowed) if (record[k] != null) out[k] = record[k];
  return out;
}

// verve('emailvalidator', project('emailvalidator', user))  sends { email } and nothing more

The contract doubles as documentation: it is the "what data goes to which processor" answer that a privacy review asks for, and it lives in code where it cannot drift from reality.

Minimise in the other direction too. Responses echo inputs. Email Validator returns the address in data.email and data.normalizedEmail; Sanctions Screening returns the screened name in data.query. If you cache or store whole responses, you have stored the personal data again. Project responses down to the fields you act on (isValid, isDisposable, verdict) before they go anywhere persistent.

GET endpoints put parameters in the URL, and URLs are the most logged string in computing. Configure your HTTP client, APM agent and reverse proxy to record the path without the query string for outbound calls, and treat any tool that captures full outbound URLs by default as a PII store until proven otherwise.

Keep personal data out of logs, traces and caches

Logs are where minimisation usually fails, because they are written for the worst day: the more you log, the easier the incident. The answer is not to log less but to log redacted structure. A log line that says a signup failed for user u_83f2 with an email at example.com and a 429 is almost as useful as one with the full address, and it is not a leak.

Redact a record without mutating it

Write one redaction function and route every structured log, error report and trace attribute through it. It must handle nesting and arrays, it must not mutate the object it is given (that object is often still in use by the request that is logging it), and it must match field names reliably:

redact.mjs
const MASK = '[REDACTED]';

export function redact(record, fields) {
  const targets = new Set(fields.map((f) => f.toLowerCase()));
  const walk = (value) => {
    if (Array.isArray(value)) return value.map(walk);
    if (value !== null && typeof value === 'object' && !(value instanceof Date)) {
      const out = {};
      for (const [k, v] of Object.entries(value)) {
        out[k] = targets.has(k.toLowerCase()) ? (v == null ? v : MASK) : walk(v);
      }
      return out;
    }
    return value;
  };
  return walk(record);
}

const event = {
  userId: 'u_83f2',
  contact: { Email: 'jane.roe@example.com', phone: '+14155552671' },
  attempts: [{ ip: '203.0.113.42', status: 429 }],
  card: null,
};
const safe = redact(event, ['email', 'phone', 'ip', 'card']);
// { userId: 'u_83f2', contact: { Email: '[REDACTED]', phone: '[REDACTED]' },
//   attempts: [ { ip: '[REDACTED]', status: 429 } ], card: null }
// event is unchanged.

Three decisions are worth noticing. Matching is case-insensitive, because Email and email arrive from different code paths. A null stays null, so the redacted log still tells you the field was absent rather than claiming it had a value. And the function builds new objects all the way down instead of copying the top level and editing in place, which is the classic way a "redacted" log ends up redacting the live request too.

The same function in Python, for log pipelines that run there:

redact.py
MASK = "[REDACTED]"

def redact(record, fields):
    targets = {f.lower() for f in fields}

    def walk(value):
        if isinstance(value, list):
            return [walk(v) for v in value]
        if isinstance(value, dict):
            return {
                k: (v if v is None else MASK) if k.lower() in targets else walk(v)
                for k, v in value.items()
            }
        return value

    return walk(record)

When full masking is too much

Sometimes support needs to find "the failed signup from that Gmail address" or count failures per user without seeing who they are. Two techniques keep that ability without storing the value:

  • Partial masks keep a non-identifying part: the email domain, the last four digits of a phone number. Useful for humans reading logs.
  • Keyed hashes replace a value with HMAC-SHA256(secret, normalisedValue). The same input always produces the same token, so you can join and count, but nobody without the secret can reverse it. A plain unkeyed hash is not enough for emails or phone numbers: the space of plausible inputs is small enough to hash them all and look the answer up.
pseudonym.mjs
import { createHmac } from 'node:crypto';

const SECRET = process.env.PII_HASH_SECRET; // rotate deliberately: old tokens stop matching
export const token = (value) =>
  createHmac('sha256', SECRET).update(String(value).trim().toLowerCase()).digest('hex').slice(0, 24);

export const maskEmail = (email) => {
  const at = String(email).lastIndexOf('@');
  return at > 0 ? `***@${email.slice(at + 1)}` : '***';
};

Use the keyed hash for cache keys too. A cache keyed by raw email addresses is a list of your users' email addresses with a TTL. Keyed by token(email) and holding only the projected response fields, it holds nothing personal, and a deletion request is handled by recomputing the token and deleting that key.

Free text is the hard case

Structured fields can be redacted by name. Free text cannot: a support ticket body or a third-party error message can contain anything. For that, pattern-based masking is the practical tool, and Data Masking (datamasking) does it as an API:

Try it liveData Masking10 credits
POSThttps://api.apiverve.com/v1/datamasking
curl -X POST "https://api.apiverve.com/v1/datamasking" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"checkout failed for jane.roe@example.com card 4111 1111 1111 1111 from 203.0.113.42, callback 555-123-4567, cc ops@example.org"}'
Sign in or create a free account to run this with your own key.

masked replaces each match with a placeholder such as [EMAIL] or [CREDIT_CARD]; detected counts what was found (email: 2 here).

Be clear-eyed about the trade. Calling a masking API sends the unmasked text to another processor in order to clean it, and it costs 10 credits a call. That is a sensible trade for free text you intend to store or share (ticket archives, datasets for testing, text sent on to another system), where one processor seeing it briefly beats every downstream system keeping it. It is the wrong tool for structured log records, where a local redact() by field name is free, faster and keeps the data in your process. And pattern matching has limits in both directions: it will not recognise a name or a street address on its own, so it complements field-level redaction rather than replacing it.

Check yourself

A worker logs the full Email Validator response for every signup to help with debugging, and caches it keyed by the email address for 30 days. Which change removes the personal data while keeping the debugging and caching value?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales