Private API subscriptions via x402 and ZK proofs on Stellar. Merchant hidden on-chain. Server blind to your wallet. Every API call unlinkable.
ZeroGate is a private API subscription protocol combining the x402 payment standard with Groth16 ZK proofs on Stellar. When you call a ZeroGate-protected API without a session, the server returns HTTP 402 Payment Required — but instead of the merchant's wallet, the to: field points to a ShieldedPool Soroban contract.
You deposit USDC to the pool, the server receives only a Poseidon commitment hash, and you prove access with a ZK credential. No wallet. No trace. No linkage.
Merchant hidden
The 402 response shows ShieldedPool as to: — the merchant's G-address is never sent to you
Server blind
/subscribe receives only a commitment hash — zero wallet address, zero tx hash
Unlinkable sessions
HMAC session tokens contain no wallet info. Upgrades to Groth16 in production
ZeroGate's core property: neither side learns the other's identity. ShieldedPool is the neutral blind intermediary between them.
| Who | What they learn | Why |
|---|---|---|
| Subscriber (API caller) | ❌ Never sees merchant's wallet | 402 response has to: ShieldedPool — merchant G-address is never sent to the client |
| Merchant (API server) | ❌ Never sees subscriber's wallet | /subscribe takes only a commitment hash — no wallet, no tx hash, provably blind |
| On-chain observer | ❌ No subscriber ↔ merchant link | Payment is wallet → ShieldedPool. Merchant withdraws privately via admin_claim |
What is visible on-chain: The USDC transfer amount is public on Stellar (token transfers are always visible in the explorer). Everything else — merchant, subscriber, usage — is hidden.
Full flow (6 steps)
Any request to a ZeroGate-protected endpoint without a session token receives HTTP 402. The response includes a zerogate-shielded payment object. Critically, to: is the ShieldedPool contract address — never the merchant's wallet. The subscriber has no way to learn who the merchant is.
Your browser computes commitment = Poseidon(secret, expiry) entirely client-side using circomlibjs. The secret never leaves the browser. This commitment is what gets stored on-chain — not your wallet, not your identity.
You send USDC to the ShieldedPool Soroban contract via Freighter. The contract stores only the commitment hash and returns a leaf_index (your position in the Merkle tree). The merchant's address is never written to the ledger at any point.
POST /subscribe with { commitment, leaf_index, expiry }. No wallet address. No transaction hash. The server is provably blind to who subscribed. It issues a stateless HMAC session token tied to the commitment hash.
Every API call carries X-ZeroGate-Session: <commitment>:<expiry>:<hmac>. The server verifies the HMAC and returns data. Server logs contain: 'valid session presented' — zero wallet info. In production this upgrades to a full Groth16 ZK proof of Merkle membership.
ZeroGate implements a private variant of x402 — the merchant's wallet is replaced by the ShieldedPool contract in the 402 response. Subscribers pay the pool, merchants withdraw privately. Neither party learns the other's address.
GET /api/prices (no session token)
→ HTTP 402
{
"x402Version": 1,
"error": "Payment required",
"accepts": [{
"scheme": "zerogate-shielded",
"network": "stellar-testnet",
"asset": "USDC",
"to": "CDMJVGYOLXA4UF4FYWMP2XXHBX7OGNM6C54NZ6BAEUPL6TXPSUJVGXYY", ← ShieldedPool
"maxAmountRequired": "0.50",
"howToPay": {
"step1": "Call ShieldedPool.deposit(USDC, 0.50, Poseidon(secret,expiry))",
"step2": "POST /subscribe with commitment hash (no wallet address sent)",
"step3": "Retry request with X-ZeroGate-Session header"
}
}]
}
# Merchant wallet (GBBG…MQUM) is NEVER in this response.// Body — server receives ONLY these fields:
{
"api_id": "price-feed",
"commitment": "21888242871839275222246405745257275088548364400416034343698204186575808495617",
"leaf_index": 3,
"subscriber_secret": "...",
"subscription_id": "sub_abc",
"expiry": 1751500000
}
// ✗ wallet address ✗ tx hash ✗ payment amount ✗ any identity
// Response:
{ "session_token": "<commitment>:<expiry>:<hmac>", "expires_at": 1751500000 }GET /api/prices
X-ZeroGate-Session: 21888...617:1751500000:a3f9c2...
→ 200 OK { "btc": 67420, "eth": 3521, "xlm": 0.12 }
# Server verified HMAC. Logs: "valid session." Nothing else.Written in Circom 2.0, compiled to Groth16 over BN254. Poseidon hash is used throughout — it matches Stellar Protocol 25's native bn254_poseidon_hash host function, enabling future on-chain proof verification.
pragma circom 2.0.0;
include "poseidon.circom";
include "merkleProof.circom";
template SubscriptionProof(levels) {
// Private inputs — never leave the browser
signal input secret;
signal input expiry;
signal input merkle_path[levels];
signal input path_indices[levels];
signal input nullifier_secret;
// Public outputs — the only things the server sees
signal output root; // current Merkle root (verified on-chain)
signal output nullifier; // Poseidon(nullifier_secret, leaf_index)
signal output commitment; // Poseidon(secret, expiry) — matches on-chain leaf
// Verify commitment is correctly formed
component commitHash = Poseidon(2);
commitHash.inputs[0] <== secret;
commitHash.inputs[1] <== expiry;
// Verify Merkle membership
component tree = MerkleProof(levels);
tree.leaf <== commitHash.out;
// ... 11,741 constraints total
}The production path replaces the HMAC session token with this Groth16 proof. The server verifies it against the live Merkle root — no wallet, no identity, just proof of membership.
A single Soroban contract (subscription_registry) acts as the ShieldedPool. It accepts USDC, stores commitment hashes in a Poseidon Merkle tree, and lets the merchant withdraw privately. Compiled to wasm32v1-none with soroban-sdk 26.x.
Deployed address (Stellar Testnet)
CDMJVGYOLXA4UF4FYWMP2XXHBX7OGNM6C54NZ6BAEUPL6TXPSUJVGXYY
initialize(admin: Address)Set contract admin (merchant)
deposit(from, token, amount, commitment: BytesN<32>) → u32Transfer USDC from subscriber, store commitment leaf, return leaf_index. Merchant address never written.
get_root() → BytesN<32>Current Poseidon Merkle root — used by ZK proof verification
get_proof(leaf_index: u32) → (Vec<BytesN<32>>, Vec<u32>)Merkle siblings + directions for client-side proof generation
leaf_count() → u32Total number of deposits (size of Merkle tree)
claim(token, to: Address, amount: i128)Admin only — merchant withdraws USDC. Not linked to any subscriber transaction.
Events emitted on deposit: ("deposit", "v1") → (leaf_index, commitment). Amount and subscriber address are NOT emitted — they are not discoverable from on-chain data.
Express backend on port 3001. Protected routes accept either an X-ZeroGate-Session HMAC token (playground) or an X-ZeroGate-Proof Groth16 proof (production).
/healthHealth check — returns { status: 'ok', service: 'zerogate-api' }
/subscribeBlind subscribe. Body: { api_id, commitment, leaf_index, expiry }. No wallet. Returns { session_token, expires_at }.
/api/weatherLive weather data. Params: lat, lon.
/api/pricesCrypto spot prices — BTC, ETH, XLM.
/api/analyzeAI text analysis. Body: { text }.
# Playground — HMAC session token (stateless, survives restarts)
X-ZeroGate-Session: <commitment>:<expiry>:<hmac-sha256>
# Production — Groth16 ZK proof (base64-encoded JSON)
X-ZeroGate-Proof: <base64(JSON.stringify({ proof, publicSignals }))>
# publicSignals = [root, nullifier, commitment]
# proof = { pi_a: [...], pi_b: [[...],[...]], pi_c: [...] }
# CORS allowed headers: Content-Type, Authorization,
# X-ZeroGate-Session, X-ZeroGate-Proofgit clone https://github.com/ayushsingh82/ZeroGate.git cd ZeroGate # Frontend cd frontend && npm install && npm run dev # http://localhost:3000 # API server cd ../api && npm install && npx tsx src/server.ts # http://localhost:3001
# XLM (for gas) curl "https://friendbot.stellar.org?addr=YOUR_G_ADDRESS" # USDC (for subscriptions) # https://faucet.circle.com → Stellar Testnet → paste your G address
1. Connect Freighter wallet at http://localhost:3000/app 2. Click "Pay X USDC · Subscribe privately" on any API card 3. Approve the ShieldedPool deposit in Freighter 4. Your USDC goes to the pool — merchant address never shown 5. Session token is saved locally — start calling APIs in Playground
SUBSCRIPTION_REGISTRY_CONTRACT=CDMJVGYOLXA4UF4FYWMP2XXHBX7OGNM6C54NZ6BAEUPL6TXPSUJVGXYY MERCHANT_SALT=<32-byte-hex> # signs HMAC session tokens SERVER_SECRET_KEY=S... # merchant keypair for admin_claim MERCHANT_ADDRESS=G... # merchant public key
cd contracts/subscription_registry rustup target add wasm32v1-none cargo build --target wasm32v1-none --release # → target/wasm32v1-none/release/subscription_registry.wasm