Verifying with the Relayer API

A second path into zkVerify

zkverifyjs is the right choice when you are building a Node.js backend, want fine-grained control over events, or are deeply integrated with the Substrate transaction lifecycle.

The Relayer API is the right choice when you want to skip all of that. It is a REST API. You send HTTP requests. You get a job ID back. You poll for the result. No seed phrase management in your code, no WebSocket connections, no wallet setup. Any language or runtime that can make an HTTP request can use it.

This lesson covers everything you need to integrate the Relayer API from scratch.

What the Relayer actually does

The Relayer is a managed service that sits in front of zkVerify. When you submit a proof to the Relayer, it handles the on-chain transaction on your behalf — signing, broadcasting, monitoring, and aggregation. You interact with a clean REST interface and never touch the chain directly.

The Relayer also provides optimistic verification — a fast pre-check that runs before the proof is submitted on-chain. If the optimistic check passes, you get immediate confirmation that your proof is structurally valid before waiting for block inclusion.

Getting an API key

Testnet: Sign up at the testnet portal and generate a key from your dashboard. Swagger docs: https://relayer-api-testnet.horizenlabs.io/docs

Mainnet: Create your API key at https://relayer.horizenlabs.io Swagger docs: https://relayer-api-mainnet.horizenlabs.io/docs

`` # .env API_KEY=your_api_key_here ``

API keys are tied to your account. Usage is rate-limited by default. If you are running a high-volume application, contact the team on Discord to request a rate limit increase.

Base URLs

`` Testnet: https://relayer-api-testnet.horizenlabs.io/api/v1 Mainnet: https://relayer-api-mainnet.horizenlabs.io/api/v1 ``

Step 1 — Register your verification key

Same concept as lesson 7, but via HTTP instead of the SDK. This is a one-time operation per circuit.

```javascript import axios from "axios"; import fs from "fs";

const API_URL = "https://relayer-api-testnet.horizenlabs.io/api/v1"; const key = JSON.parse(fs.readFileSync("./data/main.groth16.vkey.json"));

const params = { proofType: "groth16", proofOptions: { library: "snarkjs", curve: "bn128" }, vk: key };

const response = await axios.get( ${API_URL}/register-vk/${process.env.API_KEY}, { params } );

fs.writeFileSync("vkey.json", JSON.stringify(response.data)); console.log(response.data); // { vkHash: "0x828c736b...", meta: { ... } } ```

Save the vkHash. You will use it in every subsequent proof submission. Verification keys registered on testnet are automatically re-registered on mainnet — you do not need to repeat this step when moving to production.

Step 2 — Submit a proof

```javascript const proof = JSON.parse(fs.readFileSync("./data/proof.json")); const publicInputs = JSON.parse(fs.readFileSync("./data/public.json")); const vk = JSON.parse(fs.readFileSync("./vkey.json"));

const params = { proofType: "groth16", vkRegistered: true, proofOptions: { library: "snarkjs", curve: "bn128" }, proofData: { proof: proof, publicSignals: publicInputs, vk: vk.vkHash || vk.meta.vkHash } };

const response = await axios.post( ${API_URL}/submit-proof/${process.env.API_KEY}, params );

console.log(response.data); // { // jobId: "4e77e1c5-4d36-11f0-af7b-32a805cdbfd3", // optimisticVerify: "success" // } ```

If optimisticVerify is not "success", stop. Your proof artifacts are invalid and the on-chain submission will also fail. Check your proof file, public inputs, and that the verification key matches the circuit that generated the proof.

Save the jobId. You need it to poll for the result.

Step 3 — Poll for job status

The Relayer processes the submission asynchronously. Poll the job status endpoint until the proof is finalized:

```javascript const jobId = response.data.jobId;

while (true) { const statusResponse = await axios.get( ${API_URL}/job-status/${process.env.API_KEY}/${jobId} );

console.log("Job status:", statusResponse.data.status);

if (statusResponse.data.status === "Finalized") { console.log("Job finalized:", statusResponse.data); break; }

if (statusResponse.data.status === "Failed") { console.error("Job failed:", statusResponse.data); break; }

await new Promise(resolve => setTimeout(resolve, 5000)); } ```

Status values in order: SubmittedIncludedInBlockFinalized. A finalized response looks like this:

``json { "jobId": "23382e04-3d57-11f0-af7b-32a805cdbfd3", "status": "Finalized", "proofType": "groth16", "txHash": "0xc0d85e5d50fff2bb5d192ee108664878e228d7fc3c1faa2d23da891832873d51", "blockHash": "0xcd574432b1a961305bbeb2c6b6ef399e1ae5102593846756cbb472bfd53d7d43" } ``

Step 4 — Wait for aggregation (optional)

If you need the Merkle proof for on-chain settlement, add chainId to your submit-proof params and poll until status is Aggregated:

```javascript const params = { proofType: "groth16", vkRegistered: true, chainId: 11155111, // Sepolia proofOptions: { library: "snarkjs", curve: "bn128" }, proofData: { proof: proof, publicSignals: publicInputs, vk: vk.vkHash || vk.meta.vkHash } };

// Then poll until status === "Aggregated" if (statusResponse.data.status === "Aggregated") { fs.writeFileSync( "aggregation.json", JSON.stringify({ ...statusResponse.data.aggregationDetails, aggregationId: statusResponse.data.aggregationId }) ); break; } ```

An aggregated response contains everything needed for your smart contract to verify inclusion:

``json { "aggregationId": 29537, "statement": "0xd72c67547100dd6f00c60f05f4bb7cf33f22b077e6a76125e911e091197bd55c", "aggregationDetails": { "root": "0x84c25ba051bc3cc66a74bcf2169befad5f348d0ad7b24efd6c68c70a25783ad2", "leaf": "0xd72c67547100dd6f00c60f05f4bb7cf33f22b077e6a76125e911e091197bd55c", "leafIndex": 6, "numberOfLeaves": 8, "merkleProof": ["0xc714a8...", "0x958bf2..."] } } ``

Supported proof types via Relayer

The Relayer supports all proof types available on the chain. The proofType field in your request must match exactly:

`` "groth16" — requires proofOptions.library and proofOptions.curve "risc0" — requires proofOptions.version e.g. "V2_1" "ultraplonk" — requires proofOptions.numberOfPublicInputs "ultrahonk" — no additional options required "sp1" — no additional options required "plonky2" — requires proofOptions.hashFunction "fflonk" — no additional options required "ezkl" — no additional options required ``

Relayer vs zkverifyjs — when to use which

| | Relayer API | zkverifyjs | |---|---|---| | Language | Any | Node.js / TypeScript | | Wallet management | None — Relayer handles it | Seed phrase required | | Event granularity | Poll-based | Real-time EventEmitter | | Rate limits | Yes | No | | Best for | Backend services, non-JS stacks, quick integration | Full control, production Node.js apps |

💡
The Relayer is the fastest way to get a proof verified on zkVerify. If you are building a prototype, a hackathon project, or a backend service in Python, Go, or Rust, reach for the Relayer first. You can always migrate to zkverifyjs later if you need lower-level control.

Answer the quiz correctly to continue →

Video · Quiz1 / 3

Which zkVerify integration method is better suited for non-JavaScript backends?