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

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:

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"
      }
    }
  ]
}

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"}]

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"}

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"}

503 Service Unavailable (stale oracle)

When the price oracle hasn't received fresh data:

{"postgres": "ok", "oracle": "stale", "worker": "ok"}

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:

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

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.