Letterprove

Documentation

Letterprove publishes signed, machine-readable evidence that the companies a vendor names really use their product, so an evaluating agent can check a logo wall instead of believing it. This page is the whole of it: how to install the script, what it sends, what the tiers mean, how a customer consents to being named, and how to verify any of it without trusting us.

If you are here to check somebody else's claim rather than publish your own, skip to verifying a proof yourself. That section is the point of the product.

1.Install

There is no Letterprove login. Letterstory is the identity authority for both products, so your account, your key, your customers and your consent links all live in the Proofs tab there, and everything below is reached from it. This site publishes the proof; it does not hold a session of yours.

Installation is one script tag on the pages where people sign in. It has no dependencies, no build step, and nothing to configure beyond its data-key attribute.

<script src="https://app.letterprove.com/attest.js" data-key="lp_live_yourslug_0123456789ab"></script>

Copy the snippet the Proofs tab gives you rather than this one. The real one is generated for you, with your own key, pointed at the origin that served it. That is not ceremony. A written-down host has broken collection twice: once when an install kept pointing at an old deployment URL after a domain move and stayed dead for 65 hours, and once when every vendor was handed a cdn.letterprove.com URL for a host that has never existed. The script is a static file served by this app at this app's origin and nowhere else.

Both failures were silent, and that is the part worth understanding. The script is built so it can never break your page, which means a script that fails to load and a quiet weekend produce exactly the same thing: no events. Nobody gets an error. You get an empty dashboard and conclude the product does not work.

The one hard rule: the domain only

You pass an email address to the script. The script splits the domain off in the browser and sends only the domain. example.com, never someone@example.com. The local part never enters a request body and never leaves the page.

The domain does the entire job of attributing activity to a company. The local part adds nothing and carries everything: end-user personal data out of someone else's product, a data processing agreement with every vendor, and a breach story. The same discipline runs through the rest of the payload. No user ids, no cookies, no local storage, no IP address, no page URLs, no referrer.

Nothing is counted until your domain is verified

Installing the script is not enough on its own. Until you have proven control of the domain by DNS, the collector accepts your requests and records nothing. Put a TXT record on either host:

_letterprove.example.com
example.com
letterprove-site-verification=<the token shown in the Proofs tab>

_letterprove.example.com is the primary, following the same convention as _dmarc and _acme-challenge. The apex is accepted too, because that is where people expect a site-verification record to go.

This gate is stricter than it needs to be for tier purposes alone, on purpose. If an unverified vendor could collect, whoever registered a domain first would accumulate events, rollups and history on it before the real owner ever arrived. Refusing at the door means an impersonator accumulates nothing and the legitimate owner verifies into a clean slate.

Origin must match exactly

Every event is pinned to the browser's Origin header, which page script cannot override, and compared against the domain on your account. The comparison is exact on hostname. www.example.com and example.com are different origins, and the browser sends whichever one actually served the page, so the domain on your account has to be the host your users are really on. A mismatch here is the classic silent zero.

Origin-pinning is a real constraint on a browser and no constraint at all on curl. That is a known, accepted limit rather than an oversight, and it is exactly why script-observed evidence sits low on the ladder below.

Telling whether it works

Every collector response carries x-letterprove: on when the request was accepted and off when it was refused, so one curl answers the question instead of an afternoon. The body never says anything: the endpoint always answers 204, including on a bad key, a malformed payload, an unverified domain or a rate limit, because a host page must never see a failure from us.

The Proofs tab separates two questions that look identical from outside. Installed means the script has successfully fetched its config at least once, so it is really on the page. Receiving means an event has actually arrived. Installed but not receiving is normal on a marketing site with no sign-in, and is not a fault.

About the key

The data-key value is publishable, not secret. It ships in your page HTML by design, which is why it cannot authenticate anything on its own and why origin and DNS verification carry that weight instead. Rotating the key takes effect immediately and the old one stops working at once, so every install has to be updated in the same change.

Nothing is public until you publish it

Installing the script publishes nothing. Until you publish, every public URL about you — /proofs/<you>, /attest/<you> and both chain paths — answers 404, the same 404 a vendor who does not exist gets, and you are absent from the discovery document. Nobody can tell from the outside that you are here.

Everything else runs the whole time. Events are collected, rolled up hourly, signed, chained and frozen exactly as they would be if you were public. So publishing is a switch, not a build: the day you turn it on, your history already reaches back to your first observation instead of starting that morning. Install, watch it work for as long as you like, and go public when the numbers are worth showing.

Publish and unpublish from the Proofs tab. Publishing needs your domain verified, because nothing is collected for an unverified domain and the only document we could sign for you would be a zero. Unpublishing takes every URL back to 404 — but it cannot un-fetch: an attestation someone already retrieved while you were public stays signed and stays verifiable, which is the whole point of signing it.

