zkVerifier API usage
What zkVerifier is
zkVerifier's a hosted service that accepts UltraHonk proof submissions, settles payment in USDC on Base Sepolia via the x402 protocol, and submits verified proofs to zkVerify Volta. You send a proof to the API, get back a payment challenge (HTTP 402 with an x402 V2 envelope), sign the challenge with your wallet, post the signature, and the relay forwards your proof to zkVerify Volta. The proof settlement chain goes like this: POST /v1/proofs → 402 → sign → POST /v1/proofs/{job_id}/fulfill → 200 with tx_hash. Everything settles in USDC atomic units on Base Sepolia (chain ID 84532). The receive address for payments lives in the relay's RECEIVE_USDC_ADDRESS environment variable.
When you'd use it
-
You have an agent or script that generates UltraHonk proofs and needs a verification receipt without running a Substrate node yourself. You submit the proof to the relay, it handles payment and forwarding, and you get back a transaction hash you can verify on-chain.
-
You're building a pay-per-proof service and need to settle in USDC on Base Sepolia. The x402 V2 protocol handles payment discovery, signing, and settlement without you writing any smart contract code or running a facilitator.
-
You want a public, agent-consumable endpoint for proof submission. The API's stateless except for job tracking in PostgreSQL. An agent can submit proofs with idempotency keys, handle 402 challenges, and settle payments in a fully automated loop. You don't need to manage any infrastructure.
Submitting a proof end-to-end
The flow has three steps. You submit a proof, the relay returns a payment challenge, and you fulfill it with a signed x402 V2 envelope. Here's how it works.
Step 1: Submit the proof
POST to /v1/proofs with your UltraHonk V3.0 proof data. The request body needs proofType, version, proofData (containing vk, proof, and publicSignals), and an optional idempotencyKey for safe retries.
The response is HTTP 201 with a jobId, proofHash, and a quote object. The quote tells you what currency and how much USDC (in atomic units) you'll need to pay. You'll need the jobId for the next step. The response also includes eventsUrl and fulfillUrl for follow-up calls.
{
"jobId": "af7fd308-a276-4299-a411-beeb946f878e",
"status": "received",
"proofHash": "1bbed9447c6e215ed2303b6f0d5cc83415daa9dd21a76da8215f37ea23f84012",
"quote": {
"quoteCurrency": "USDC",
"vfyPerQuote": "10000",
"quoteAtomic": "23000",
"expiresAt": "2026-09-24T19:23:09.557768+00:00"
},
"eventsUrl": "/v1/proofs/af7fd308-a276-4299-a411-beeb946f878e/events",
"fulfillUrl": "/v1/proofs/af7fd308-a276-4299-a411-beeb946f878e/fulfill"
}
Step 2: Sign the x402 payment challenge
POST to {fulfillUrl} without a payment header to trigger the 402 challenge. The relay returns an x402 V2 PaymentRequirements body inside the 402 response. It also emits a PAYMENT-REQUIRED HTTP header whose value is the base64-encoded JSON of the response body. You'll construct an EIP-3009 transferWithAuthorization and sign it with your wallet.
The payment needs these fields:
scheme:"exact"(exact amount, no partials)network:"eip155:84532"(Base Sepolia)asset: the USDC token contract address on Base Sepolia (0x036CbD53842c5426634e7929541eC2318f3dCF7e)amount: theaccepts[0].amountvalue from the 402 body, as a stringpayTo: the relay's receive address fromaccepts[0].payTomaxTimeoutSeconds: from the 402 response (usually 60)
Your signed authorization must include from, to, value, nonce, validAfter, validBefore, and token fields. Make sure your from address holds enough USDC for the payment.
Important: The EIP-712 domain you sign against is the USDC token contract's own domain, not a custom "x402" domain. Use the USDC contract address as verifyingContract, name="USD Coin", version="2", and chainId=84532. The typed data primary type is TransferWithAuthorization per USDC's EIP-712 schema. The authorization fields (from, to, value, nonce, validAfter, validBefore, token) map directly to USDC's transferWithAuthorization struct. Use secrets.token_bytes(32).hex() or os.urandom(32).hex() for the nonce. Never hardcode a static nonce.
Step 3: Fulfill with the signed payment
POST to {fulfillUrl} with the PAYMENT-SIGNATURE header (x402 v2 canonical) or X-PAYMENT header (alias) set to your base64-encoded signed payload. If the payment verifies and settles, you get back HTTP 200 with the settlement status and transaction details. If the quote's expired or a payment's already been settled for this job, you'll get HTTP 409.
Here's a working Python example that walks through all three steps. It uses requests and eth_account with USDC's EIP-712 typed data signing. Swap in your own private key before running.
"""End-to-end proof submission via zkVerifier API.
Usage:
pip install requests eth_account
# Set your wallet
PRIVATE_KEY = "0x..." # your Base Sepolia wallet
BASE_URL = "https://zkverifier.net"
python submit_proof.py
"""
import json
import base64
import secrets
import requests
from datetime import datetime, timezone
from eth_account import Account
def submit_proof(base_url, proof_type="ultrahonk", version="V3_0",
vk=None, proof=None, public_signals=None, idempotency_key=None):
"""Step 1: Submit a proof and get back a job ID + quote."""
payload = {
"proofType": proof_type,
"version": version,
"proofData": {
"vk": vk or "0x" + secrets.token_hex(32),
"proof": proof or [
"0x" + secrets.token_hex(32),
"0x" + secrets.token_hex(32),
],
"publicSignals": public_signals or ["0x" + secrets.token_hex(32)],
},
}
if idempotency_key:
payload["idempotencyKey"] = idempotency_key
resp = requests.post(f"{base_url}/v1/proofs", json=payload)
body = resp.json()
if resp.status_code != 201:
print(f"Submit failed ({resp.status_code}): {body}")
return None, None
job_id = body["jobId"]
proof_hash = body["proofHash"]
quote = body["quote"]
print(f"Job ID: {job_id}")
print(f"Hash: {proof_hash[:16]}...")
print(f"Currency: {quote['quoteCurrency']}")
print(f"Amount: {quote['quoteAtomic']} atomic units")
print(f"Expires: {quote['expiresAt']}")
return job_id, quote
def get_402_challenge(base_url, job_id):
"""Step 2a: Trigger the 402 challenge by calling fulfill without payment."""
resp = requests.post(f"{base_url}/v1/proofs/{job_id}/fulfill")
body = resp.json()
if resp.status_code != 402:
print(f"Expected 402, got {resp.status_code}: {body}")
return None
print(f"402 Challenge: {body['error']}")
print(f"Network: {body['accepts'][0]['network']}")
print(f"Amount: {body['accepts'][0]['amount']} atomic USDC")
print(f"Pay to: {body['accepts'][0]['payTo']}")
return body
def build_payment_payload(accepts, private_key, receive_address):
"""Step 2b: Build and sign the x402 V2 payment payload using USDC's EIP-712 schema.
The domain and typed data must match the USDC token contract's own EIP-712
schema, not a custom domain. eth_account.sign_typed_data handles the
EIP-712 encoding; we just provide the right domain + types + message.
The nonce must be a random 32-byte value. Use secrets.token_bytes(32).hex().
"""
now = datetime.now(timezone.utc).isoformat() + "Z"
next_year = (
datetime.now(timezone.utc).replace(year=2027).isoformat() + "Z"
)
# Build the accepted requirements from the 402 response
accepted = {
"scheme": accepts["scheme"],
"network": accepts["network"],
"asset": accepts["asset"],
"amount": accepts["amount"],
"payTo": accepts["payTo"],
"maxTimeoutSeconds": accepts["maxTimeoutSeconds"],
}
# Build the EIP-3009 authorization struct
# nonce: random 32 bytes, NOT a hardcoded value
nonce = secrets.token_bytes(32).hex()
authorization = {
"from": receive_address,
"to": accepts["payTo"],
"value": accepts["amount"],
"nonce": f"0x{nonce}",
"validAfter": now,
"validBefore": next_year,
"token": accepts.get("asset"),
}
# The EIP-712 domain: USDC contract's own domain, not a custom "x402" name.
# chainId=84532 = Base Sepolia.
domain = {
"name": "USD Coin",
"version": "2",
"chainId": 84532,
"verifyingContract": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
}
# USDC's TransferWithAuthorization typed data schema.
# This is the canonical EIP-712 type that USDC's transferWithAuthorization
# on-chain function expects.
types = {
"TransferWithAuthorization": [
{"name": "from", "type": "address"},
{"name": "to", "type": "address"},
{"name": "value", "type": "uint256"},
{"name": "validAfter", "type": "uint256"},
{"name": "validBefore", "type": "uint256"},
{"name": "nonce", "type": "bytes32"},
],
}
# Convert validAfter/validBefore strings to uint256 timestamps
auth_for_signing = {
"from": authorization["from"],
"to": authorization["to"],
"value": int(authorization["value"]),
"validAfter": int(datetime.fromisoformat(authorization["validAfter"].rstrip("Z")).timestamp()),
"validBefore": int(datetime.fromisoformat(authorization["validBefore"].rstrip("Z")).timestamp()),
"nonce": authorization["nonce"],
}
# Sign using eth_account EIP-712 against the USDC contract domain
account = Account.from_key(private_key)
signed = account.sign_typed_data(
domain_data=domain,
primary_type="TransferWithAuthorization",
message_types=types,
message_data=auth_for_signing,
)
# Build the PAYMENT-SIGNATURE header value: base64-encoded JSON with the
# accepted requirements + signed authorization.
x_payment = {
"v": 2,
"accepted": accepted,
"authorization": {
"from": authorization["from"],
"to": authorization["to"],
"value": authorization["value"],
"nonce": authorization["nonce"],
"validAfter": authorization["validAfter"],
"validBefore": authorization["validBefore"],
"token": authorization["token"],
"r": signed.r.hex(),
"s": signed.s.hex(),
"v": signed.v,
},
}
return base64.b64encode(json.dumps(x_payment).encode()).decode()
def fulfill_proof(base_url, job_id, x_payment_header):
"""Step 3: POST the signed payment and get back settlement status."""
resp = requests.post(
f"{base_url}/v1/proofs/{job_id}/fulfill",
headers={"PAYMENT-SIGNATURE": x_payment_header},
)
if resp.status_code == 200:
body = resp.json()
print(f"Status: {body.get('status')}")
print(f"Job ID: {body.get('job_id')}")
print(f"Valid: {body.get('payment', {}).get('is_valid')}")
print(f"Payer: {body.get('payment', {}).get('payer')}")
print(f"TX Hash: {body.get('payment', {}).get('transaction')}")
print(f"Network: {body.get('payment', {}).get('network')}")
return body
else:
body = resp.json()
if isinstance(body, list):
print(f"Error: {body[0]['code']}: {body[0]['detail']}")
else:
print(f"Error: {body}")
return None
def main():
account = Account.from_key("0xYOUR_PRIVATE_KEY_HERE")
receive_address = account.address
print("Step 1: Submitting proof...")
result = submit_proof("https://zkverifier.net")
if result[0] is None:
return
job_id, quote = result
print("\nStep 2: Triggering 402 challenge...")
accepts = get_402_challenge("https://zkverifier.net", job_id)
if accepts is None:
return
print("\nStep 2b: Building payment payload...")
x_payment = build_payment_payload(accepts, "0xYOUR_PRIVATE_KEY_HERE", receive_address)
print("\nStep 3: Fulfilling with signed payment...")
fulfill_proof("https://zkverifier.net", job_id, x_payment)
if __name__ == "__main__":
main()
Important: The accepts object comes from the 402 challenge response (Step 2), not from the 201 quote. The 201 response gives you jobId + quote (with quoteAtomic for the amount), but you need to call POST /v1/proofs/{job_id}/fulfill without a payment header first. The 402 response body contains the full accepts array with payTo, maxTimeoutSeconds, network, and the final amount. See scripts/ops/e2e-x402-payment-test.py for the complete runnable reference.
Reading the responses
402 Payment Required (from POST /v1/proofs/{id}/fulfill without PAYMENT-SIGNATURE)
This is the x402 challenge. It tells you what to sign and how much you'll need to pay. You can't skip this step. The relay won't process your proof without a valid payment envelope. The PAYMENT-REQUIRED HTTP header is also present; its value is the base64-encoded JSON of the response body.
{
"x402Version": 2,
"error": "Payment required to fulfill this proof",
"accepts": [
{
"scheme": "exact",
"network": "eip155:84532",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"amount": "23000",
"payTo": "0xB0fdA7132A348c23C97f055DeBF15aC45Aae8bBA",
"maxTimeoutSeconds": 84,
"extra": {
"name": "zkpay-relay",
"version": "0.1.0",
"token_address": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"decimals": 6,
"symbol": "USDC"
}
}
]
}
x402Version: Always2for this endpointerror: Human-readable message. Always "Payment required to fulfill this proof"accepts: Array of PaymentRequirements you can satisfy. Always exactly one entry.scheme:"exact"means you must pay the full amount, no partials accepted.network: EIP-155 chain identifier."eip155:84532"is Base Sepolia.asset: USDC token contract address on Base Sepolia (0x036CbD53842c5426634e7929541eC2318f3dCF7e).amount: The exact USDC amount in atomic units (string, not number. USDC has 6 decimals).payTo: The receive address where the USDC settles.maxTimeoutSeconds: How long you have to sign and submit the payment (usually 60).extra: Token metadata for display purposes.
200 OK on fulfill (successful payment)
The 200 OK response shape depends on which x402 scheme the facilitator returned. We don't hardcode a fixed response format here. Run scripts/ops/e2e-x402-payment-test.py against the live service with a funded testnet wallet to see the real shape.
422 Unprocessable Entity (bad proof submission)
If the proof doesn't pass validation:
[{"code": "PROOF_INVALID", "detail": "UltraHonk proof format error: vk must be at least 64 hex characters (32 bytes), got 1"}]
code: Stable error code.PROOF_INVALIDcovers empty bodies, missing fields, or malformedproofData. Thedetailfield gives the specific validation failure (e.g., invalid hex, wrong length).detail: Human-readable explanation of what went wrong. For malformedproofData, it names the exact field that failed.
Missing vk, proof, or publicSignals fields will trigger this. Hex values in vk, proof, and publicSignals must be valid hex strings prefixed with 0x. The vk must be at least 64 hex chars (32 bytes). Each element in proof must be exactly 64 hex chars. Each element in publicSignals must be exactly 64 hex chars.
410 Gone (deprecated endpoint)
The old /v1/pay endpoint is gone:
{"code":"DEPRECATED","detail":"/v1/pay has been replaced by /v1/proofs/{id}/fulfill"}
code:DEPRECATED. The endpoint was removed, not rate-limited.detail: Tells you which endpoint to use instead.
Use POST /v1/proofs/{id}/fulfill with the PAYMENT-SIGNATURE header instead.
404 Not Found (non-existent job)
If the job ID doesn't exist:
{"detail": "Job not found"}
detail: Confirms the job ID isn't in the database.
503 Service Unavailable (stale oracle)
When the price oracle hasn't received fresh data:
{"postgres": "ok", "oracle": "stale", "worker": "ok"}
- The oracle is stale but the USDC $0.01 floor still applies. The relay returns 503 until the oracle data is fresh again.
Where to check a tx on-chain
The payment transaction settles on Base Sepolia. You get the transaction hash back in the 200 fulfill response. Agents can construct the explorer URL themselves: https://sepolia.basescan.org/tx/<tx_hash>, where <tx_hash> is the payment.transaction value from the response. The USDC receipt lands in the RECEIVE_USDC_ADDRESS wallet that the x402 payTo field points to. Proof submission to zkVerify Volta happens on a separate path.
Cost and pricing
Payment's in USDC atomic units. The relay quotes the exact amount in the 402 response body via quoteAtomic. The quote's derived from a VFY/USDC price oracle that runs a 60-second sampling loop. It reads the V4 spot price from the VFY/USDC pool on Base (primary) and a 6-hour TWAP from the Aerodrome SlipStream VFY/USDC pool on Base (fallback), then writes ticks to a rolling buffer to compute a time-weighted average. The current rate's available at GET /v1/price, which returns vfy_per_usdc. That's how many proof verifications one USDC buys. What you pay for a single proof submission depends on the current oracle price. Quotes are time-limited (usually 60 seconds). If the quote expires you'll need to submit a new proof to get a fresh one.
When the oracle data is stale, the relay falls back to a fixed USDC floor of $0.01 per proof. The GET /v1/price endpoint reports "source": "floor" when using this fallback. Check the /ready endpoint to see if the oracle is healthy ("oracle": "ok").
Checking proof status
After submitting a proof, you can check its state with two endpoints:
-
GET /v1/proofs/{id}returns the job state:jobId,status,proofHash,proofType,version,quote,eventsUrl,fulfillUrl, optionalzkTx, optionalerror, andcreatedAt/updatedAttimestamps. Returns 404 for non-existent jobs. -
GET /v1/proofs/{id}/eventsreturns a JSON array of all events for the job, each withtypeandtsfields. Returns an empty array when no events exist.
Poll these endpoints to track progress between submission and settlement. The status field moves from received through processing states to a final status.
Error codes and what they mean
-
402 Payment Required - You submitted a proof but haven't settled payment yet. POST to
/v1/proofs/{id}/fulfillwith thePAYMENT-SIGNATUREheader to complete the flow. -
409 Conflict - Either the quote's expired (submit a new
POST /v1/proofsto get a fresh quote) or a payment's already been settled for this job. Theauth_hashis a unique constraint, so double-submissions get rejected. -
410 Gone - You're calling
/v1/pay. That endpoint's gone. Use/v1/proofs/{id}/fulfillinstead. -
422 Unprocessable Entity - Your proof submission failed validation. The
PROOF_INVALIDerror covers empty bodies, missing fields, or malformedproofData. Thedetailfield names the specific failure. Check thatvkis at least 64 hex chars, eachproofelement is exactly 64 hex chars, andpublicSignalselements are also 64 hex chars. -
404 Not Found - The job ID you're querying doesn't exist. You may have mis-typed it.
-
503 Service Unavailable - The price oracle data's stale or the PostgreSQL backend's unreachable. The relay returns 503 until the oracle catches up. Check
GET /readyfor details.
Support and issues
zkVerifier is an early-stage hosted service. There's no public issue tracker yet. Report problems via the channel you received this URL from. There's no SLA, on-call response, or contractual uptime guarantee at this time. For status, the live /health and /ready endpoints reflect service availability.
Frequently asked questions
For common questions about tracking proofs, querying by address, error codes, and rate limits, see the FAQ.