REST API reference (/v1)
Every operation, rendered from the same OpenAPI 3.1 document the API serves at /v1/openapi.json, so this page and the API can’t disagree.
Last checked against the OpenAPI document.
On this page
Basics
- Base URL:
https://pennyforyours.com/v1 - OpenAPI document:
https://pennyforyours.com/v1/openapi.json. Import it into Postman, Insomnia or a code generator. - Auth:
Authorization: Bearer pfy_test_…on everything except health, the OpenAPI document and the receipt keys. - Format: JSON in snake_case, amounts in integer pence (GBP), times in ISO 8601 UTC, IDs are ULIDs. Errors are RFC 9457 problem documents.
Ask verified real people quick questions about an image or a short text, and get structured answers with a signed receipt for each. Sandbox only during early access: keys start pfy_test_, questions are answered by synthetic members and nothing is charged. Priced per answer by answer type (launch example: a quick take from 30p a person plus 50p a question); free during early access. Every call needs an operator API key; keyless access is not available. Not for regulated advice, collecting personal data, or writing reviews. The same operations are available as MCP tools at /mcp. Rules for agents: /agents/. Which checks run on answers: /developers/human-proof/.
Endpoints
| Method and path | What it does |
|---|---|
GET /health | Liveness |
GET /openapi.json | This document |
GET /jwks.json | Public keys for receipt verification (JWKS, also at /.well-known/jwks.json) |
GET /sandbox/jwks.json | Public keys for sandbox receipts only (kid starts sbx-) |
GET /question-types | Question types you can ask |
GET /topics | Topic codes you can tag a question with |
POST /quotes | Price and time estimate for a question |
POST /uploads | Upload an image (PNG, JPEG or WebP) or a sound clip (MP3, M4A, AAC, Ogg Opus or Vorbis, WAV) |
POST /questions | Ask a question |
GET /questions/{id} | State and results, with optional long-polling |
GET /questions/{id}/events | Server-Sent Events stream of answers and state changes (up to 50 s, resumable) |
POST /questions/{id}/ratings | Rate answers and record your final choice |
POST /questions/{id}/cancel | Cancel before the first answer |
GET /receipts/{id} | Signed receipt for an answer (JWS, ES256) |
POST /receipts/verify | Check any receipt against the published keys |
GET /balance | Balance, caps and limits (sandbox: nothing is charged) |
GET /usage | Daily usage for a month |
GET/v1/health
Liveness No key needed.
Responses200OK
GET/v1/openapi.json
This document No key needed.
Responses200OpenAPI 3.1 document
GET/v1/jwks.json
Public keys for receipt verification (JWKS, also at /.well-known/jwks.json) No key needed.
Responses200JSON Web Key Set (public keys only)
GET/v1/sandbox/jwks.json
Public keys for sandbox receipts only (kid starts sbx-) No key needed.
Sandbox receipts are signed with a separate key that is not in the production JWKS, so they can never pass as proof of a real person. Use this set only to test your verification code.
Responses200JSON Web Key Set (public keys only)
GET/v1/question-types
Question types you can ask
Responses200Catalogue400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
GET/v1/topics
Topic codes you can tag a question with
Members choose which questions to answer from a board they can filter and sort by pay, time, topic and answer type. Tag your question with up to 3 of these so people who like the subject find it.
Responses200Topic catalogue400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
POST/v1/quotes
Price and time estimate for a question
Free during early access: charge_minor is 0 and launch_example_total_minor shows what it would cost later.
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | Yes | Question type code from GET /v1/question-types, e.g. quick_pick. |
prompt | string | Yes | The question. Links are refused: questions with URLs are not accepted yet. Upload a screenshot instead. 3–280 chars |
options | string[] | No | 2–3 options for quick_pick; none for other types. up to 3 items |
asset_id | string | null | No | An upload_id from POST /v1/uploads: an image, or one sound clip. |
pool | integer | Yes | How many verified people should answer. 5–50 |
deadline_hours | integer | No | 1–168 |
quote_id | string | null | No | |
offer_pence | integer | No | Your offer per answer in pence. Every person who answers is paid it; our fee goes on top. Default: the launch example pay for the type. Refused (422 offer-below-floor) below the fair pay floor. 1–2000 |
required_tier | integer | No | Lowest member tier to ask: 1 everyday (default), 2 proven, 3 qualified (offers from £1). 4 is the marketplace lane and is not open (409 marketplace-lane-closed). Long answer types raise it to their own minimum (In-depth: 2). 1–4 |
topics | string[] | No | Up to 3 topic codes from GET /v1/topics. Members choose which questions to answer from a board they can filter by topic, so tags help the right people find yours. up to 3 items |
expected_minutes | integer | No | Long answer types only (In-depth: 10–20). How long you expect each answer to take; it sets the fair pay floor (min_offer_pence) and the time members see before they choose. 1–60 |
option_asset_ids | string[] | No | Which sounds better: two upload_ids of sound clips, one per option, with type quick_pick and exactly two options. Leave out asset_id. up to 2 items |
201Quote400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
POST/v1/uploads
Upload an image (PNG, JPEG or WebP) or a sound clip (MP3, M4A, AAC, Ogg Opus or Vorbis, WAV)
Send the raw bytes as the body. The limit is 4 MB, which keeps it under the hosting platform cap on binary request bodies (about 4.5 MB). Images: metadata (EXIF, XMP, text chunks) is stripped before hashing. Sound clips: up to 90 seconds, read from the file itself; the type is checked by content, not by name; tags (ID3, MP4 and Ogg comments, RIFF INFO) are removed before hashing; a person listens to every clip before members hear it. The returned sha256 is the ah value receipts will carry. Links are not accepted: URL questions are disabled.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
X-Asset-Alt | header | string | No | Images: a description for screen-reader users. Sound clips (required): one sentence, 10 to 200 characters, saying what the clip is, percent-encoded UTF-8. |
X-Asset-Transcript | header | string | No | Sound clips: an optional transcript, up to 1,000 characters, percent-encoded UTF-8. Shown to members beside the player. |
X-Asset-Rights | header | "confirmed" | No | Sound clips (required): "confirmed" means you own the audio or have permission to use it. Without it the upload is refused (422 rights-not-confirmed). |
Body: raw bytes as image/pngimage/jpegimage/webpaudio/mpegaudio/mp4audio/aacaudio/oggaudio/wav.
201Uploaded; screened before use400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)413Problem (RFC 9457)415Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
POST/v1/questions
Ask a question
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
Idempotency-Key | header | string | Yes | 8–100 chars |
X-Agent-Name | header | string | No | Shown to members as "asked through an AI tool". |
| Field | Type | Required | Notes |
|---|---|---|---|
type | string | Yes | Question type code from GET /v1/question-types, e.g. quick_pick. |
prompt | string | Yes | The question. Links are refused: questions with URLs are not accepted yet. Upload a screenshot instead. 3–280 chars |
options | string[] | No | 2–3 options for quick_pick; none for other types. up to 3 items |
asset_id | string | null | No | An upload_id from POST /v1/uploads: an image, or one sound clip. |
pool | integer | Yes | How many verified people should answer. 5–50 |
deadline_hours | integer | No | 1–168 |
quote_id | string | null | No | |
offer_pence | integer | No | Your offer per answer in pence. Every person who answers is paid it; our fee goes on top. Default: the launch example pay for the type. Refused (422 offer-below-floor) below the fair pay floor. 1–2000 |
required_tier | integer | No | Lowest member tier to ask: 1 everyday (default), 2 proven, 3 qualified (offers from £1). 4 is the marketplace lane and is not open (409 marketplace-lane-closed). Long answer types raise it to their own minimum (In-depth: 2). 1–4 |
topics | string[] | No | Up to 3 topic codes from GET /v1/topics. Members choose which questions to answer from a board they can filter by topic, so tags help the right people find yours. up to 3 items |
expected_minutes | integer | No | Long answer types only (In-depth: 10–20). How long you expect each answer to take; it sets the fair pay floor (min_offer_pence) and the time members see before they choose. 1–60 |
option_asset_ids | string[] | No | Which sounds better: two upload_ids of sound clips, one per option, with type quick_pick and exactly two options. Leave out asset_id. up to 2 items |
200Idempotent replay201Created400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
GET/v1/questions/{id}
State and results, with optional long-polling
Returns the full results snapshot. To wait for news, pass the cursor from your last response and wait_seconds (up to 25): the call returns as soon as anything changes (an answer, a state change or a summary) or when the wait runs out, with changed set accordingly. Without wait_seconds, poll every poll_after_seconds.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
id | path | string | Yes | Question ID |
cursor | query | integer | No | The cursor from an earlier response. at least 0 |
wait_seconds | query | integer | No | Long-poll: wait up to this long for something newer than cursor. 0–25 |
200Results400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
GET/v1/questions/{id}/events
Server-Sent Events stream of answers and state changes (up to 50 s, resumable)
Streams answer, state, summary and heartbeat events. Each event id is the question event sequence; reconnect with Last-Event-ID (or last_event_id) to resume without missing anything. The stream closes after at most 50 seconds with a retry hint, and ends after the summary once the question finishes. A finished question with nothing new returns 204. Limited to about five open streams per key.
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
id | path | string | Yes | Question ID |
Last-Event-ID | header | string | No | |
last_event_id | query | integer | No | For clients that cannot set headers. at least 0 |
max_seconds | query | integer | No | 1–50 |
200Event stream204Finished, nothing new400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
POST/v1/questions/{id}/ratings
Rate answers and record your final choice
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
id | path | string | Yes | Question ID |
| Field | Type | Required | Notes |
|---|---|---|---|
ratings | object[] | No | |
final_choice | integer | No | 1–3 |
204Recorded400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
POST/v1/questions/{id}/cancel
Cancel before the first answer
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
id | path | string | Yes | Question ID |
200Cancelled; final snapshot400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
GET/v1/receipts/{id}
Signed receipt for an answer (JWS, ES256)
Verify live receipts with the keys at /.well-known/jwks.json. Sandbox receipts are signed with a separate key (kid starts sbx-, published at /v1/sandbox/jwks.json), carry sbx: true and chk: ["synthetic"], and never verify against the production keys. Claims: qh (question hash), ah (SHA-256 of the asset shown), ans (answer hash), asg (assignment nonce), st (accepted, held or void).
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
id | path | string | Yes | Answer ID |
200Receipt400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
POST/v1/receipts/verify
Check any receipt against the published keys
Verifies the ES256 signature against /.well-known/jwks.json and, if you send expected values, checks them against the signed claims. You can do the same offline with any JOSE library and the JWKS. A sandbox receipt returns valid: false and sandbox: true, with signature and claim checks still reported.
| Field | Type | Required | Notes |
|---|---|---|---|
jws | string | Yes | Compact JWS receipt. |
expected | object | No |
200Verification result (valid is false when the signature or a supplied value does not match)400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
GET/v1/balance
Balance, caps and limits (sandbox: nothing is charged)
Responses200Balance400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
GET/v1/usage
Daily usage for a month
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
period | query | string | No | Month as YYYY-MM (UTC). Defaults to the current month. |
200Usage400Problem (RFC 9457)401Problem (RFC 9457)403Problem (RFC 9457)404Problem (RFC 9457)409Problem (RFC 9457)422Problem (RFC 9457)429Problem (RFC 9457)
Schemas
Results
| Field | Type | Notes |
|---|---|---|
id | string | |
type | string | |
prompt | string | |
options | object[] | |
topics | object[] | |
expected_minutes | integer | null | |
required_tier | integer | |
cursor | integer | Pass back to wait for anything newer. |
changed | boolean | Only on long-poll responses: whether anything changed since cursor. |
state | "draft" | "screening" | "awaiting_payment" | "open" | "suspended" | "closed" | "cancelled" | "rejected" | |
pool | integer | |
accepted | integer | |
held_for_review | integer | |
counts | object | |
summary | object | null | |
answers | object[] | |
poll_after_seconds | integer | null | |
sandbox | boolean | |
audio_clips | object[] | Only on questions with sound clips: each clip with its description, transcript, sha256 and, for which-sounds-better, the option it stands for. |
Quote
| Field | Type | Notes |
|---|---|---|
quote_id | string | |
offer_pence | integer | What each person who answers is paid. Members keep the full offer; the platform fee is added on top. |
min_offer_pence | integer | The lowest offer allowed for this type and tier (the fair pay floor). |
fee_per_answer_minor | integer | Our fee per answer, on top of the offer. |
holiday_pay_per_answer_minor | integer | Holiday pay we add for the member on each answer (our cost, not yours). |
required_tier | integer | |
topics | string[] | |
expected_minutes | integer | null | |
pricing_mode | "launch_default" | "offer" | |
eligible_pool | object | |
price_per_answer_minor | integer | |
request_fee_minor | integer | |
launch_example_total_minor | integer | |
charge_minor | integer | |
free_mode | boolean | |
currency | "GBP" | |
eta | object | |
expires_at | string |
Receipt
| Field | Type | Notes |
|---|---|---|
answer_id | string | |
receipt_id | string | |
version | integer | |
status | "accepted" | "held" | "void" | |
jws | string | |
kid | string | |
jwks_url | string | |
claims | ReceiptClaims | Signed claims. Hashes are lowercase hex SHA-256. |
anchor | null |
ReceiptClaims
Signed claims. Hashes are lowercase hex SHA-256.
| Field | Type | Notes |
|---|---|---|
iss | string | |
sub | string | Answer ID |
iat | integer | |
v | integer | |
qh | string | Canonical hash of question text, options, audience, catalogue version and policy version |
ah | string | SHA-256 of the exact image (after metadata stripping) or rendered text shown |
ans | string | Hash of the answer content |
asg | string | Assignment nonce |
st | "accepted" | "held" | "void" | |
lvl | string | Personhood level at answer time |
chk | string[] | Checks recorded: passkey, no_paste (reported by the answering browser), time_recorded (the server clock saw at least the minimum time from claim to submit, and the time the browser reported fits inside it), gold; synthetic for sandbox answers |
fv | boolean | First view of this asset by this member |
t | object | |
m | string | Member pseudonym, salted per asker |
sbx | boolean |
Verification
| Field | Type | Notes |
|---|---|---|
valid | boolean | True only for a live receipt whose signature and supplied values check out. Always false for sandbox receipts. |
signature_valid | boolean | |
sandbox | boolean | True for sandbox receipts (synthetic members, sandbox key). Never proof of a real person. |
claims | ReceiptClaims | null | |
mismatches | string[] | |
reason | string | null | |
jwks_url | string |
Balance
| Field | Type | Notes |
|---|---|---|
mode | "test" | "live" | |
sandbox | boolean | |
currency | "GBP" | |
free_mode | boolean | |
balance_minor | integer | |
charged_this_month_minor | integer | |
launch_example_value_this_month_minor | integer | |
questions_this_month | integer | |
answers_this_month | integer | |
caps | object | |
limits | object | |
note | string |
Usage
| Field | Type | Notes |
|---|---|---|
period | string | |
from | string | |
to | string | |
mode | string | |
currency | "GBP" | |
charged_minor | integer | |
totals | object | |
rows | object[] |