Auditor- and integrator-facing reference for the public transparency / Merkle / audit endpoints exposed at
https://imdifferent.id. This document is the source of truth; the in-app/transparency/apipage renders from it.Scope: 9 read-only public endpoints (no API key required). Internal scheduled jobs and admin endpoints are out of scope. Voting submission and poll CRUD endpoints are out of scope.
Last updated: 2026-05-18.
Conventions
| Aspect | Rule |
|---|---|
| Base URL | https://imdifferent.id |
| Auth | None. All endpoints in this document are publicly accessible. |
| CORS | All endpoints set Access-Control-Allow-Origin: *. |
| Caching | Each endpoint sets its own Cache-Control. Stable data (anchors, manifests) caches for 5 min â 1 day; volatile data (pending status) is no-store. |
| Errors | JSON body of shape { "error": "..." } on 4xx/5xx (some endpoints add extra context fields â see per-endpoint sections). |
| IDs | All UUIDs are lowercase, hyphenated, 36 chars. Hashes use lowercase hex with 0x prefix. |
| Dates | All timestamps are ISO-8601 UTC (2026-05-14T02:34:14.000Z). Batch dates are date-only (2026-05-13). |
| Tier enum | world_id_only | identity_verified | mixed | anonymous. Returned in lowercase snake_case in API responses. The Anchor Browser also uses the synthetic value all for coalesced NO_VOTES rows. |
| Status enum (batch) | ANCHORED (on-chain confirmed) | NO_VOTES (anchoring ran, zero leaves) | PENDING (computed, awaiting tx) | FAILED (tx reverted, retried). Public read endpoints surface only ANCHORED and NO_VOTES. |
| Dual-era IDs | Any endpoint that takes a voteId parameter accepts BOTH a ballot_id (Phase 1+, since 2026-05-13) AND a legacy vote_id (pre-Phase-1). The backend resolves automatically. See Dual-era lookup. |
| Always-array shape | Since Phase 2 (2026-05-14 02:34 UTC), proof responses always return leaves: [...] as an array, with cardinality being the array length. Single-select ballots have cardinality: 1 (1-element array); multi-select ballots have cardinality: N. There is no special-cased "single leaf" branch for clients. |
Endpoint index
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | GET | /api/audit/manifest | Signed manifest binding verifier+doc+contract bytes to the anchor wallet |
| 2 | GET | /api/audit/verifier-script | Raw verify-merkle.mjs (download) |
| 3 | GET | /api/audit/verification-doc | Raw MERKLE_VERIFICATION.md (download) |
| 4 | GET | /api/audit/contract-source | Raw MerkleAnchor.sol (download) |
| 5 | GET | /api/merkle/proof/[voteId] | Inclusion proof for a vote / ballot (always-array) |
| 6 | GET | /api/merkle/poll/[pollId] | Per-poll audit: anchored vs claimed tally per option |
| 7 | GET | /api/merkle/roots | Anchor browser feed: all anchored roots + NO_VOTES days |
| 8 | POST GET | /api/merkle/verify | Full receipt verification: hash check + Merkle inclusion |
| 9 | POST | /api/receipts/verify | Receipt-only check (no Merkle inclusion) |
GET /api/audit/manifest
Returns a signed manifest that binds the SHA-256 of the verifier script, the verification doc, and the on-chain contract source to the same wallet that anchors Merkle roots on Base. Auditors verify the signature with viem's verifyMessage against signerAddress, then independently SHA the downloaded files (endpoints 2â4) and compare against the sha256 field for each.
| Param | Required | Notes |
|---|---|---|
| (none) | â | No query / body. |
Example request
curl -s https://imdifferent.id/api/audit/manifest
Example response â 200 OK
{
"type": "imdifferent-audit-manifest",
"version": "v1",
"files": [
{
"name": "verify-merkle.mjs",
"url": "/api/audit/verifier-script",
"sha256": "0xe00dab316b8af8a332180566c685faa3c2f5d54b890febe21fcbe6ee8108d453",
"bytes": 22385
},
{
"name": "MERKLE_VERIFICATION.md",
"url": "/api/audit/verification-doc",
"sha256": "0xf7fae72e1bc4618b...",
"bytes": 54814
},
{
"name": "MerkleAnchor.sol",
"url": "/api/audit/contract-source",
"sha256": "0xca863221e996dd67...",
"bytes": 2407
}
],
"signedAt": "2026-05-14T03:12:33.193Z",
"signerAddress": "0x6321cC751D6B41A5D1256019F2AaadCA28dC9321",
"message": "imdifferent.id audit-manifest v1 | 2026-05-14T03:12:33.193Z | MERKLE_VERIFICATION.md:0xf7fae72e... | MerkleAnchor.sol:0xca863221... | verify-merkle.mjs:0xe00dab31...",
"signature": "0x1b33d47c9969bde342d8f6e9...",
"verificationRecipe": "ECDSA-recover(message, signature) === signerAddress (use viem's verifyMessage or ethers' verifyMessage)"
}
Errors
| Status | Body | Meaning |
|---|---|---|
| 503 | { error: "Manifest signing unavailable", reason: "...", files: [...] } | MERKLE_ANCHOR_PRIVATE_KEY is missing or malformed in the deploy. Auditors can still download files from files[].url, but cannot verify signatures. |
| 500 | { error: "Manifest unavailable" } | Filesystem or unexpected error. |
Caching
Cache-Control: public, max-age=300, s-maxage=86400, stale-while-revalidate=86400. Re-signed at every cold-start, so deployed bytes â manifest bytes stay in lockstep.
GET /api/audit/verifier-script
Returns the canonical bytes of scripts/verify-merkle.mjs as a downloadable .mjs file. Pair with endpoint 1 (manifest) to verify the bytes were signed by the anchor wallet.
| Param | Required | Notes |
|---|---|---|
| (none) | â | No query / body. |
Example request
curl -s -o verify-merkle.mjs https://imdifferent.id/api/audit/verifier-script
sha256sum verify-merkle.mjs # compare against manifest.files[name=verify-merkle.mjs].sha256
Response â 200 OK
Content-Type: text/javascript; charset=utf-8Content-Disposition: attachment; filename="verify-merkle.mjs"- Body: raw script bytes (currently ~22 KB).
Errors
| Status | Body | Meaning |
|---|---|---|
| 500 | { error: "Verifier script unavailable" } | Filesystem error. |
GET /api/audit/verification-doc
Returns the canonical bytes of docs/MERKLE_VERIFICATION.md as a downloadable .md file. Same shape as endpoint 2 with Content-Type: text/markdown.
curl -s -o MERKLE_VERIFICATION.md https://imdifferent.id/api/audit/verification-doc
sha256sum MERKLE_VERIFICATION.md
GET /api/audit/contract-source
Returns the canonical bytes of contracts/MerkleAnchor.sol. Same shape as endpoint 2 with Content-Type: text/plain.
curl -s -o MerkleAnchor.sol https://imdifferent.id/api/audit/contract-source
sha256sum MerkleAnchor.sol
GET /api/merkle/proof/[voteId]
Returns a Merkle inclusion proof for a single ballot. The response always uses the array shape (since Phase 2): cardinality: N and leaves: [...] with N entries.
Path params
| Param | Required | Notes |
|---|---|---|
voteId | yes | Either a ballot_id (Phase 1+) or a legacy vote_id (pre-Phase-1). Backend resolves automatically â see Dual-era lookup. |
Example request
curl -s https://imdifferent.id/api/merkle/proof/20968f36-7f38-44cb-9331-0e04cf2dd37c
Example response â 200 OK (multi-select, cardinality 3)
{
"voteId": "20968f36-7f38-44cb-9331-0e04cf2dd37c",
"tier": "mixed",
"batchDate": "2026-05-13",
"expectedRoot": "0x5818...8a19",
"anchor": {
"txHash": "0xb5e9f5417fb49935bffcc6fa0d1bb516c4f090c6a47cd7da3882c50ad4c737d4",
"blockNumber": "45968355",
"chainId": 8453,
"contractAddress": "0x569e1ca29a8879d1394664ddfa3dad93ea9cc953"
},
"cardinality": 3,
"leaves": [
{
"voteId": "20968f36-7f38-44cb-9331-0e04cf2dd37c_<optionId-1>",
"pollOptionId": "<optionId-1>",
"position": 14,
"leafHash": "0xabc...",
"siblings": [{ "position": "right", "hash": "0xdef..." }, ...]
},
{ ... }, { ... }
]
}
Other response shapes
| Status | Body | Meaning |
|---|---|---|
| 202 | { voteId, status: "pending_anchor", expectedAnchorAt } | Vote exists but its batch hasn't been anchored yet. Anchoring runs daily at 03:30 UTC. |
| 200 | { voteId, status: "predates_anchoring", voteCreatedAt, cutoffDate } | Vote was cast before Merkle anchoring began (pre-MERKLE_ANCHOR_CUTOFF_DATE). |
| 404 | { voteId, status: "not_found" } | No matching ballot or vote row. |
| 500 | { error: "cardinality_mismatch", expected, got, voteId } | Server-side integrity check failed: the number of returned leaves does not match the ballot's selection count. Surfaces honestly rather than returning a partial proof. Operator monitoring will fire on this. |
Caching
Anchored: Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=600. All other states: no-store.
Rate limit
Shared audit-verify budget: 4 req / 60s per IP, shared across endpoints 5, 7, 8, and 9. See docs/RATE_LIMITS.md §25 for the rationale.
GET /api/merkle/poll/[pollId]
Per-poll audit: returns anchored vs claimed tally per option for a sealed poll. Used by the /transparency/poll/[pollId] UI. Designed to expose aggregate integrity (does the on-chain count of leaves per option match the database's claimed voteCount?) without leaking individual ballot IDs.
Path params
| Param | Required | Notes |
|---|---|---|
pollId | yes | UUID of a poll. Works for both sealed and open polls; response shape varies by status (see below). |
Example request
curl -s https://imdifferent.id/api/merkle/poll/c1a55140-81a7-4e81-8ce3-4e56d1b249c8
Example response â 200 OK (sealed poll, status: available)
{
"status": "available",
"pollId": "c1a55140-81a7-4e81-8ce3-4e56d1b249c8",
"sealedAt": "2026-05-13T22:00:00.000Z",
"batches": [
{
"batchId": "...",
"batchDate": "2026-05-13",
"tier": "MIXED",
"leafCount": 6,
"root": "0x5818...8a19",
"txHash": "0xb5e9f5417f...",
"chainId": 8453,
"contractAddress": "0x569e1ca29a8879d1394664ddfa3dad93ea9cc953",
"blockNumber": "45968355",
"anchoredAt": "2026-05-14T02:34:14.000Z",
"basescanUrl": "https://basescan.org/tx/0xb5e9f5417f..."
}
],
"totals": {
"anchoredVotes": 6,
"claimedTotalVotes": 6,
"anchoredTallyByOption": [
{ "optionId": "...", "optionText": "Caring", "count": 2 },
{ "optionId": "...", "optionText": "Social Content", "count": 2 },
{ "optionId": "...", "optionText": "Breeding", "count": 1 },
{ "optionId": "...", "optionText": "Friendship", "count": 1 }
],
"claimedTallyByOption": [ /* same shape, mirrors voteCount per option */ ],
"tallyMatches": true
}
}
Other status values
status | When | Body shape |
|---|---|---|
open_no_seal | Poll exists but is not sealed yet | { status, pollId, sealedAt: null, message } |
no_votes | Sealed poll received zero votes | { status, pollId, sealedAt, message } |
predates_anchoring | All votes were cast before Merkle anchoring began | { status, pollId, sealedAt, cutoffDate, message } |
pending_anchoring | Sealed poll still has unanchored batches | { status, pollId, sealedAt, progress: { anchoredBatches, totalBatches, anchoredVotes, totalVotes }, pendingBatches: [...] } |
Errors
| Status | Body | Meaning |
|---|---|---|
| 404 | { error: "Poll not found" } | No poll with that ID. |
| 429 | { error: "Audit endpoint rate limit (4 req / 120s per IP). Next request in <ttl>s.", retryAfterSeconds } | Too many distinct audit URLs from the same IP within the 120s window. See Rate limit below. |
| 500 | { error: "Internal server error" } | Unexpected. |
Privacy note
This endpoint deliberately does not return per-ballot IDs or per-voter information. The strongest assertion it can make about an individual ballot is that it was included in some daily Merkle root for the poll's tier â which is what /api/merkle/proof/[voteId] exposes when the voter (or an auditor with the receipt code) supplies the ID directly.
Rate limit
4 handler invocations / 120s per IP, across all polls. Keyed by IP only (not per-poll), so cache-bust loops over distinct poll IDs cap at the IP level rather than starting a fresh window per poll.
Two patterns:
- Same-URL refresh (re-load one audit page): bounded by the CDN cache, not by this rate limit. The 60s
s-maxage(see Caching) means the handler runs at most once per 60s per URL â well under the 4-per-120s cap. Refreshing one audit URL in a tight loop is always 200, never 429. - Cross-URL bursts (open many distinct audit URLs from the same IP in succession): first 4 unique URLs return 200; the 5th onwards returns 429 (also CDN-cached for 60s for the URL it was issued on). At t=120 the per-IP rate-limit window expires and the cycle restarts.
Caching
All responses (200 and 429): public, s-maxage=60 (no stale-while-revalidate).
SWR is deliberately omitted: for cross-URL bursts it would keep a stale 200 served on a URL that should have started returning 429, breaking the rate-limit defence. The audit endpoint is opened once per page-load, never polled, so the synchronous origin call once per 60s is invisible to real users. Net freshness is also better â status transitions (openâsealed, pendingâavailable, new vote arrives) propagate within 60s instead of up to 6â15 min that a per-status SWR window would have allowed.
GET /api/merkle/roots
Anchor Browser data feed. Returns ANCHORED batches and NO_VOTES days (the latter coalesced when filtering all tiers, so a quiet day shows as a single row instead of four).
Query params
| Param | Required | Default | Notes |
|---|---|---|---|
limit | no | 50 | Integer 1..200. Cap on the returned roots array length. |
tier | no | all | One of world_id_only | identity_verified | mixed | anonymous | all. When all, NO_VOTES days for which every tier had zero leaves are coalesced into one row with tier: "all" and isCoalesced: true. |
Example request
curl -s 'https://imdifferent.id/api/merkle/roots?limit=20&tier=mixed'
Example response â 200 OK
{
"anchorWalletAddress": "0x6321cC751D6B41A5D1256019F2AaadCA28dC9321",
"anchorContractAddress": "0x569e1ca29a8879d1394664ddfa3dad93ea9cc953",
"contractSourceUrl": "https://basescan.org/address/0x569e1ca29a8879d1394664ddfa3dad93ea9cc953#code",
"chain": "base",
"chainId": 8453,
"explorerBaseUrl": "https://basescan.org/tx/",
"easAttestation": "0x...",
"easAttestationUrl": "https://base.easscan.org/attestation/view/0x...",
"roots": [
{
"batchDate": "2026-05-13",
"tier": "mixed",
"leafCount": 41,
"root": "0x5818...8a19",
"anchoredAt": "2026-05-14T02:34:14.000Z",
"txHash": "0xb5e9f5417f...",
"blockNumber": "45968355",
"signingKeyVersion": 1,
"hmac": "0x...",
"status": "ANCHORED"
},
{
"batchDate": "2026-05-12",
"tier": "all",
"leafCount": 0,
"root": "",
"anchoredAt": "2026-05-13T03:30:00.000Z",
"txHash": "",
"blockNumber": "0",
"signingKeyVersion": 0,
"hmac": "",
"status": "NO_VOTES",
"isCoalesced": true
}
],
"generatedAt": "2026-05-14T03:13:00.000Z"
}
Errors
| Status | Body | Meaning |
|---|---|---|
| 400 | { error: "Invalid limit. Must be 1..200." } | limit out of range or non-numeric. |
| 400 | { error: "Invalid tier parameter. Must be one of: ..." } | Unknown tier value. |
| 500 | { error: "Internal server error" } | Unexpected. |
Caching
revalidate = 300 (5 min). Anchors update at most once per day, so a 5-minute stale window is generous.
Pending fields
The response carries _anchorBasename_pending, _contractDeployedAt_pending, _lastProofOfLife_pending, and (when applicable) _easAttestation_pending. These are placeholders for fields that will be populated in later phases (Basename registration, monthly proof-of-life publication, etc.). Treat them as informational; don't rely on the underscore-prefixed keys staying around once the real fields land.
Rate limit
Shared audit-verify budget â see endpoint 5's rate limit note.
POST /api/merkle/verify (also GET ?code=âŠ)
End-to-end receipt verification: validates the receipt hash, looks up the matching ballot, AND confirms the Merkle inclusion proof passes against the on-chain root. Used by the /transparency/verify UI. The single-call equivalent of (endpoint 9 + endpoint 5).
POST body
| Field | Required | Type | Notes |
|---|---|---|---|
voteId | yes | string | Ballot ID (Phase 1+) or legacy vote ID. |
hash | yes | string | The 64-char hex hash from the receipt code. |
ledgerEntryId | no | string | Required for token-gated polls; omit otherwise. |
GET query
| Param | Required | Notes |
|---|---|---|
code | yes | Full receipt code as voteId:hash or voteId:hash:ledgerEntryId. The route splits on : and returns 400 if there are fewer than 2 or more than 3 parts. |
Example request â POST
curl -s -X POST https://imdifferent.id/api/merkle/verify \
-H 'Content-Type: application/json' \
-d '{"voteId":"20968f36-7f38-44cb-9331-0e04cf2dd37c","hash":"abc..."}'
Example request â GET
curl -s 'https://imdifferent.id/api/merkle/verify?code=20968f36-7f38-44cb-9331-0e04cf2dd37c:abc...:ledgerXYZ'
Example response â 200 OK (anchored, multi-select)
{
"success": true,
"receiptValid": true,
"merkle": {
"anchored": true,
"tier": "mixed",
"batchDate": "2026-05-13",
"txHash": "0xb5e9f5417f...",
"txUrl": "https://basescan.org/tx/0xb5e9f5417f...",
"blockNumber": "45968355",
"verifiedAt": "2026-05-14T06:30:00.000Z",
"cardinality": 3,
"selections": [
{ "pollOptionId": "<optionId-1>", "leafHash": "0xabc...", "anchored": true },
{ "pollOptionId": "<optionId-2>", "leafHash": "0xdef...", "anchored": true },
{ "pollOptionId": "<optionId-3>", "leafHash": "0xfff...", "anchored": true }
]
}
}
Other response shapes
| Body | Meaning |
|---|---|
{ success: false, receiptValid: false, error: "Receipt hash mismatch" } | Hash does not match. The vote ID exists but the receipt was tampered, mistyped, or copied from a different deployment. |
{ success: true, receiptValid: true, merkle: { anchored: false, expectedAnchorAt } } | Receipt valid, but the batch hasn't been anchored yet. |
{ success: true, receiptValid: true, merkle: { anchored: false, status: "no_leaf_yet" } } | Receipt valid, but no Merkle leaf has been generated for this vote yet (anchoring hasn't run). |
{ success: true, receiptValid: true, merkle: { anchored: false, status: "predates_anchoring", cutoffDate } } | Receipt valid, but the vote was cast before Merkle anchoring began. |
Errors
| Status | Body | Meaning |
|---|---|---|
| 400 | { success: false, error: "Please provide a receipt code and hash to verify." } | Missing voteId or hash (POST) or empty ?code= (GET). |
| 400 | { success: false, error: "Receipt code must be in the format voteId:hash or voteId:hash:ledgerEntryId." } | GET only â ?code= had < 2 or > 3 colon-separated parts. |
| 404 | { success: false, error: "No matching vote record found." } | Neither ballot nor legacy vote lookup found a match. |
| 500 | { success: false, error: "cardinality_mismatch", expected, got } | Server-side integrity check failed (see endpoint 5). |
| 500 | { success: false, error: "Internal verification error" } | A leaf failed Merkle proof verification â should never happen for honestly anchored data. |
| 500 | { success: false, error: "Internal server error" } | Unexpected. |
Rate limit
Shared audit-verify budget â see endpoint 5's rate limit note.
POST /api/receipts/verify
Receipt-only verification: validates the receipt hash without performing the Merkle inclusion check. Used by the /[lang]/receipts page to give voters an immediate "your receipt is real" answer before the slower Merkle round-trip. Prefer endpoint 8 if you want the full audit chain in one call.
POST body
| Field | Required | Type | Notes |
|---|---|---|---|
voteId | yes | string | Ballot ID (Phase 1+) or legacy vote ID. |
hash | yes | string | 64-char hex receipt hash. |
ledgerEntryId | no | string | Required for token-gated polls; omit otherwise. |
Example request
curl -s -X POST https://imdifferent.id/api/receipts/verify \
-H 'Content-Type: application/json' \
-d '{"voteId":"20968f36-7f38-44cb-9331-0e04cf2dd37c","hash":"abc..."}'
Example response â 200 OK (success)
{
"success": true,
"data": {
"voteId": "20968f36-7f38-44cb-9331-0e04cf2dd37c",
"pollTitle": "Why do people bond with cats?",
"pollLanguage": "en",
"pollTranslations": [ /* localized titles */ ],
"hashAlgorithm": "SHA-256 Verified"
}
}
Other response shapes
| Body | Meaning |
|---|---|
{ success: false, error: "No matching vote record found. Please verify the receipt code is correct." } | Neither ballot nor legacy vote lookup found a match. |
{ success: false, error: "Verification failed. The receipt does not match our records." } | The hash does not match either the new (with ledgerEntryId) or legacy (without ledgerEntryId) recipe. |
Errors
| Status | Body | Meaning |
|---|---|---|
| 400 | { success: false, error: "Please provide a receipt code and hash to verify." } | Missing voteId or hash. |
| 500 | { success: false, error: "Something went wrong. Please try again." } | Unexpected. |
Rate limit
Shared audit-verify budget â see endpoint 5's rate limit note.
Receipt hash recipe
The hash component of a receipt code is computed by the server when the vote is recorded, then handed back to the voter as the second segment of voteId:hash[:ledgerEntryId].
hash = SHA-256(`${internalId}-${pollId}-${voterIpHash}-${ledgerEntryId || 'null'}`)
| Field | Meaning |
|---|---|
internalId | The ballot's unique identifier (Phase 1+ format) or, for pre-Phase-1 receipts, the legacy vote identifier with its trailing _<optionId> suffix stripped (if present). |
pollId | The poll's UUID, as returned by the public poll endpoints. |
voterIpHash | The voter's IP-hash field â HMAC-SHA-256 of the raw IP under a server-side key that rotates on a recurring schedule and is never exposed by any endpoint. Forgery resistance comes from this field, not from the recipe's secrecy. |
ledgerEntryId | Pre-allocated UUID for token-gated burn (one per ballot). For non-token-gated polls, the literal string 'null' is concatenated instead â the recipe is shape-stable. |
Backward-compatible legacy recipe (pre-§B.3 receipts):
hash = SHA-256(`${internalId}-${pollId}-${voterIpHash}`)
The verify endpoints accept either recipe â the server tries both and matches whichever passes. Note: pollOptionId is intentionally NOT part of the hash. One ballot, one receipt, even when N selections sit underneath.
Dual-era lookup
Any endpoint that takes a voteId accepts BOTH:
- A
ballot_id(UUID) â every ballot cast since the Phase 1 cutover at 2026-05-13 21:15 UTC has one. This is the canonical receipt identifier going forward (single-select and multi-select alike). - A legacy
vote_id(UUID) â every vote cast before Phase 1 has one, and was never associated with a Phase 1 ballot.
Resolution behaviour (used by endpoints 5, 8, 9):
- The backend first tries the value as a Phase 1+ ballot identifier. If found â it is used directly as
internalId. - Otherwise the backend tries it as a legacy vote identifier â including via prefix match, since legacy vote identifiers may carry a trailing
_<optionId>suffix that must be stripped before comparison. - If neither path matches â
404 not_found.
Why the prefix fallback exists: the legacy identifier was constructed as ${ballotId}_${optionId} for single-select Phase 1 too, so a Phase 1 ballot ID is also the prefix of the corresponding legacy vote ID. The prefix match catches the case where someone hands you the wrong half (the full vote ID instead of the parent ballot ID).
Tier classification (for context)
The daily Merkle anchoring process classifies each vote into exactly one tier based on the verification methods present on its ballot:
| Tier | Rule |
|---|---|
WORLD_ID_ONLY | Verified solely with World ID (Iris biometrics). |
IDENTITY_VERIFIED | Verified via Coinbase or Human Passport (ECDSA signature). |
MIXED | Multiple verification methods OR other supported integrations. |
ANONYMOUS | No identity verification â rate-limited / fingerprint-only. |
Each tier produces a separate daily Merkle batch; each batch produces one root, one transaction, one EAS attestation. The Anchor Browser (endpoint 7) returns one row per (date, tier) pair, except where coalescing applies.
Worked examples
A. Verify your own multi-select ballot end-to-end
RECEIPT="20968f36-7f38-44cb-9331-0e04cf2dd37c:abc123def456..."
curl -s "https://imdifferent.id/api/merkle/verify?code=${RECEIPT}" | jq
You should see success: true, receiptValid: true, merkle.anchored: true, merkle.cardinality: 3, and three entries in merkle.selections[].
B. Re-verify the same ballot with the standalone script
# Download the verifier and the manifest in parallel
curl -s -o verify.mjs https://imdifferent.id/api/audit/verifier-script
curl -s https://imdifferent.id/api/audit/manifest > manifest.json
# Confirm the bytes match what the manifest says
echo "$(sha256sum verify.mjs | cut -d' ' -f1)" \
| grep -i "$(jq -r '.files[] | select(.name=="verify-merkle.mjs") | .sha256[2:]' manifest.json)" \
&& echo OK
# Run the verifier (uses /api/merkle/proof/[voteId] under the hood)
node verify.mjs --vote-id=20968f36-7f38-44cb-9331-0e04cf2dd37c \
--base-url=https://imdifferent.id
C. Audit a sealed poll's tally
curl -s https://imdifferent.id/api/merkle/poll/c1a55140-81a7-4e81-8ce3-4e56d1b249c8 | jq '.totals'
Look for tallyMatches: true. If false, every option's claimedCount vs anchoredCount is shown so you can identify which option drifted.
D. Pull the last 7 days of anchors
curl -s 'https://imdifferent.id/api/merkle/roots?limit=200' | jq '.roots | map(select(.batchDate >= "2026-05-08"))'
Voter count vs. selection count
Added 2026-05-17.
Endpoint 6 (/api/merkle/poll/[pollId]) distinguishes two numbers that historical responses called "votes" interchangeably:
| Concept | Definition |
|---|---|
| Voter count (= ballot count) | Number of distinct ballots cast. One ballot per voter intent, regardless of how many options that ballot picked. Surfaced by the public poll endpoints as the poll's totalVotes field. |
| Selection count | Total number of option-counter increments. For single-select polls this equals voter count; for multi-select polls it is >= voter count. Equal to the sum of each option's voteCount. |
The /api/merkle/poll/[pollId] totals block now returns BOTH naming conventions:
| Field | Meaning | Status |
|---|---|---|
anchoredSelections | Anchored Merkle leaves (one leaf = one selection since Phase 2). | New canonical name. |
claimedSelectionCount | Sum of each option's voteCount â what the per-option counters claim. | New canonical name. |
anchoredVotes | Equal to anchoredSelections. | Deprecated alias â equal value, name will be removed after a deprecation window of at least one quarter. |
claimedTotalVotes | Equal to claimedSelectionCount. | Deprecated alias â see above. |
Integrators reading claimedTotalVotes / anchoredVotes continue to work unchanged. New integrators should read the canonical names.
For sealed-poll JSON snapshots (returned alongside poll metadata), snapshots written on or after 2026-05-17 carry an explicit selectionCount field. Older snapshots omit it and the read path computes it on the fly from options[].voteCount. Both shapes resolve to the same number â the new field is a forward migration so we can drop the recompute once legacy seals age out.
Change log
| Date | Change |
|---|---|
| 2026-05-18 | Receipt-hash recipe gains an explicit threat-model line on voterIpHash (HMAC under a rotating server-side key). Internal implementation references throughout abstracted to observable behaviour for the public surface. No endpoint contract changes. |
| 2026-05-17 | /api/merkle/poll/[pollId] now returns anchoredSelections + claimedSelectionCount alongside the deprecated anchoredVotes + claimedTotalVotes aliases. New sealed-poll snapshots carry an explicit selectionCount field. See Voter count vs. selection count above. |
| 2026-05-14 | Initial publication. Phase 2 (per-selection anchoring + always-array proof shape) live as of 02:34 UTC; verifier v1.3 (narrow eth_getLogs window) live as of 03:12 UTC. |
