Signed receipts: check that a person answered
Every answer comes with a signed receipt you, your client or an auditor can check against our public keys, without trusting our servers.
Last checked against the receipt code.
On this page
What a receipt is
A receipt is a compact JSON Web Signature (JWS) over a small set of claims, signed with ES256 (ECDSA on P-256). It records that a verified member answered a specific question, about a specific image or text, with a specific answer, through an assignment we issued. Fetch one with GET /v1/receipts/{answer_id} or the MCP tool get_receipt.
Receipts are signed, not stored on a blockchain. Anchoring batches of receipts on a public ledger is designed but switched off until it has been reviewed.
The protected header names the key and the receipt type:
{ "alg": "ES256", "kid": "pfy-2026-…", "typ": "pfy-receipt+jws" }Public keys are published as a JSON Web Key Set at https://pennyforyours.com/.well-known/jwks.json (also /v1/jwks.json). The set holds public keys only. When the signing key changes, the previous public key stays in the set, so older receipts keep verifying. Sandbox receipts use a different key that is not in this set (see sandbox receipts).
The claims
{
"iss": "https://pennyforyours.com",
"sub": "01M4402BTZ9M2C1KXP2GXFY5DD",
"iat": 1791134457,
"v": 1,
"qh": "216eaeeabc5d71cb…",
"ah": "6881c2f7d74ba28e…",
"ans": "a4e425c2957aba46…",
"asg": "oHpHlkQvg6qRxtrcNzd-VLHl",
"st": "accepted",
"lvl": "H0",
"chk": ["passkey", "no_paste", "time_recorded"],
"fv": true,
"t": { "submitted": "2026-10-04T17:40:57.810Z", "accepted": "2026-10-04T17:40:57.822Z" },
"m": "3f9c0d…",
"sbx": false
}| Claim | What it binds |
|---|---|
qh | Question hash: SHA-256 of the canonical JSON of the question ID, the final question text, the options (with any option image hashes), the audience, the question type and its catalogue version, and the policy version. Change any of these and the hash changes. |
ah | Asset hash: SHA-256 of the exact bytes members were shown. For an image, that is the file after we strip metadata (EXIF, XMP, text chunks), which is the sha256 the upload returned. For a text question, it is the hash of the prompt and options as rendered. |
ans | Answer hash: SHA-256 of the answer content (the choice, the rating and the written line). |
asg | Assignment nonce: the single-use token issued when the question was shown to that member. An answer cannot be replayed onto another assignment. |
st | Status: accepted, held (waiting for a person to review it) or void (rejected by a person). |
sub | The answer ID. |
lvl | The member's verification level when they answered (H0 is a passkey-verified account; higher levels add stronger checks). |
chk | Checks this answer passed, for example passkey, no_paste, time_recorded and gold. Only checks that actually ran are listed. no_paste comes from a signal the member's browser reports, so treat it as supporting evidence, not proof. time_recorded means our server's own clock saw at least the minimum time for the question type between handing out the question and receiving the answer, and the time the browser reported fits inside that window; it says the answer was not rushed, not who wrote it. Sandbox answers say ["synthetic"] only. |
fv | First view: whether this was the first time this member saw this image. |
t | When the answer was submitted and accepted. |
m | Member pseudonym, salted per asker, so you can tell two of your answers came from the same person without being able to link them across askers. |
sbx | true for sandbox answers from synthetic test members, signed with the separate sandbox key. Never treat these as proof of a person. |
iss, iat, v | Issuer, issue time (seconds) and receipt schema version. |
Verify it yourself
No API key is needed: fetch the key set and check the signature with any JOSE library that supports ES256. In Node, with jose (MIT):
import { compactVerify, createRemoteJWKSet, decodeProtectedHeader } from 'jose';
const keys = createRemoteJWKSet(new URL('https://pennyforyours.com/.well-known/jwks.json'));
export async function verifyReceipt(jws) {
const { typ } = decodeProtectedHeader(jws);
if (typ !== 'pfy-receipt+jws') throw new Error('Not a PennyForYours receipt');
const { payload } = await compactVerify(jws, keys, { algorithms: ['ES256'] });
return JSON.parse(new TextDecoder().decode(payload)); // the claims
}The repository's examples/verify-receipt.mjs does the same from the command line and can compare an image file with ah.
Verify with the API
If you would rather not handle keys, send the receipt to POST /v1/receipts/verify (or the MCP tool verify_receipt). Any valid key works, so you can check a receipt someone else gave you. Add the values you already hold to confirm the receipt is about exactly that question, image or answer.
curl https://pennyforyours.com/v1/receipts/verify \
-H "Authorization: Bearer $PFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "jws": "eyJhbGciOiJFUzI1NiIs…", "expected": { "asset_sha256": "6881c2f7…" } }'{
"valid": true,
"signature_valid": true,
"sandbox": false,
"claims": { "sub": "01M4402BTZ9M2C1KXP2GXFY5DD", "st": "accepted", … },
"mismatches": [],
"reason": null,
"jwks_url": "/.well-known/jwks.json"
}valid is true only for a live receipt whose signature checks out and where every value you supplied matches. A good signature with a different image comes back as signature_valid: true, valid: false and mismatches: ["ah"].
Sandbox receipts
Sandbox keys (pfy_test_) get answers from synthetic test members, and their receipts are kept apart from real ones so they can never pass as proof of a person:
- They are signed with a separate sandbox key whose
kidstartssbx-. That key is published only athttps://pennyforyours.com/v1/sandbox/jwks.json, never in the production key set, so a sandbox receipt fails verification againsthttps://pennyforyours.com/.well-known/jwks.json. - Their claims say
sbx: trueandchk: ["synthetic"]. POST /v1/receipts/verifyandverify_receiptreturnvalid: falseandsandbox: truefor them, while still reportingsignature_validand anymismatches, so you can test your integration end to end.- The receipt response's
jwks_urlnames the right key set for each receipt.
Checking the image
Compare ah with the sha256 returned when you uploaded the image. Hashing your original file only matches when it had no metadata, because we strip location and camera data before anyone sees it.
Status and versions
A receipt is issued when an answer is accepted or held. If a person later reviews a held answer, a new version is issued with st set to accepted or void; the API always returns the latest version. Receipts are signed but not yet anchored on a public ledger; anchor is null during early access.
What it does not prove
- A receipt shows which checks an answer passed. No single check proves a human, which is why we use several layers and a person reviews anything flagged. See human proof and AI use.
- It does not identify the member. The pseudonym
mis deliberately not linkable across askers. - Sandbox receipts (
sbx: true, sandbox key) come from synthetic test members and prove nothing about people.