Signed receipts: check that a person answered

By PennyForYours

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
}
Receipt claims (version 1)
ClaimWhat it binds
qhQuestion 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.
ahAsset 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.
ansAnswer hash: SHA-256 of the answer content (the choice, the rating and the written line).
asgAssignment nonce: the single-use token issued when the question was shown to that member. An answer cannot be replayed onto another assignment.
stStatus: accepted, held (waiting for a person to review it) or void (rejected by a person).
subThe answer ID.
lvlThe member's verification level when they answered (H0 is a passkey-verified account; higher levels add stronger checks).
chkChecks 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.
fvFirst view: whether this was the first time this member saw this image.
tWhen the answer was submitted and accepted.
mMember 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.
sbxtrue for sandbox answers from synthetic test members, signed with the separate sandbox key. Never treat these as proof of a person.
iss, iat, vIssuer, 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 kid starts sbx-. That key is published only at https://pennyforyours.com/v1/sandbox/jwks.json, never in the production key set, so a sandbox receipt fails verification against https://pennyforyours.com/.well-known/jwks.json.
  • Their claims say sbx: true and chk: ["synthetic"].
  • POST /v1/receipts/verify and verify_receipt return valid: false and sandbox: true for them, while still reporting signature_valid and any mismatches, so you can test your integration end to end.
  • The receipt response's jwks_url names 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 m is deliberately not linkable across askers.
  • Sandbox receipts (sbx: true, sandbox key) come from synthetic test members and prove nothing about people.

Questions developers ask

Do I need an API key to verify a receipt?

No. Fetch the public keys from /.well-known/jwks.json and check the signature with any JOSE library that supports ES256. The API's verify endpoint needs a key but works for receipts someone else gave you.

Does a receipt identify the person?

No. It carries a per-asker pseudonym, so you can tell two of your answers came from the same person but can't link them across askers or identify anyone.

What happens when you rotate keys?

The previous public key stays in the key set, so older receipts keep verifying.

Why does a sandbox receipt fail verification?

On purpose. Sandbox receipts are signed with a separate key that isn't in the production key set, and the verify endpoint returns valid: false and sandbox: true, so they can never pass as proof of a person.

Can a receipt change?

A held answer gets a new receipt version when a person reviews it, with st set to accepted or void. The API returns the latest version.

Why doesn't the image hash match my file?

We strip hidden metadata before anyone sees the image. Compare ah with the sha256 returned at upload, not a hash of your original file.

Are receipts on a blockchain?

Not today. Anchoring on a public ledger is designed but switched off until it has been reviewed. Signed receipts work without it.

Stuck?

Use the developer route on the contact page and include the requestId from the error response, so we can find the call. A real person replies, usually within 2 working days.

Contact the developer route