Reading the purposes
Every route and code below is in the reference.
GET /consent/purposesIt answers every purpose this Worker asks consent for, email’s and yours, each with its summary, current notice version, data classes, whether it is explicit, its minimum method and whether a withdrawal must be written. Reading sets nothing. A page that only asks leaves no identifier behind.
Recording a choice
POST /consent/grants
POST /consent/withdrawals{ "purpose": "email.marketing", "noticeVersion": 1, "method": "click", "surface": "banner" }Anyone may call them, signed in or not. A signed-in caller is recorded as their account. A visitor is recorded against an anonymous device id, minted on their first choice and held in a first-party cookie, or sent by a phone in a header of its own (Visitors). Each answers 201 with the row it appended and whether hasConsent now answers yes.
noticeVersionmust be the purpose’s current one. An older one isconsent/stale_notice, 409: the person has to read the new notice.surfaceis where the choice was made, a short lowercase name:settings,banner,signup.identityis how your flow verified the person:session,verified_email,in_person,provider, ornone. It defaults tosessionfor an account andnonefor a visitor, and a visitor cannot claimsession.expiresAt, on a grant, is when it stops counting. It must be in the future.
Both writes wear the same-origin gate and a per-IP rate limit. The visitor cookie and a web session are both ambient, so a cross-site form cannot record a choice for somebody. A bearer request carries no ambient credential, so the gate lets it through, as it does on every route. A phone’s signed-out request carries no cookie and names its visitor in a header a cross-site form cannot set, so nothing in it is ambient either, and the gate lets it through too. The rate limit applies to all of them, ahead of everything else. A grant needs no account, so the limit is what stops a script filling a record that can never be deleted.
Signed consent
A purpose with a minimumMethod above click takes a signature, and the grant carries the exact document the person signed.
{
"purpose": "clinic.records",
"noticeVersion": 1,
"method": "typed",
"surface": "intake",
"document": "I authorize the clinic to keep my health record…",
"typedName": "Ada Lovelace",
"expiresAt": "2027-10-28T00:00:00Z",
"representative": { "name": "Ada Byron", "relationship": "parent" }
}documentis the rendered text, required for every method butclick. It is kept once per SHA-256 and can be produced again byte for byte. Aclickrecords the hash and keeps no text.typedtakestypedName, encrypted at rest.drawntakesdrawn: { image, type }, a base64 PNG or SVG, stored in theCONSENT_EVIDENCEbucket under its own hash.providertakesprovider: { name, envelopeId, certificate? }from your own e-signature integration. The certificate is stored in the bucket by hash. No provider SDK ships here.representativeis a parent, guardian or personal representative signing for the subject, and their name is encrypted at rest.
A grant below the minimum is refused, consent/method_below_minimum. One missing what its method needs is consent/evidence_missing.
Withdrawing
A withdrawal takes the same fields, without noticeVersion, expiresAt or a representative. A click withdraws anything, so withdrawing is never harder than granting, unless the purpose sets writtenWithdrawal. Then a withdrawal below the grant’s own method is consent/withdrawal_below_grant: a typed grant needs a typed withdrawal, as a HIPAA revocation must be in writing.
Asking
import { composedConsent } from "@pithy-sh/core/src/consent/reader";
const consent = composedConsent(capabilities, env);
if (await consent?.hasConsent({ kind: "account", id: userId }, "clinic.records")) {
// process under consent
}hasConsent is the one question. It answers yes only for a grant under the current notice version, at or above the minimum, not expired and not withdrawn since. Nothing imports @pithy-sh/consent to ask: the reader is a core seam. With no consent composed, composedConsent is null.
A person’s own record
GET /consent/me
GET /consent/me/documents/:hash/me answers where the caller stands on every purpose and every row behind it. The document route serves a signed document only when the caller’s own record names it.
pithy ui add react --consent and pithy ui add react-native --consent scaffold the screen that reads them: every purpose on a row, a click purpose agreed to and withdrawn on the same row, and a written withdrawal taken at its grant’s grade. For a grant signed through a provider, it says to withdraw where it was signed. The paths on this page are at the default basePath. Both screens call under the one you compose, read from consent’s client projection, so with consent({ basePath: "/privacy" }) they call /privacy/me.
Visitors
The cookie is set only once a visitor chooses. pithy_consent_visitor is first-party, HttpOnly and SameSite=Lax, and lives visitorWindowDays. The first request that is signed in and carries it merges the visitor into the account: one row in pithy_consent_merges, one consent/merged event, and the cookie is cleared. The visitor’s own rows are never rewritten.
A phone holds no cookie, so a signed-out app sends its own visitor id. It mints a random UUID once, for consent alone, keeps it in its secure store, and sends it as x-pithy-consent-visitor on consent’s requests, every request under the basePath consent’s projection names; CONSENT_VISITOR_HEADER in @pithy-sh/consent/src/client/api names it, and the transport pithy ui add react-native scaffolds does all of this. The Worker reads the header only from a request that carries no cookie at all, so a page’s script can never use it to speak over a browser’s cookie. It is never the device id auth registers, which operators can list and the audit trail records, so knowing a phone’s device id reads and writes nothing here.
A phone keeps its id after the merge. The first signed-in request that carries it merges the visitor into the account, as a cookie’s would. From then on that id is no visitor: signed out on that phone, the app reads as nobody, and a choice made there is tied to no device and cannot reach the account.
A visitor never merged stops counting after visitorWindowDays. Their rows are kept, because the record is append-only.
With consent composed, pass the recipient’s subject when you enqueue.
const { jobId, status, consentWithheld } = await enqueue(env, {
template: "newsletter",
to: user.email,
consentSubject: { kind: "account", id: user.id },
});A marketing template goes only to a subject consenting to email.marketing. Otherwise the message is born suppressed, the result carries consentWithheld: "email.marketing", and its event reads consent:email.marketing. Opens and clicks are tracked only for a subject consenting to email.tracking. A message with no consentSubject is withheld if it is marketing and untracked either way. Transactional mail is never gated.
A withdrawal from email.marketing suppresses the address for marketing, permanently, so it also holds mail already scheduled. Granting it again lifts that suppression and no other: a bounce, a complaint, an unsubscribe link and a manual suppression all stand. A withdrawal from email.tracking stops tracking on mail already queued.
From a management client
A management client reads the record behind consent:read, and can change nothing.
| Route | Answers |
|---|---|
GET /consent/control-plane/purposes | Every purpose, with counts per notice version: subjects whose latest row is a grant, those whose latest is a withdrawal, and expired, the grants among them whose expiry has passed |
GET /consent/control-plane/expiring?within=<days> | Each subject’s latest grant per purpose whose expiry has passed or falls within the window, soonest first, paged by cursor and limit. within defaults to 30 days, from 1 to 366 |
GET /consent/control-plane/subjects/:kind/:subjectId | One subject’s standing, every row with the names opened, and the merges behind it. :kind is account or visitor |
GET /consent/control-plane/documents/:hash | A signed document, byte for byte, as text/plain |
GET /consent/control-plane/evidence/:hash | A drawn signature or a certificate, as an attachment that will not render on the Worker’s origin |
The counts read the record the way hasConsent does. A person is counted once, by their latest row for the purpose, and a visitor merged into an account is counted as that account. grants still counts a grant past its expiry, so grants - expired is what stands.
The expiring list names a person the way the counts do, so it agrees with expired. Each entry is the subject, the purpose, the notice version, the method, expiresAt, expired and documentHash, and never the typed name or the representative. A grant with no expiry never appears, and neither does one withdrawn since. An already expired grant is listed whatever the window.
There is no write. Consent is the person’s to give and to take back, so no management credential can grant or withdraw it.
What is recorded
Every grant, withdrawal and merge is an audit event when audit is composed: consent/granted, consent/withdrawn, consent/merged. A grant or withdrawal event carries the record’s id, the purpose, the notice version, the method, the surface and the document’s hash. It never carries a name.