2.The client API

The script exposes exactly three calls on window.Letterprove. There is no fourth, and no general event method.

Letterprove.identify(email)  // establishes the domain, fires one "session" per page load
Letterprove.signup(email)    // identifies, then fires "signup"
Letterprove.login(email)     // identifies, then fires "login"

identify is what you call on an authenticated page load. signup and login call it internally before firing their own event, so an auth success handler never needs to call both. The session event fires at most once per page load no matter how many times you call identify.

An address that cannot be split into a domain is dropped silently, and nothing is sent until one has been. There is no way to pass a domain directly: the split happens here so that the address cannot be sent by mistake.

Three behaviours are worth knowing before you wire it up.

  • It cannot throw into your page. Every public method is wrapped, and so is every transport path. Your product must behave identically whether this script loads, fails, or is missing.
  • It fails closed. The script fetches its collection config once at boot. Calls made before that resolves are queued in memory and flushed when it does. If the fetch fails, the queue is dropped and the page collects nothing. It is never retried mid-page, because a guess is worse than a gap.
  • Transport prefers sendBeacon, falling back to a keepalive fetch. Neither is awaited and neither surfaces a result.

What actually goes over the wire is five fields: your publishable key, the domain, the event name, the config version that produced it, and a client timestamp. The timestamp is used for ordering and de-duplication only and is never authoritative. Counting happens on the server against our own receipt timestamp, because a counter the page can set is a counter the page can inflate. We additionally store the request origin and a country and region derived at the edge.

Volume is capped at 300 requests a minute per source IP and 3,000 a minute per vendor key. Both refusals look like every other refusal: a 204 with x-letterprove: off.

3.What a published attestation says

Two kinds of document are published. The aggregate is a claim about a vendor and names nobody: how many distinct company domains were observed in the last 30 days, and the session, signup and login totals behind that. The per-customer attestation names one company and reports what was observed for its domain. The aggregate is the only signed claim most vendors can publish, because naming a customer needs that customer's consent and counting them does not.

The aggregate says companies_observed, and it means it. A session from an address at a company proves somebody there used the product. It does not prove that company buys it. Domains that can never name a company, free mail providers and the vendor's own, are counted separately as domains_excluded rather than silently dropped, so the headline can be read honestly.

Inside a per-customer body, the fields have different provenance, and it matters:

  • sessions_30d is measured. It is the sum of hourly rollups for that domain over the trailing 30 days.
  • seats_active is always 0 today. Phase-one events carry no per-user dimension, so there is nothing honest to sum. It is signed as a literal zero because the field is covered by the signature, and it should be read as “not yet measured” rather than as a measurement of zero.
  • features and since are vendor-asserted. Named feature events are a later phase and are not wired, so nothing observes feature use today.
  • contract_currency, contract_monthly and contract_since appear only when an invoice in the vendor's Stripe account actually settled. They are absent rather than zero for everyone else, because a zero would assert “pays nothing” where absence correctly says “we hold no payment evidence”. contract_since is the first settled invoice, not the subscription's start date.
  • observed_through is the end of the window summarised. published_at is when the snapshot was cut. They are deliberately separate.
  • method is a commit-pinned link to the source file that computed the numbers. Nothing else in the document asks to be trusted.
  • prev_hash is the SHA-256 of the previous signed snapshot for the same subject, with 64 zeroes at the start of a chain. This is what makes the history auditable rather than merely signed.

Signing runs on a cadence, not per request. Rollups are written hourly on the hour, and the freeze that signs, chains and countersigns them runs five minutes later. Published documents carry a ttl of 60 minutes, which matches that cadence.

4.The tier ladder

Every attestation carries a `tier`. A valid signature proves only that this document is ours and unaltered — it says nothing about how good the underlying evidence is. The tier says that, and it is the claim. A signed tier-0 document asserts only that the vendor said so.

tier 0vendor-asserted

The vendor stated this. Nothing corroborates it — either no usage was observed, or the vendor has not proven control of the domain events are pinned to.

Forgeable by: The vendor alone, trivially. Treat as a claim, not as evidence.

tier 1script-observed

Letterprove's script reported usage attributable to this company's email domain.

Forgeable by: The vendor, with effort. The collector pins events to a verified origin, which a browser cannot forge — but a non-browser client can send whatever origin it likes.

tier 2infrastructure-bound

Observed usage carrying a Letterprove-side receipt timestamp and an origin pinned to a domain the vendor proved control of by DNS.

Forgeable by: A determined vendor running a distributed spoofing rig. Volume and burst anomalies are scored against it; a slow, well-distributed rig is an accepted open gap.

tier 3third-party confirmed

An invoice that actually settled for this company, read directly from the vendor's own live-mode Stripe account, alongside observed usage. A subscription on its own does not qualify: it says what the vendor meant to bill, and a free one reaches `active` for nothing.

