API reference

Base URL https://anonymous-credit-tokens.info. Two issuers share one API shape: {scheme} below is act (classical) or pq (post-quantum). Bodies are JSON; binary fields are base64 (standard alphabet, padded). The scheme-native bytes inside request and response fields are produced and consumed by the two Rust crates listed at the bottom; the JSON layer never interprets them beyond hashing.

GET /api/v1/{scheme}/params

Issuer parameters and public key. domain_separator appears for act so third-party clients can reconstruct the exact parameter derivation; pq carries application_context (currently anonymous-credit-tokens.info/pq/prod/e1) instead. The PQ public key is large, about 106 KB before base64. The status field states the deployment's experimental status machine-readably.

curl -s https://anonymous-credit-tokens.info/api/v1/act/params

{
  "scheme": "act",
  "epoch": "e1",
  "public_key": "5A3vPqkQ...issuer public key, base64...",
  "domain_separator": "ACT-v1:anonymous-credit-tokens.info:act:prod:e1",
  "max_credits_per_issue": 1024,
  "challenge_ttl_seconds": 900,
  "status": "experimental and unaudited; credits have no monetary value and may be erased at any time"
}

POST /api/v1/{scheme}/challenge

Mints a single-use proof-of-work challenge for an issuance of credits, clamped to 1 through 1024. Difficulty is 18 leading zero bits at 1 credit plus one bit per doubling (so 24 bits for 64 credits, 28 at the 1024 cap). The challenge expires 900 seconds after minting.

curl -s -X POST https://anonymous-credit-tokens.info/api/v1/act/challenge \
  -H 'content-type: application/json' \
  -d '{"credits": 64}'

{
  "challenge_id": "m3S1kQyLQFqCePYtcdMylg==",
  "salt": "9uV1J6kQxJ2gUuKfW0bqQw==",
  "difficulty_bits": 24,
  "credits": 64,
  "expires_at": "2026-07-19T17:45:00Z"
}

Mining

Build the scheme-native issuance request first, because the work is bound to its bytes. Then search for an 8-byte little-endian nonce satisfying:

digest = BLAKE3.derive_key("anonymous-credit-tokens.info digest v1")
         over parts ["act-issue", request_bytes]      # "pq-issue" for pq
         where each part is (len as u64 LE) || bytes

hash   = BLAKE3.derive_key("anonymous-credit-tokens.info pow v1")
         over scheme_str || challenge_id || salt || digest || nonce_u64_le

accept when leading_zero_bits(hash) >= difficulty_bits

Both BLAKE3 invocations use derive-key mode with the quoted strings as the context. Work is bound to the challenge and to the exact request, so it cannot be mined ahead, reused, or applied to a different issuance.

POST /api/v1/{scheme}/issue

Exchanges a solved challenge and an issuance request for the issuance response. nonce is the 8-byte little-endian solution, base64. Consuming the challenge and storing the response happen in one atomic step: resending the identical envelope replays the stored response with replayed: true, and a different request against a consumed challenge fails with the final challenge_mismatch.

curl -s -X POST https://anonymous-credit-tokens.info/api/v1/act/issue \
  -H 'content-type: application/json' \
  -d '{
    "challenge_id": "m3S1kQyLQFqCePYtcdMylg==",
    "nonce": "vAkAAAAAAAA=",
    "request": "hkNZ2m4vTq...issuance request, base64..."
  }'

{
  "response": "qL8wXf3d...issuance response, base64...",
  "credits": 64,
  "replayed": false
}

POST /api/v1/{scheme}/spend

Verifies a spend proof, records the nullifier, and returns the response that finishes the client's change token. requested_return (default 0) asks the issuer to return part of the spent amount; it is granted up to the spend amount, and it is part of the request's identity, so a retry must repeat it exactly. On act, spend proofs must carry a zero top-up; this issuer has no policy that authorizes top-ups.

