Configuration that looks right is not evidence. This recipe proves, from three independent places, that a request went through zer0pii and that personal data in it was masked before it left.
1. Send a request with fake personal data
Use your configured tool, or curl against the same route the tool uses. The fake values below are deliberately non-real.
OpenAI-shaped route:
curl -sS -D headers.txt https://api.zer0pii.com/v1/chat/completions \
-H "Authorization: Bearer zpii_..." \
-H "X-zer0pii-Upstream-Key: sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Repeat exactly: contact dana.levi@example.com or +972-52-000-0000"}]}'
Anthropic-shaped route (what Claude Code uses):
curl -sS -D headers.txt https://api.zer0pii.com/v1/messages \
-H "Authorization: Bearer zpii_..." \
-H "X-zer0pii-Upstream-Key: sk-ant-..." \
-H "Content-Type: application/json" \
-d '{"model":"claude-sonnet-4-5","max_tokens":100,"messages":[{"role":"user","content":"Repeat exactly: contact dana.levi@example.com or +972-52-000-0000"}]}'
Self-hosted: replace the hostname. If your admin configured the provider key on the gateway, drop the X-zer0pii-Upstream-Key line.
Expected reply body: the model repeats the email and phone as you sent them. That is the reconstitution working. The provider never saw them; it saw two masked tokens and echoed them back, and the gateway swapped the originals back in.
2. Read the response headers
headers.txt (or your tool's network log) carries these on every proxied response (source: apps/gateway/api/v1/proxy.py, zheaders):
| Header | What to look for |
|---|---|
X-zer0pii-Redacted-Count |
2 for the request above (one email, one phone). 0 means nothing was masked: check the count before anything else. |
X-zer0pii-Mode |
standard. degraded-deterministic means the contextual name detector was skipped and only pattern-based detectors ran; emails and phones are still caught in that mode, free-text names may not be. |
X-zer0pii-Request-ID |
req_ plus 12 hex characters. Copy it; it is the join key for the audit log. |
X-zer0pii-Attestation |
The signed receipt, see step 4. |
X-zer0pii-Latency |
total=...ms; scan=...ms; vault=...ms, the gateway's own overhead, excluding the provider. |
If the headers are absent, the request did not go through zer0pii. The most common cause in Claude Code is a saved claude.ai login still being the active credential; /status shows it (see claude-code.md).
3. Confirm in the console
Sign in to the console.
- Audit log (
/console/audit): one row per gateway request, described on the page itself as "route, mode, counts, latency, key prefix and the Merkle leaf. Never a prompt, a reply, or a mapping." Filter by route and find your request ID in the row's details: Mode, Redacted (should read 2), Entities (the labels found, for example EMAIL and PHONE), Key prefix (the first characters of thezpii_key you used) and Merkle leaf. - Usage (
/console/usage): Requests and Items redacted move by 1 and 2 respectively; the By key table attributes them to your key's prefix.
The audit row is metadata written by the gateway at forward time (source: proxy.py, record_audit_event_later); it holds no byte of the payload, which is why you can look at it without a second privacy review.
4. Read the attestation receipt
The X-zer0pii-Attestation header has this exact shape (source: apps/gateway/core/attestation.py, Attestor.sign):
t=<unix seconds>;kid=<key id>;raw=sha256:<hex>;sanitized=sha256:<hex>;status=purged;sig=ed25519:<base64url>
rawis the SHA-256 of the request body exactly as your tool sent it.sanitizedis the SHA-256 of the masked body exactly as forwarded to the provider. The two differ whenever something was masked, and they are the same bytes the gateway forwarded, not a re-serialisation.status=purgedsays the gateway's own copy of the raw body was overwritten in memory after signing. What that does and does not cover is spelled out indocs/ZDR-SCOPE.md.sigis an Ed25519 signature overt|request_id|raw|sanitized|PURGED, made with the key whose id iskid.
To verify independently, fetch the public keys from https://api.zer0pii.com/.well-known/jwks.json (source: apps/gateway/api/v1/health.py; the audit page links the same URL under Verify with), pick the key whose kid matches, and check the signature. apps/gateway/core/attestation.py contains a reference verify(header, public_key, request_id) function you can lift into your own tooling.
A receipt that verifies against a published key is your evidence, independent of anything the console displays, that the masked hash is what left the gateway.
If step 2 shows Redacted-Count: 0
In order of likelihood:
- The request did not reach zer0pii (no
X-zer0pii-*headers at all). Fix the base URL or the credential variable. - The tool wrapped the text in a field the gateway treats as code or configuration. Put the fake values in the user message body, as above, for the check.
- The values do not match a detector. The email and phone above do; if you substituted your own, use a plainly formatted email and an international phone number for the check.