API
Create a stamp, check one, list a log, make an attestation. Hash a file yourself first, sha256, lowercase hex; we never see the file itself.
The API keeps the older names for the two main things: a record is a
stamp here, and a statement is an attestation. The paths and fields will
not change under you.
Machine-readable version: OpenAPI 3.1, for generating a client
or loading into an API tool. The pollypay command line tool in the repository wraps the same calls.
Authenticate
Sign in at https://polly.hadi-rizvi123.workers.dev/login with a passkey or an email link. That sets a session cookie. Every call below runs as that account and needs the cookie, so call these from a browser or keep the cookie jar from a script that signed in the same way.
From a server instead of a browser, use an API key: Authorization: Bearer
pk_... on any request below in place of the cookie. Create one signed in at
https://polly.hadi-rizvi123.workers.dev/developers, or:
POST /api/me/api-keys {"name": "production server", "scope": "write" | "read", "expires_in_days": 90}
→ {"id": "01J...", "name": "...", "key": "pk_...", "prefix": "pk_abc12345", "created_at": ...}
GET /api/me/api-keys → {"api_keys": [{"id", "name", "prefix", "scope", "expires_at", "created_at", "last_used_at"}]}
DELETE /api/me/api-keys/:id
POST /api/me/api-keys/:id/rotate → a new key with the same name, scope and expiry; the old one stops at once
A read key can list and verify but every POST or DELETE with it is refused with
read_only_key; scope defaults to write and keys never expire unless
expires_in_days is given.
The full key is only ever shown once, at creation; store it then. These three routes are session-only, an API key cannot create or revoke other API keys.
Every /api response carries CORS headers for any origin, so the public
endpoints (verify, attested, batch-verify, logs) work from any web page with a plain fetch.
Keys stay on servers; never ship a pk_ key in browser code.
Writes are limited to 60 a minute per account or address, and sign-in requests to 10 a minute. Past
that, and past the free hourly quota with nothing in the balance, you get 429 with a
Retry-After header saying how many seconds to wait.
Teams
Add people to a team and everything below becomes shared: the same stamps, attestations, batches, log, hourly free quota, credits balance, API keys and webhooks. Each person still signs in with their own email or passkey; nobody shares a login. Manage a team at https://polly.hadi-rizvi123.workers.dev/team, or:
POST /api/teams {"name": "Acme Inc"}
GET /api/teams/mine → {"team": {...} | null, "my_role", "members": [...]}
POST /api/teams/invite {"email": "teammate@example.com"} owner or admin
POST /api/teams/invites/:token/accept session required
POST /api/teams/members/:id/remove owner or admin
POST /api/teams/members/:id/role {"role": "admin" | "member"} owner only
POST /api/teams/leave
DELETE /api/teams owner only
An invite is an email link; whoever gets it signs in as that address the normal way, then accepts. Only the owner and admins can invite, remove people, or manage API keys/webhooks/the log handle; any active member can stamp, attest, and batch on the team's quota. Someone removed or who leaves reverts to a solo account with their own prior stamps, keys and webhooks intact, nothing is deleted. All of these routes are session-only.
Create a stamp
POST /api/stamps
Content-Type: application/json
{"hash": "<64 hex chars, sha256>", "label": "optional, shown on the certificate", "public": false}
Returns once the transaction lands, usually under two seconds.
{
"stamp_id": "01J...",
"hash": "...",
"public": false,
"status": "confirmed",
"signature": "...",
"slot": 123456,
"block_time": 1893456000,
"explorer_url": "https://solscan.io/tx/...",
"certificate_url": "https://polly.hadi-rizvi123.workers.dev/s/...",
"paid_with_credits": false,
"balance_micro": null
}
Quota: 20 stamps an hour per account, 60 an hour per IP, free. Past that, each
stamp is a cent from a prepaid balance (see Credits below); quota_reached with nothing
in the balance stops it there, as a 429 with Retry-After. public: true also requires a log handle be set first, see
Logs below; without one it is silently kept private.
Keep a copy of the file
PUT /api/stamps/:stamp_id/file body: the file's bytes (or gzip of them)
Content-Type: <the file's type> X-File-Name: <URL-encoded name>
X-Visibility: private | public X-Content-Encoding: identity | gzip
PATCH /api/stamps/:stamp_id/file {"visibility": "public" | "private"}
DELETE /api/stamps/:stamp_id/file
GET /api/files/:stamp_id → the file, as an attachment; anyone for a public copy, the owner otherwise
POST /api/stamps/:stamp_id/file/share {"hours": 24} → {"url": ".../api/files/:stamp_id?k=...", "expires_at"}; one link at a time
DELETE /api/stamps/:stamp_id/file/share
DELETE /api/me {"confirm": "DELETE"} session with step-up; removes everything personal, records stay on the ledger
Optional, after the record confirms. PollyPay re-hashes what arrives and refuses it with
file_mismatch unless the fingerprint is the record's, so a downloaded copy is known to be the
recorded file. Up to 25 MB per file (after decompression) and 500 MB per account. Records, list entries
and /api/verify carry a file object when a copy exists and you may see it.
Hand a record to someone
POST /api/stamps/:stamp_id/transfers {"email": "optional@example.com"} → {"transfer": {"link": "https://.../t/<token>", ...}}
GET /api/stamps/:stamp_id/transfers
POST /api/stamps/:stamp_id/transfers/:transfer_id/cancel
GET /api/transfers/:token session; what is on offer
POST /api/transfers/:token/accept session; writes the handover to the ledger, moves the record
The link works once, for seven days, and only for that email address when one is given. Accepting
writes a pollypay:transfer: memo and moves the record and any kept copy to the recipient; both
sides get a stamp.transferred webhook. /api/verify then carries a custody
list: who recorded it and each handover since, by page handle where there is one. No money moves through
PollyPay; a handover is a change of who holds the record, nothing more.
Safe retries
Send an Idempotency-Key header, any string up to 128 characters that is unique per
request, on POST /api/stamps, /api/attestations, /api/batches or
/api/credits. A retry with the same key and the same body returns the first response again,
marked Idempotent-Replayed: true, instead of writing a second record; the same key with a
different body is a 422. A request that failed with a 5xx is not remembered, so the same key
runs it again. Keys are kept for 24 hours.
List your stamps
GET /api/stamps/mine?limit=50&cursor=<optional> → {"stamps": [...], "next_cursor": "..." | null}
GET /api/stamps/mine?include_hidden=1 → hidden ones too
GET /api/stamps/lookup?hash=<64 hex> → {"records": [...]} your own records of that hash
GET /api/stamps/:stamp_id
PATCH /api/stamps/:stamp_id {"hidden": true, "label": "new name"}
Newest first, up to 100 a page. Pass the next_cursor you got back to fetch
the next page; /api/attestations/mine and /api/batches/mine page the same way.
Batch stamps
Up to 500 hashes for the price of one. Every hash goes into a Merkle tree, and only the tree's root, 32 bytes, ever touches Solana, in a single transaction. Each hash then verifies on its own against that root with a short proof, so anyone can confirm any one of them belongs to the batch without trusting our database at all, only the on-chain memo and the math.
POST /api/batches
Content-Type: application/json
{"hashes": ["<64 hex>", "<64 hex>", ...]} // 2 to 500, no duplicates
{
"batch_id": "01J...",
"root": "...",
"count": 3,
"status": "confirmed",
"signature": "...",
"explorer_url": "https://solscan.io/tx/...",
"certificate_url": "https://polly.hadi-rizvi123.workers.dev/b/...",
"items": [{"hash": "...", "index": 0, "proof": ["...", "..."]}, ...],
"paid_with_credits": false,
"balance_micro": null
}
Same free quota as a single stamp, 20 an hour per account, 60 per IP; a
batch of any size still only costs one of those, or one cent from the balance past the free
quota. Every item's proof is an array of sibling hashes; keep it, it's what proves
that item belongs to root.
GET /api/batches/mine GET /api/batches/:batch_id
GET /api/batch-verify/:batch_id → the batch: root, signature, status, every hash it covers
GET /api/batch-verify/:batch_id/:hash → {"found", "verified", "root", "index", "proof", "signature", ...}
No account needed for either. verified recomputes the proof against
root and live-reverifies that root is really the one written on chain, same as
/api/verify. The Merkle scheme is standard second-preimage-safe hashing: a leaf is
sha256(0x00 || hash), an internal node is sha256(0x01 || sorted(left, right)),
sha256 throughout, so a proof can be recomputed with nothing but a hash, its sibling array, and the
on-chain root, no PollyPay code required.
Verify a stamp, no account needed
GET /api/verify/:hash_or_signature
{
"found": true,
"status": "confirmed",
"verified": true,
"hash": "...",
"label": "...",
"signature": "...",
"slot": 123456,
"block_time": 1893456000,
"created_at": 1893456000,
"explorer_url": "https://solscan.io/tx/..."
}
verified is a live re-check against Solana, not just our database. The
certificate page at /s/:signature calls this same endpoint.
status is one of not_found, pending, failed or
confirmed. found is true whenever there is a record of that hash or signature at
all; verified is only ever true for a confirmed stamp. A stamp that was broadcast but not
confirmed within the request stays pending and is checked again every hour until it
lands or its blockhash is known to have expired, and the stamp.confirmed or
stamp.failed webhook fires at that moment. /api/attested/:signature and
/api/batch-verify carry the same status field.
Logs
A public, tamper-evident stream of one account's stamps: a running audit trail for a
team or a product, at https://polly.hadi-rizvi123.workers.dev/log/:handle. Claim a handle once, then mark a stamp
public: true when you create it to add it to the stream. Nothing is added retroactively
and nothing is removed from the stream once it lands, since it is only ever what is already public
on chain, indexed for browsing.
POST /api/me/log-handle
Content-Type: application/json
{"handle": "acme-ops"}
GET /api/logs/:handle?cursor=<stamp_id, optional>
→ {"handle": "acme-ops", "stamps": [...], "next_cursor": "01J..." | null}
No account needed to read a log. Pass next_cursor back as cursor
to page further back.
Credits
Only relevant once the free hourly quota is used up. One top-up, paid straight to the same fee wallet that covers stamp network costs, then every stamp past the quota debits a cent from the balance silently, no signature needed per stamp.
GET /api/credits → {"balance_micro": 1000000}
POST /api/credits {"amount_micro": 1000000} → {"topup_id": "01J..."}
POST /api/credits/:id/tx {"payer": "<your Solana address>"} → {"tx_b64": "..."}
POST /api/credits/:id/submit {"signed_tx_b64": "..."} → {"signature": "...", "status": "confirmed", "balance_micro": 1000000}
Sign tx_b64 with any Solana wallet holding USDC before submitting it. The
/record page does this with a browser wallet extension via Wallet Standard, or with a
passkey through an embedded wallet, no extension needed; from a script, decode it, sign the message
with your keypair, and re-encode it the same way any Solana transaction is signed.
Make an attestation
A public claim about a subject: an email, a wallet, a URL, a name, anything. Unlike a stamp, both the subject and the claim are written in the open on chain, in the memo itself, so anyone can read one directly from a Solana explorer with no dependency on PollyPay at all.
POST /api/attestations
Content-Type: application/json
{"subject": "wallet:8x...", "claim": "Verified team member as of 2026-09-07"}
{
"attestation_id": "01J...",
"subject": "...",
"claim": "...",
"status": "confirmed",
"signature": "...",
"certificate_url": "https://polly.hadi-rizvi123.workers.dev/a/...",
"explorer_url": "https://solscan.io/tx/...",
"paid_with_credits": false,
"balance_micro": null
}
Same quota and credits model as stamps, 20 free an hour per account, 60 per IP, a cent from the balance past that, its own separate hourly allowance from stamps.
GET /api/attestations/mine GET /api/attestations/:attestation_id
Check what's been attested, no account needed
GET /api/attested/:subject_or_signature?cursor=<optional>
Pass a signature to get one attestation back (kind: "attestation",
live-reverified against Solana); pass anything else and it is treated as a subject, returning every
confirmed attestation about it (kind: "subject"), newest first, paginated the same way
as Logs.
Webhooks
Get a POST instead of polling for a stamp or attestation to land. Manage these at https://polly.hadi-rizvi123.workers.dev/developers, or:
POST /api/hooks {"url": "https://...", "events": ["stamp.confirmed", "stamp.failed",
"attestation.confirmed", "attestation.failed",
"batch.confirmed", "batch.failed",
"stamp.transferred", "stamp.cosigned"]}
→ {"id": "01J...", "url", "events", "active": true, "secret": "..."}
GET /api/hooks
DELETE /api/hooks/:id
POST /api/hooks/:id/test → sends a synthetic stamp.confirmed delivery now
GET /api/hooks/:id/deliveries?limit=20 → latest delivery attempts, up to 100
POST /api/hooks/:id/deliveries/:delivery_id/redeliver → sends that delivery again now
The secret is only ever shown once, at creation. Every delivery is a POST
with this body:
{"id": "01J...", "type": "stamp.confirmed", "created_at": 1893456000, "data": {
"stamp_id": "01J...", "hash": "...", "label": "...", "status": "confirmed",
"signature": "...", "certificate_url": "https://polly.hadi-rizvi123.workers.dev/s/...", "created_at": ...
}}
and three headers: X-Polly-Timestamp (unix seconds),
X-Polly-Event (the event name), and X-Polly-Signature, an HMAC-SHA256 of
"<timestamp>.<raw body>" keyed by your webhook secret, hex-encoded and sent as v1=<hex>. Recompute
it the same way and compare; reject anything more than a few minutes old to rule out replay. A
failed delivery retries with backoff (10s, 30s, 2m, 10m, 1h, 6h) for up to 6 attempts, then stops;
GET .../deliveries shows what happened.
Extras
Every certificate and attestation page carries a QR code pointing at itself, and a
short link at https://polly.hadi-rizvi123.workers.dev/c/:id (the stamp_id or
attestation_id from the create response) that redirects to the full certificate once a
signature exists. Every /log/:handle also has an RSS feed at
https://polly.hadi-rizvi123.workers.dev/log/:handle/rss. A small embeddable badge for any certificate is at
https://polly.hadi-rizvi123.workers.dev/embed/s/:signature or /embed/a/:signature, meant to sit in an
<iframe>, both offered directly on the certificate page itself.