curl -s -X POST https://anonymous-credit-tokens.info/api/v1/act/spend \
  -H 'content-type: application/json' \
  -d '{
    "request": "9tKqYc1R...spend proof, base64...",
    "requested_return": 5
  }'

{
  "response": "e2NwUvHa...refund, base64...",
  "returned": 5,
  "replayed": false
}

PQ spends additionally require settlement (which response family the request was built for) and input_kind (what kind of token is being spent), each direct or deferred. With settlement: "direct" the protocol fixes everything at proof time and returned is 0; with settlement: "deferred" the issuer chooses the return after verification, up to requested_return.

curl -s -X POST https://anonymous-credit-tokens.info/api/v1/pq/spend \
  -H 'content-type: application/json' \
  -d '{
    "request": "A4tR7oQz...spend request, base64...",
    "requested_return": 2,
    "settlement": "deferred",
    "input_kind": "direct"
  }'

{
  "response": "S1jVbe0K...spend response, base64...",
  "returned": 2,
  "replayed": false
}

Retry semantics are the heart of this endpoint: an identical resend replays the stored response byte for byte with replayed: true; any different request on the same nullifier is a final double_spend. The full contract is on the failure semantics page.

GET /api/v1/{scheme}/nullifier/{hex}

Whether a nullifier has been consumed. {hex} is 64 hex characters (32 bytes). Anyone can query any nullifier; the value reveals nothing but spent-or-not.

curl -s https://anonymous-credit-tokens.info/api/v1/act/nullifier/6f2a9c81d44e03b7595d1c0e8a67f3b2410cd98e7b5a6d21f0c3841e9b72a5dd

{"spent": false}

GET /api/v1/stats

Aggregate counters: nullifiers recorded per scheme and challenges consumed. Also rendered at /stats.

curl -s https://anonymous-credit-tokens.info/api/v1/stats

{"act_nullifiers": 1231, "pq_nullifiers": 208, "challenges_consumed": 1566}

Errors

Every non-2xx response from a handler carries a JSON body with a stable machine-readable code and a human message:

{"code": "double_spend", "message": "nullifier already consumed by a different request"}
CodeHTTPMeaningClient action
bad_request400Malformed envelope or base64, or a request rejected by policy (such as a classical top-up).Final; fix the request.
proof_invalid400The proof failed verification. No state was consumed.Final; the spent token is still live.
pow_invalid400The nonce does not satisfy the challenge for this request.Keep mining, retry with a valid nonce.
challenge_gone410Unknown or expired challenge.Mint a new challenge.
challenge_mismatch409Challenge already consumed by a different request.Final.
double_spend409Nullifier already consumed by a different request.Final; retrying can never succeed.
rate_limited429Too many requests.Honor retry-after, slow down, retry.
too_large413Body exceeds the route's cap.Final for that request.
internal500Server fault.Retry later; identical retries stay safe.

The 429 and 413 rejections come from middleware in front of the handlers and may carry a plain-text body instead of the JSON envelope; treat the HTTP status as authoritative for those two.

The replayed flag and retries

Issue and spend replies carry a replayed flag. true means this exact request was answered before and the stored response was returned unchanged, which is the normal recovery path after a dropped connection. Clients should persist their exact request bytes before sending and resend them unchanged on failure; rebuilding a randomized proof produces a different request and a final error.

Limits

Request bodies are capped at 16 KB on act routes and 256 KB on pq routes (PQ spend requests are large; see costs). All API routes are rate limited per client; expect 429 with a retry-after header under bursts. Handlers time out at 30 seconds.

MCP endpoint

A read-only MCP server for agents runs at https://anonymous-credit-tokens.info/mcp with three tools: get_params (scheme parameters and public key), check_nullifier (spent-or-not for a hex nullifier), and get_stats. Issuing and spending are deliberately not exposed as tools: they require mining and client-side secrets, which belong in a wallet.

Native byte formats

The request and response payloads are the schemes' own canonical encodings: