Foundation PoH API Developer Guide
The Foundation Proof of Humanity (PoH) API provides human verification as a service.
Soulbound Attestations Endpoint (Live — May 2026)
The primary GaaS surface for third-party governance platforms. Query a wallet's on-chain soulbound attestations to verify humanity, voting participation, and proposal support.
Environments
This fork (foundation-next) has one Firebase project and no staging tier — see
CLAUDE.md § Deployment. API keys are per-project.
| Environment | Endpoint |
|---|---|
| Prod | https://us-east1-foundation-next-app.cloudfunctions.net/pohAttestationsEndpoint |
Corrected 2026-09-03. This table previously listed
us-east1-solanavote-devnet.cloudfunctions.netas "Prod" andus-east1-foundation-staging-69917.cloudfunctions.netas "Staging". Both of those hosts belong to live Foundation, a different deployment this fork is deliberately isolated from — an integrator following them would have been sending API traffic at someone else's production project.foundation-nextdeploys only tofoundation-next-app. This is the same class of error corrected inCLAUDE.md's local-development section; the curl examples below carried it too.
Auth: X-PoH-API-Key header. Keys are stored as SHA-256 hashes in the
poh_api_keys Firestore collection (active: true). Platform admins can
create, list, and revoke keys self-serve under Admin → PoH API Keys — the
plain-text key is shown once at creation and never persisted.
Lookup modes
The endpoint identifies the subject via exactly one of three mutually exclusive query params, checked in order:
| Param | Privacy | Notes |
|---|---|---|
?wallet=<base58> |
wallet returned in response | direct on-chain address lookup |
?email=<address> |
wallet not returned | server resolves wallet from Firebase Auth |
?token=<uuid> |
wallet not returned | opaque user-generated token (24h TTL), generated under Identity Proof → "Generate Verification Token" |
GET ?wallet=<base58Address>
curl -s "https://us-east1-foundation-next-app.cloudfunctions.net/pohAttestationsEndpoint?wallet=4ZiTV3aWkR2pfjQbsLicyCs3scjbzkub8XBFjujJirJ3" \
-H "X-PoH-API-Key: your_key_here"
Response (200):
{
"wallet": "4ZiTV3aWkR2pfjQbsLicyCs3scjbzkub8XBFjujJirJ3",
"attestations": [
{
"attestationType": 0,
"contextHashHex": "0000000000000000000000000000000000000000000000000000000000000000",
"status": "confirmed",
"onChainAddress": "AxBz...",
"syncedAt": "2026-05-27T14:30:00.000Z",
"createdAt": "2026-05-27T14:00:00.000Z"
}
],
"count": 1,
"hasVerifiedHuman": true,
"hasVoted": false,
"votedCount": 0,
"hasSupportedProposal": false,
"supportedCount": 0
}
| Field | Description |
|---|---|
attestationType |
0 = VERIFIED_HUMAN, 1 = VOTED, 2 = SUPPORTED_PROPOSAL, 3 = RECEIVED_SHARE, 4 = POLL_VOTED (see the full table below) |
contextHashHex |
64-char hex. For VERIFIED_HUMAN: 32 zero bytes. For VOTED/SUPPORTED: proposal PDA pubkey bytes |
status |
"confirmed" = on-chain and mirrored; "pending" = minted locally, awaiting chain confirmation |
hasVerifiedHuman |
true if at least one attestationType: 0 confirmed — the primary signal for humanity gating |
hasVoted |
true if at least one attestationType: 1 confirmed |
votedCount |
Number of confirmed VOTED attestations (one per proposal) |
hasSupportedProposal |
true if at least one attestationType: 2 confirmed |
supportedCount |
Number of confirmed SUPPORTED_PROPOSAL attestations (one per proposal) |
Error responses:
401— missing or invalidX-PoH-API-Key400— invalidwallet(not base58), or none ofwallet/email/tokenprovided404— no wallet for the givenemail, or token not found / revoked / expired500— internal error
Attestation types
| Type | Value | Context hash | Issued when |
|---|---|---|---|
| VERIFIED_HUMAN | 0 | 32 zero bytes | Passport/humanity verification completes |
| VOTED | 1 | Proposal PDA pubkey | Vote is mirrored on-chain |
| SUPPORTED_PROPOSAL | 2 | Proposal PDA pubkey | Support signature is mirrored on-chain |
| RECEIVED_SHARE | 3 | see functions/on-chain-tasks.js:849 |
A Pillar-2 share distribution is mirrored on-chain (best-effort, non-fatal) |
| POLL_VOTED | 4 | the poll vote's nullifierHashHex — already a 32-byte digest, reused directly |
A public-poll vote is mirrored on-chain (best-effort, non-fatal) |
Types 3 and 4 added 2026-09-03 — they shipped with §4/§5 and this table had not caught up. All five constants are defined in
functions/lib/attestation-types.js:20-24and re-exported byfunctions/lib/attestation-onchain.js:7-16;POLL_VOTEDis issued atfunctions/on-chain-tasks.js:514. An integrator filtering onattestationType <= 2will silently miss the two newer kinds.
On-chain program
Anchor program at GQrFse7NiB6QdqtagGayNYwrr8zn4W4uWhji57VkKGky (Solana devnet). PDAs are soulbound — unspendable accounts seeded from [b"attestation", holder, attestation_type, context_hash]. The holder cannot transfer them; revocation requires the original issuing keypair.
Identity Verification API (Legacy REST — retired; architecture retired too)
⚠ Retired — do not integrate against anything below this line. Everything from here to the end of the document describes a system that no longer exists, at two separate levels, and the banner that used to sit here only warned about the first:
- The transport is gone. The REST gateway (
evoting-api-213114263206.us-east1.run.app) was the Phase 3 architecture and was retired at the Phase 4/5 cutover (2026-04-19).- The architecture it describes is gone too — this is the part the previous note missed. The old banner said only that "the current identity verification flow runs through Firebase Cloud Functions," which reads as though the same three-step flow simply moved transport. It did not. The Self Protocol passport path, the Semaphore identity commitment, and the Semaphore group-membership vote proof were all retired on 2026-08-31 (commit
7f6d77ba). There is no Cloud Functions equivalent ofPOST /attach-commitmentorGET /group, because there is nothing left to attach or to enumerate. Verification runs through RariMe and a self-hostedverificator-svc; anonymity comes from a per-proposal Rarimo nullifier.What to read instead:
docs/identity-architecture-current.mdfor the architecture, anddocs/cf-reference.mdfor the callables that exist today (startL2Verification,getL2VerificationStatus,startAnonymousVoteProof,castAnonymousVote). The Soulbound Attestations Endpoint above this line is the live, supported GaaS surface and the only part of this document you can integrate against.The sections below are kept verbatim as a record of the retired API — including its Semaphore-based three-step flow,
/verify-passport,/attach-commitment,/group,/anonymous-voteand/status/{nullifier}, none of which have a live counterpart. They are not corrected in place, because correcting a retired API's reference documentation into a description of a different architecture would destroy the record without producing a usable guide.
Base URL:
https://evoting-api-213114263206.us-east1.run.app
All endpoints are prefixed with /api/v1/poh/.
Quick Start
1. Get an API key. Contact the Foundation team to provision an API key for your project. You will receive a key in the format poh_live_.... Store it securely -- it is shown only once.
2. Make your first call. Check the status of a nullifier to confirm your key works:
curl -X GET \
https://evoting-api-213114263206.us-east1.run.app/api/v1/poh/status/0x1a2b3c4d \
-H "X-PoH-API-Key: poh_live_your_key_here"
3. Integrate the verification flow. The typical integration follows these steps:
- Verify passport -- Submit a ZK proof from an NFC-scanned ePassport
- Attach commitment -- Bind a Semaphore identity commitment to the verified identity
- Cast votes -- Use Semaphore ZK proofs for anonymous on-chain voting
Authentication
All endpoints require an API key passed in the X-PoH-API-Key HTTP header. The key is SHA-256 hashed on the server side and looked up in Firestore.
X-PoH-API-Key: poh_live_your_key_here
The POST /attach-commitment endpoint additionally requires a Firebase JWT in the Authorization header, issued after ePassport verification:
Authorization: Bearer <firebase-jwt>
The admin POST /api-keys endpoint uses a separate X-API-Key header for admin authentication.
Rate Limits & Quotas
| Plan | Requests / min | Monthly Quota |
|---|---|---|
| free | 10 | 1,000 |
| pro | 100 | 100,000 |
| enterprise | 1,000 | unlimited |
When you exceed your rate limit or monthly quota, the API returns 429 Too Many Requests. Upgrade your plan or wait for the limit to reset.
Endpoints Reference
POST /verify-passport
Verify an ePassport ZK proof. Accepts a proof generated from an NFC-scanned ePassport, verifies it, and returns a nullifier with an assigned trust tier.
Request:
curl -X POST \
https://evoting-api-213114263206.us-east1.run.app/api/v1/poh/verify-passport \
-H "Content-Type: application/json" \
-H "X-PoH-API-Key: poh_live_your_key_here" \
-d '{
"attestation_id": "att_9f3a2b1c",
"proof": { "pi_a": [...], "pi_b": [...], "pi_c": [...] },
"pub_signals": { "signal1": "value1" },
"userContextData": { "device": "iPhone 15", "locale": "en-US" }
}'
Response (200):
{
"nullifier": "0x1a2b3c4d...",
"trustTier": "passport_zk",
"verifiedAt": "2026-04-14T12:00:00Z"
}
| Field | Type | Description |
|---|---|---|
| nullifier | string | Unique nullifier derived from the proof |
| trustTier | string | Trust tier based on verification strength |
| verifiedAt | string | ISO-8601 timestamp of verification |
Errors: 400 invalid proof, 401 missing/invalid API key, 429 rate limited.
POST /attach-commitment
Bind a Semaphore identity commitment to a verified identity. After passport verification, the client generates a Semaphore identity commitment and attaches it to their nullifier.
Requires both an API key and a Bearer JWT.
Request:
curl -X POST \
https://evoting-api-213114263206.us-east1.run.app/api/v1/poh/attach-commitment \
-H "Content-Type: application/json" \
-H "X-PoH-API-Key: poh_live_your_key_here" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
-d '{
"commitment": "12345678901234567890"
}'
Response (200):
{
"success": true,
"commitment": "12345678901234567890"
}
| Field | Type | Description |
|---|---|---|
| success | boolean | Whether the commitment was attached |
| commitment | string | The commitment that was attached |
Errors: 400 invalid request, 401 missing/invalid API key or JWT.
GET /group
Retrieve the Semaphore group. Returns all commitments in the group along with the current member count and Merkle root.
Request:
curl -X GET \
https://evoting-api-213114263206.us-east1.run.app/api/v1/poh/group \
-H "X-PoH-API-Key: poh_live_your_key_here"
Response (200):
{
"commitments": [
"12345678901234567890",
"98765432109876543210"
],
"memberCount": 42,
"merkleRoot": "0xabcdef..."
}
| Field | Type | Description |
|---|---|---|
| commitments | string[] | List of Semaphore identity commitments |
| memberCount | integer | Total members in the group |
| merkleRoot | string | Current Merkle root of the Semaphore group |
Errors: 401 missing/invalid API key.
POST /anonymous-vote
Cast an anonymous on-chain vote. Submits a Semaphore ZK proof to cast an anonymous vote on a Solana proposal. The nullifier prevents double-voting.
Request:
curl -X POST \
https://evoting-api-213114263206.us-east1.run.app/api/v1/poh/anonymous-vote \
-H "Content-Type: application/json" \
-H "X-PoH-API-Key: poh_live_your_key_here" \
-d '{
"proposalId": "prop_abc123",
"optionId": "opt_yes",
"proof": { "pi_a": [...], "pi_b": [...], "pi_c": [...] },
"nullifier": "0x1a2b3c4d...",
"signal": "0xsignal...",
"externalNullifier": "0xexternal..."
}'
Response (200):
{
"success": true,
"transactionSignature": "5K8z...txSig"
}
| Field | Type | Description |
|---|---|---|
| success | boolean | Whether the vote was recorded |
| transactionSignature | string | Solana transaction signature |
Errors: 400 invalid proof, 401 missing/invalid API key, 409 duplicate vote (nullifier already used for this proposal).
GET /status/{nullifier}
Look up verification status by nullifier. Returns whether a nullifier exists, its proof type, trust tier, verification timestamp, and whether a Semaphore commitment is bound.
Request:
curl -X GET \
https://evoting-api-213114263206.us-east1.run.app/api/v1/poh/status/0x1a2b3c4d \
-H "X-PoH-API-Key: poh_live_your_key_here"
Response (200):
{
"exists": true,
"nullifier": "0x1a2b3c4d...",
"proofType": "passport_zk",
"trustTier": "passport_zk",
"verifiedAt": "2026-04-14T12:00:00Z",
"hasCommitment": true
}
| Field | Type | Description |
|---|---|---|
| exists | boolean | Whether a record exists for this nullifier |
| nullifier | string | The queried nullifier |
| proofType | string | Type of proof used (only when exists is true) |
| trustTier | string | Trust tier (only when exists is true) |
| verifiedAt | string | Verification timestamp (only when exists is true) |
| hasCommitment | boolean | Whether a Semaphore commitment is bound |
Errors: 401 missing/invalid API key.
POST /api-keys (Admin)
Provision a new PoH API key. Admin-only endpoint. The plain-text key is returned only once.
Request:
curl -X POST \
https://evoting-api-213114263206.us-east1.run.app/api/v1/poh/api-keys \
-H "Content-Type: application/json" \
-H "X-API-Key: your_admin_key_here" \
-d '{
"owner": "acme-corp",
"plan": "pro"
}'
Response (200):
{
"key": "poh_live_abc123...",
"key_id": "key_7f8e9d0c",
"plan": "pro",
"rate_limit": 100,
"monthly_quota": 100000,
"message": "Store this key securely. It will not be shown again."
}
| Field | Type | Description |
|---|---|---|
| key | string | Plain-text API key (shown only once) |
| key_id | string | Key identifier for management operations |
| plan | string | Pricing plan (free, pro, enterprise) |
| rate_limit | integer | Requests per minute |
| monthly_quota | integer | Monthly request quota (-1 for unlimited) |
| message | string | Reminder to store the key |
Errors: 401 missing/invalid admin API key.
Error Handling
All errors return a JSON object with an error field:
{
"error": "Human-readable error message."
}
Common Error Codes
| HTTP Status | Meaning | Example |
|---|---|---|
| 400 | Bad Request | Invalid proof, missing required fields |
| 401 | Unauthorized | Missing or invalid X-PoH-API-Key header |
| 409 | Conflict | Duplicate vote -- nullifier already used for this proposal |
| 429 | Too Many Requests | Rate limit or monthly quota exceeded |
| 500 | Internal Server Error | Unexpected server-side failure |
TypeScript / Fetch Examples
Verify a passport
const BASE_URL = "https://evoting-api-213114263206.us-east1.run.app";
const API_KEY = "poh_live_your_key_here";
async function verifyPassport(attestationId: string, proof: object, pubSignals: object) {
const res = await fetch(`${BASE_URL}/api/v1/poh/verify-passport`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-PoH-API-Key": API_KEY,
},
body: JSON.stringify({
attestation_id: attestationId,
proof,
pub_signals: pubSignals,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`Verification failed (${res.status}): ${err.error}`);
}
return res.json(); // { nullifier, trustTier, verifiedAt }
}
Check verification status
async function getStatus(nullifier: string) {
const res = await fetch(`${BASE_URL}/api/v1/poh/status/${nullifier}`, {
headers: { "X-PoH-API-Key": API_KEY },
});
if (!res.ok) {
const err = await res.json();
throw new Error(`Status check failed (${res.status}): ${err.error}`);
}
return res.json(); // { exists, nullifier, proofType, trustTier, verifiedAt, hasCommitment }
}
Cast an anonymous vote
async function castVote(
proposalId: string,
optionId: string,
proof: object,
nullifier: string,
signal: string,
externalNullifier: string
) {
const res = await fetch(`${BASE_URL}/api/v1/poh/anonymous-vote`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-PoH-API-Key": API_KEY,
},
body: JSON.stringify({
proposalId,
optionId,
proof,
nullifier,
signal,
externalNullifier,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`Vote failed (${res.status}): ${err.error}`);
}
return res.json(); // { success, transactionSignature }
}
Full integration example
// 1. Verify the user's passport
const verification = await verifyPassport("att_9f3a2b1c", zkProof, pubSignals);
console.log("Verified:", verification.nullifier);
// 2. Attach a Semaphore commitment (requires JWT from step 1)
const commitRes = await fetch(`${BASE_URL}/api/v1/poh/attach-commitment`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-PoH-API-Key": API_KEY,
"Authorization": `Bearer ${firebaseJwt}`,
},
body: JSON.stringify({ commitment: semaphoreIdentity.commitment.toString() }),
});
// 3. Fetch the Semaphore group to generate a proof
const group = await fetch(`${BASE_URL}/api/v1/poh/group`, {
headers: { "X-PoH-API-Key": API_KEY },
}).then(r => r.json());
// 4. Generate a Semaphore proof and cast a vote
const vote = await castVote("prop_abc123", "opt_yes", semaphoreProof, nullifier, signal, extNullifier);
console.log("Vote tx:", vote.transactionSignature);