Forgeable by: A vendor willing to pay themselves. Every condition is checked against a third party's ledger rather than the vendor's word, and money has to genuinely move through a processor in a Stripe-verified live account — but a vendor prepared to spend real money on a lie can still reach it. Read this as corroboration, not as immunity.

tier 4customer counter-signed

The customer reviewed this exact usage summary and approved it, at a link delivered to an address on their own domain. The vendor never held that link.

Forgeable by: Nobody, without control of a mailbox at the customer's own domain. A vendor who registers a domain and invents a company on it controls both ends — fraud scoring, not this tier, is the backstop for that.

That list is rendered from the same definition the discovery document publishes, so this page cannot describe a ladder we do not serve.

Your asserted tier is a ceiling, never a floor

A customer record carries a tier you set. Setting it to 2 does not make a claim tier 2. It caps what the evidence is allowed to publish, and the evidence decides the rest. Concretely, and in this order:

  • If the vendor's domain is not DNS-verified, the published tier is 0, whatever you asserted and whatever was observed. Without domain control, an observation is only the vendor asserting.
  • If nothing at all was observed for that domain in the window, the published tier is 0. A tier is a statement about evidence, and there is none.
  • Otherwise the published tier is the one you asserted. Tiers 1 and 2 are therefore your own claim, released only once observation and domain control back it. The verified boolean comes from the same record and is released by the same two gates, so at those tiers it is your word, gated, rather than an independent finding.

An unmeasurable window fails toward the weaker claim rather than the stronger one. A failed telemetry read, an unconfigured datastore and a genuinely empty window all leave the same mark: not observed, so tier 0.

Two tiers are not capped by your assertion

Tier 4 short-circuits everything. If the customer has counter-signed, the claim is tier 4 before the domain and observation gates are even reached. That is the entire point of it: a counter-signature does not travel through the vendor's domain, script or pipeline, which is what makes it the one tier a vendor cannot forge. Capping it by a vendor-set number would be capping the one piece of evidence that did not come from the vendor.

Tier 3 is not capped either, for the same reason: it is read from a third party's ledger rather than from anything you typed. A vendor understating their own tier should not suppress that. Tier 3 still sits below the observation and domain gates, because money proves a commercial relationship and not that the product was used.

What tier 3 actually requires

