Security and data handling
your invoices are never stored. This is everything that is.
Invoice data sent to Attestwire is checked in memory and is not stored as an invoice. It is not sent to an AI model or used for training. The sections below explain account data, payments, optional records and the safeguards we provide.
| Question | Answer |
|---|---|
| Are invoice payloads stored? | No. An optional validation record keeps the findings, not the document. |
| Are they used for model training? | No. There is no model here to train. |
| Are responses cached? | No. Responses carry Cache-Control: no-store. |
| How are API keys stored? | As SHA-256 hashes. The plaintext key is shown once. |
| What is the infrastructure? | Cloudflare Workers, Workers KV and Durable Objects. |
| Where is data processed? | US-operated. Cloudflare routes to the nearest point of presence, for latency. EU-only processing is not contractually guaranteed today. |
| If there is a breach, when do I hear? | Within 48 hours of us becoming aware. It is a term of the DPA. |
| What does the payment processor see? | Paddle handles billing. No invoice payload is sent to Paddle. |
| Certifications? | No SOC 2 and no ISO 27001 today. |
| Penetration test? | No third-party penetration test today. |
Each answer links to the section below that supports it. For what the product does and does not do, see product scope and limits; for a GDPR Article 28 processor agreement, the data processing addendum.
What happens to an invoice you send us
Invoice processing is separate from live registration lookups. A VAT check sends the VAT number to EU VIES. A Peppol lookup sends the participant identifier to OpenPeppol’s public service. These checks do not send the invoice itself to those services.
It is processed in memory and never stored. A request
to POST /v1/validate or POST /v1/generate is
parsed, run through the validation engine, and answered. When the
response is written, the payload goes out of scope with the request.
There is no database of invoices, no object storage bucket, no queue and
no cache holding one.
There is nothing for us to go and look at later, which is also why we cannot help you re-run yesterday's document.
It is never used to train anything. There is no model here to train: the engine is deterministic TypeScript, a rule set compiled from EN 16931. We do not send payloads to any third-party service, AI or otherwise.
Responses carry Cache-Control: no-store.
So neither Cloudflare's cache nor an intermediary proxy retains the
result of a validation.
What we log. We do not log invoice payloads in normal operation. What we keep is what we need to run the service: a request count per key, and the fact that a call happened. The validation and generation paths log the error class name and nothing else: never the document, never a field value.
The catch-all handler behind every route does the same: it records the error class name and the route, and no stack trace. There is no trace to scrub, because none is written.
One real exception. If our Paddle webhook handler
fails, we log the stack trace of that failure. That path carries
billing events, never an invoice payload. If that is not good enough for
your data, run @attestwire/en16931 in your own process. It
makes no network calls at all.
API keys
We do not have your key. The plaintext existed once, in the body of the one HTTP response that created it. What we keep is a SHA-256 hash of it, which is enough to recognise the real key and useless for producing it.
A breach of our key store yields a list of hashes rather than a table of live credentials. Nobody here can read your key back to you, whether in a support thread, by accident, to somebody impersonating you, or under a subpoena, which would yield the same hashes we hold. The same property applies if you lose the key: we can no more help you than we could help an attacker.
The common alternative is to encrypt the keys instead of hashing them and put a reveal button in a dashboard. That is friendlier on one day of a customer's life and worse on every other, because it rebuilds the store that can hand out working credentials to whoever reaches it. We chose to be unable to reveal a key.
This is the constraint the code was written under, not marketing
written afterwards. Here is the comment at the top of
apps/api/src/keys.js, verbatim:
SECURITY INVARIANT: the plaintext key exists in exactly one place — the HTTP
response to the request that created it. We store SHA-256(key) and nothing
else derived from it, we never log it, and we cannot recover it. A lost key
is re-issued, not recovered.
What to quote instead of your key. Every key has a fingerprint: the first eight hex characters of its hash, which is what our logs record. It identifies which key a conversation is about and cannot authenticate as it, so it is safe in an email or a ticket. Never send the key itself: an email is a poor place to keep a secret, and we have no use for it.
That leaves four cases. The last two differ in what we hold, and therefore in what happens next.
-
One free key per email address. Asking again for the
same address returns
409 key_already_issued, not a second key. The limit keeps the free tier tidy and is not a security control: the address behind a free key is never verified, so it proves nothing about who holds the key. -
The key is in hand. While you still hold the key you can replace it yourself with POST /v1/keys/rotate. You authenticate the call with the key you are replacing, your plan and usage carry over, and the old key stops working at once. That key is the entire credential, so anyone else who has a copy can rotate it away from you on the same terms. If you think it has been exposed, rotate first and investigate afterwards. The
old key's
410begins the moment that call returns, so deploy the replacement before you make it. - The key is lost, and you pay us. There is no self-serve path, because a replacement any email address could request would be no safer than a reveal button. If you are on a paid plan and the key is gone, email hello@attestwire.com from the address on your Paddle billing receipt. We check the request against Paddle's record of your subscription rather than against the receipt itself — a receipt can be forwarded, so attaching one proves only that somebody has a copy of it — and the replacement is delivered one way only: as a reply to the billing address on file, whoever did the asking. Whoever handles it retires the old key and issues the new one.
- The key is lost, and it was free. We cannot recover a lost free key — not as a matter of policy, but because we never collected anything that could prove it was yours: a free signup address is never verified, and the key itself only ever existed in one HTTP response. The practical answer is faster than support anyway. The limit is one free key per email address, so a different address gets you a working free key on the spot, and a plus-alias such as you+attestwire@example.com counts. That limit keeps casual signups to one key each; it is housekeeping rather than a security control, and it was never meant to trap you. If you would rather we were able to rescue you, that is part of what a paid plan buys: Paddle holds a billing identity we can check.
The lost key itself is never recoverable: we store its SHA-256 hash and nothing else, so there is nothing on our side to show you again and every route above ends in a replacement rather than the old key returned. That is the same property that means a breach of our key store hands over a list of hashes, and that no support conversation can leak your credential, because the support side of it has never held one.
Metering is per key, held in a Cloudflare Durable Object, so the count is consistent without a shared database.
The complete list of what we hold about an account, in Cloudflare Workers KV:
- The key record, stored under the SHA-256 hash of the key: your signup email address, your tier, the creation timestamp, the time the tier last changed, whether the subscription is past due, the subscription status Paddle reports, and the Paddle customer and subscription identifiers.
- An email index, so one address gets one free key. It maps the address to the key hash.
- A Paddle event receipt per webhook we process, keyed by the Paddle event id. It stops us applying the same billing event twice.
- Rotation records. When you rotate a key we keep a permanent pointer from the old hash to the new one, and a permanent marker that the old hash is retired. Both hold hashes, not keys.
- Usage counts per key, and a daily count per IP address, described below.
-
Validation records, only where you asked for one
with
?record=true, in their own namespace: the verdict, the findings, the engine and rule-set versions, the time and a SHA-256 fingerprint of the document, never the document. A finding’s message can quote an invoice value. Each is readable by anyone with its link, is kept with a salted hash of the issuing key for abuse tracing, and expires after 365 days. More on records.
IP addresses
POST /v1/keys is open to anyone, so it is rate limited by
IP address: five key requests per address per UTC day. To do that we
keep a counter named after your IP address, for that day. The key form
on our homepage posts to that same endpoint, so requesting a key from
this website writes one.
An IP address is personal data under the GDPR, so it is listed here and in DPA section 2 and section 7.
No other route records an IP address. We do not attach one to a validation, to a key record, or to any log line we keep.
Hostile XML
The package ships with zero runtime dependencies, so it carries its own XML reader rather than a general-purpose parser. That is a security decision as much as a size one: a general parser accepts DTDs, entities, notations and mixed content, and an invoice needs none of them. The classic XML attacks are absent rather than mitigated: the features they need were never implemented.
-
No DTD processing. Any
<!DOCTYPEor<!ENTITYin the document is refused, with the error codesxml_doctype_forbiddenandxml_entity_declaration_forbidden. That closes XXE — an external entity reading a local file or making a network request — and the declaration half of billion-laughs. The check runs on the raw text, so a DOCTYPE hidden inside a CDATA section is refused too. -
No custom entity expansion. Only
&,<,>,",'and numeric character references are decoded; anything else is refused withxml_entity_forbidden. That closes billion-laughs by construction rather than by a counter. An unknown entity is refused, never silently dropped: dropping one would change the text of a tax document without telling anyone. - Size, depth and width ceilings. The reader refuses an over-sized, over-deep or over-wide document rather than truncating or guessing, so a hostile file cannot exhaust the process — or, on the playground, your browser tab. A parser that guesses at a construct it does not understand produces a wrong invoice, and a wrong invoice is a tax problem. Every refusal names itself, so a caller can tell a hostile file from an unsupported one.
These are properties of the MIT package, so they hold whether you call the hosted API, run the library in your own process, or paste a file into the browser checker. Same reader, same refusals, same error codes.
Infrastructure
Everything runs on Cloudflare Workers: the marketing site, the API, the free tools. We administer no servers: there are no long-lived hosts to patch and no remote access to leave open. Cloudflare terminates TLS; the API is HTTPS-only. State is limited to the KV and Durable Object stores described above.
Administrative access to the Cloudflare and Paddle accounts belongs to one person and is protected by multi-factor authentication.
Payments
Card data never touches us. Checkout and the billing portal are hosted by Paddle; you enter payment details on Paddle's pages, not ours. We hold a Paddle customer identifier and your tier. Nobody here can see a card number, because it never arrives.
Paddle is our merchant of record, not a card gateway we point at. It sells the subscription to you, so it also holds the billing address and any VAT or tax identifier you enter, it works out the sales tax or VAT due, and the invoice for your subscription comes from Paddle. None of that reaches us either.
Paddle never receives an invoice payload. It knows you are a customer; it does not know what you validate.
Where processing happens
Cloudflare Workers executes your request at the point of presence nearest the caller. In practice, a request from Germany is normally handled inside the EU and never leaves it. We do not contractually guarantee that today. Cloudflare chooses the location by proximity to the caller, so we will not print a residency promise we cannot enforce.
Cloudflare offers a product for this, Data Localization Suite, with regional processing controls. Adopting it is on the roadmap, and it is a roadmap item, not a claim. If contractual EU-only processing is a requirement for you, say so at hello@attestwire.com before you integrate; in the meantime the library runs entirely inside your own infrastructure, wherever that is, and sends nothing anywhere.
Attestwire itself is operated from the United States. Where personal data is involved, the transfer basis is covered in the data processing addendum.
Subprocessors
There are two, with different jobs.
| Who | What for | What they touch | Where |
|---|---|---|---|
| Cloudflare, Inc. | Infrastructure | Everything the API receives, for the moments it is being processed. The key record (signup email address, tier, timestamps, past-due flag, subscription status and Paddle identifiers), the email index, Paddle event receipts and rotation pointers, all stored in Workers KV. Per-key request counts and a daily key-request count per IP address, stored in Durable Objects. | Global edge network; the PoP nearest the caller. |
| Paddle.com Market Limited (and its affiliates, including Paddle.com Inc.) | Billing only (merchant of record) | Your billing email, billing address, any VAT or tax identifier you enter, and payment details — all given to Paddle directly at its checkout. Paddle never receives invoice payloads, and no card number ever reaches our code. | United Kingdom and United States (entity depends on buyer location), per Paddle's Buyer Terms. |
Nothing else in the stack sends data to a third party: there is no
error-tracking service, CRM, marketing automation or advertising
network. Two things measure anything, and both are Cloudflare's own.
The marketing pages load
Cloudflare Web Analytics
(static.cloudflareinsights.com/beacon.min.js), which is
cookieless, does no cross-site tracking and builds no visitor profiles.
Three of those pages also count a few named button presses in Cloudflare Workers Analytics Engine, first-party, in the account that already serves the page: a name from a closed list and nothing else, with no identifier, address, URL or payload attached, and nothing sent when the browser signals Do Not Track or Global Privacy Control.
Neither runs on the API, neither writes to your
browser, and neither sees an invoice payload:
privacy describes the beacon and
the counters in full. The beacon is the
only cross-origin subresource on almost every page. The two exceptions
are the pages you pay us on: /pay, the noindex checkout
page, and /fix, which sells the one-time Invoice Fix
Report.
Both load
cdn.paddle.com/paddle/v2/paddle.js so Paddle can open its
payment window, and
that is described in full too.
The site sets no cookies. If we add a subprocessor, DPA customers get 30
days' notice before it starts processing.
Cloudflare and Paddle process data under their own terms. We choose them and describe what they do for us; we do not warrant their conduct, and no statement here is a representation on their behalf.
If something goes wrong, when you hear
Within 48 hours. Under the data processing addendum we notify you without undue delay, and in any case within 48 hours of becoming aware of a personal data breach affecting your data, with what we know at the time. That is a contract term: DPA section 4(f). What there is to breach is on this page: no invoice store, keys as hashes, no card data.
What we do not have
Vendor questionnaires ask for these. The answers are all "no" today, and we state them as such so that you can plan around them.
- No SOC 2. No Type I, no Type II, no audit in progress.
- No ISO 27001. Not certified, not in scope this year.
- No third-party penetration test. None has been commissioned. When one is, the date and the firm will be named here.
- No formal bug bounty and no disclosure platform. See below for what there is instead.
- No 24/7 on-call and no support SLA. Pacific time zone, business hours. See response times.
What stands in for a certificate is the architecture described above: there is no invoice store to breach, keys exist only as hashes, and cards never arrive.
Reporting a vulnerability
Email hello@attestwire.com. The same address is published in /.well-known/security.txt per RFC 9116.
There is no bug bounty and no formal disclosure process: one address that reaches a person. Tell us what you found and how to reproduce it, and we will answer. Two business days is the target. We will not threaten you, and we will credit you if you want to be credited. Please do not run automated scans against the paid endpoints or test with anyone else's invoice data.
Last updated . If this page changes materially we will say so here rather than silently swapping it.