It used to require an active subscription, which was not enough and was not honest. A subscription is what you configured, not what anyone paid: a $0 recurring price reaches active the moment it is created, with no payment method attached and no money involved, and so does a 100%-off coupon. A signed tier-3 attestation naming any company you like cost nothing to manufacture in your own account. Every condition below is now checked instead:

  • The key is live mode. A test-mode key reports real counts and stores nothing, because test payments are invented by definition.
  • The subscription carries a real recurring price above zero on a real billing interval. The floor is simply “above zero”: any larger figure would be denominated in one currency's minor unit and would mean something different in yen, and it would exclude small customers who are real customers. The cost of forgery is the next bullet, not the size of the number.
  • An invoice actually settled against it — paid, for a non-zero amount, with a charge or payment intent behind it. An invoice marked paid by hand (Stripe's paid_out_of_band) is you asserting payment, so it does not count, and it is reported back to you as such rather than dropped.
  • That payment is recent relative to the billing interval: about a billing period plus a grace window for retries. A subscription that stays active for years while nothing is collected stops publishing as paid.
  • contract_since is dated from the first settled invoice, never from the subscription's start date. A start date is a field you set, and Stripe accepts a backdated one, so tenure read from it was settable to any year you liked.

Because of the invoice read, a restricted key now needs read access to Invoices as well as Subscriptions and Customers. A key without it fails the sync with that instruction rather than falling back to the weaker evidence: a corroboration check you can switch off by removing a permission is not a corroboration check.

Evidence expires. The sync runs hourly, and payment evidence older than a day stops being published — not as a claim that the customer stopped paying, but as an honest refusal to keep asserting something nothing has confirmed since yesterday. Repeated sync failures clear the evidence outright. Both exist because disconnecting Stripe is something you control: without them, revoking your own key would freeze the last favourable answer in place for ever, with nothing left in the system that could ever contradict it.

The honest limit: this raises the price of a forged tier 3 from nothing to a real charge through a real processor, in a live Stripe account Stripe has verified, leaving a record in your own books. It does not make it impossible. A vendor willing to pay themselves can still reach tier 3, and nothing here binds the Stripe account to the vendor in the first place — the key is pasted in, not granted through Connect. Read tier 3 as corroboration by a third party's ledger, not as immunity.

One more caveat. Tier 3 has never run against a live-mode Stripe key in production. The publishing half is tested against the real schema with a live-mode flag, but a test-mode key deliberately stores nothing, so no production attestation has ever carried real payment evidence.

The aggregate document uses a narrower rule of its own: tier 2 when anything at all was observed, tier 0 when nothing was.

6.Verifying a proof yourself

Nothing on this site asks you to trust it. The verifier is a single standalone script with no dependencies, using only Node built-ins, short enough to read before you run it.

It deliberately shares no code with this service. It re-implements canonicalisation and signature checking from the published description rather than importing ours, because a verifier built on the producer's own canonicaliser cannot detect the one bug that matters, which is the producer and the specification disagreeing. Agreement between two independent implementations is the only agreement worth anything here.

Fetch what you want to check, and the keys it claims to be signed with:

curl -s https://app.letterprove.com/attest/<vendor>/chain > chain.json
curl -s https://app.letterprove.com/.well-known/letterprove-jwks.json > jwks.json

Then run the verifier over them, from a checkout of the repository:

git clone https://github.com/letterstory/Letterprove
cd Letterprove
npm run verify -- ./chain.json --jwks ./jwks.json

There is nothing to install first. The script imports only Node built-ins, so a clone is enough, and node scripts/verify.mjs works just as well as the npm script. It also takes URLs directly, in which case it fetches the JWKS from the same origin as the proof unless you pass --jwks:

npm run verify -- https://app.letterprove.com/attest/<vendor>/chain

Point it at a chain rather than a single document. A lone attestation is a window into a history, and its prev_hash has nothing to be checked against; given the chain, the verifier checks every link.

For each entry in the chain it does three things:

  • Canonicalises every field except the signature. Object keys sorted, array order preserved, integers only. A non-integer number is a hard error rather than a rounding, because floats would break byte agreement between two implementations.
  • Finds the published key whose id matches the document's key_id and checks the Ed25519 signature over those exact bytes. Retired keys are published forever, so an old proof still verifies years after its key leaves rotation.
  • Recomputes the hash of the entry and checks the next entry's prev_hash against it, starting from 64 zeroes.

It then prints the provenance tier from the head of the chain, the method link, and a warning if anything in the chain was signed with a development key. It exits non-zero if any entry failed.

What agreement proves

A passing run proves two things and no more: this document is ours, and it has not been altered since we signed it, and the history behind it has not been quietly restated.

It proves nothing whatsoever about how good the underlying evidence is. The tier says that, and the tier is the claim. A perfectly valid signature over a tier-0 body asserts only that the vendor said so. This is the more dangerous direction of failure: an agent that cannot read the tier does not distrust us, it over-trusts us, verifying a signature and reporting “attested” about a sentence the vendor typed. Read both, always.

One more warning to take seriously. Anything signed by a development key is a demonstration and not evidence, because the development key is published and anyone can forge under it. Development deployments say so in a banner, in the discovery document, in the key id, and in the verifier's own output.

The verify page renders the live discovery document, and the keys page renders the current JWKS, if you would rather read either in prose first.

7.Endpoint reference

Every proof endpoint is public, unauthenticated and CORS-open, answers JSON as UTF-8, and carries x-letterprove: on. A proof nobody can fetch cross-origin is not proof.

All of them resolve only once you have published. Before that they answer 404, indistinguishably from a vendor who does not exist.

PathServes
/proofs/<vendor>The vendor report, as a page or as JSON
/attest/<vendor>The aggregate attestation. Counts, no names
/attest/<vendor>/chainThe full signed aggregate history, oldest first
/attest/<vendor>/<customer>One customer's current attestation, if they consented to be named
/attest/<vendor>/<customer>/chainThat customer's full signed history
/.well-known/letterprove.jsonDiscovery. Start here
/.well-known/letterprove-jwks.jsonEvery public key a proof here has ever been signed with
/attest.jsThe collection script itself

All relative to https://app.letterprove.com, the deployment serving this page.

/proofs/<vendor> is content-negotiated. A browser gets the human page; a .json suffix or an explicit JSON Accept with no HTML alternative gets the machine document, which carries the summary, each customer's current attestation, and the tier ladder inline so an agent that landed there directly can weight what it reads without a second fetch. /attest/<vendor> and the per-customer path both accept a .json suffix as well, for clients that cannot set a header.

The two collection endpoints are called by the script, not by you. They live under /api/v1/, and both answer with the diagnostic header described in the install section.

GET  https://app.letterprove.com/api/v1/config?k=<publishable key>
POST https://app.letterprove.com/api/v1/observe

Start from the discovery document if you are writing an agent. It is built by the same function that renders the verify page, and it carries the signing algorithm and mode, the JWKS location, a commit-pinned link to the canonicalisation rules, a link to the verifier, the full tier ladder, and every published proof with its aggregate and chain URLs. One fetch gets you from “this host publishes proof” to a verified claim without reading any of this page.

Something here disagreeing with what the service actually does is a bug. The code is open, and it is the authority. Privacy Policy · Terms of Service