2.0 RC docsView 1.x docs
Concept · Relayer API key

Mainnet encryption needs a relayer API key

Keep the key on the server: Node encryptors pass auth, browsers go through a proxy you run.

v2.0.0-rc.1 release candidate
The API is frozen and later candidates carry fixes only, except the receipt-free and Safe create surface of /fhe-airdrop, which is @beta. Published on the next dist-tag; latest stays on 1.6.0 until 2.0.0.

Every encryption goes through a Zama relayer: the SDK asks it for the FHE public key and for the input proof of each encrypted value, and user decryption goes through it too. The two Zama-hosted relayers differ.

ChainRelayer (@zama-fhe/sdk preset)API key
Sepolia (11155111)https://relayer.testnet.zama.orgNone, the relayer is open
Mainnet (1)https://relayer.mainnet.zama.orgRequired on every request

Without a key the mainnet relayer answers 403, so the first encrypt fails before any transaction is sent. Apply for a key through Zama's Relayer API keys guide in the zama-ai/sdk repository, or self-host a relayer. The hosted relayer accepts the key only as an x-api-key header.

RelayerAuth#

createSepoliaEncryptor (/fhe) and createSepoliaEncryptorWeb (/fhe/web) take an auth option and set it as the chain's auth. Its type mirrors FheChainAuth from @zama-fhe/sdk/chains 3.6, so either assigns to the other.

type RelayerAuth =
  | { __type: "BearerToken"; token: string }
  | { __type: "ApiKeyHeader"; header?: string; value: string }
  | { __type: "ApiKeyCookie"; cookie?: string; value: string };
ModeSendsFor
ApiKeyHeaderx-api-key: <value> (or header)The Zama-hosted mainnet relayer. The only mode it accepts.
ApiKeyCookieAn x-api-key cookie (or cookie)The browser-to-proxy hop of a proxy you run.
BearerTokenAuthorization: Bearer <token>A self-hosted relayer behind a bearer auth layer.

Server side: pass auth#

server/encryptor.ts
ts
import { createSepoliaEncryptor, MAINNET_CHAIN_ID } from "@tokenops/sdk/fhe";

const encryptor = await createSepoliaEncryptor({
  chainId: MAINNET_CHAIN_ID,
  rpcUrl: process.env.MAINNET_RPC_URL!,
  auth: { __type: "ApiKeyHeader", value: process.env.RELAYER_API_KEY! },
});
  • On Sepolia, leave auth unset.
  • With auth set, @fhevm/sdk refuses a plain http: relayer URL unless the host is localhost.
  • A ZamaSDK you build yourself takes the same field on the chain: { ...mainnet, network: rpcUrl, auth: { __type: "ApiKeyHeader", value } }.

Browser: proxy the relayer#

Run a backend route that forwards relayer requests to https://relayer.mainnet.zama.organd adds the key, then point the browser encryptor's relayerUrl at it. The proxy must:

  • forward every sub-path and method: GET v2/keyurl, POST v2/input-proof, POST v2/user-decrypt / v3/user-decrypt, and the GET job polls that follow each POST;
  • pass the request body through unchanged and return the upstream status and body;
  • add x-api-key only on the way out, never echo it back.

The FHE public key and CRS download straight from the URLs v2/keyurl returns, not through the proxy, and no API key is sent there. Anyone who can reach the route spends your quota: put it behind a session or origin check and rate-limit it. If it authenticates with cookies, add CSRF protection, since the web() transport sends no CSRF token.

Next.js App Router route handler#

app/api/relayer/[...path]/route.ts
ts
// app/api/relayer/[...path]/route.ts - server only; the key never reaches the browser.
import { mainnet } from "@zama-fhe/sdk/chains";

const UPSTREAM = `${mainnet.relayerUrl}/`;

async function forward(req: Request, ctx: { params: Promise<{ path: string[] }> }) {
  const { path } = await ctx.params;
  const target = new URL(path.map(encodeURIComponent).join("/"), UPSTREAM);
  target.search = new URL(req.url).search;

  const upstream = await fetch(target, {
    method: req.method,
    headers: {
      "content-type": req.headers.get("content-type") ?? "application/json",
      "x-api-key": process.env.RELAYER_API_KEY!,
    },
    body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.text(),
  });

  return new Response(upstream.body, {
    status: upstream.status,
    headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
  });
}

export { forward as GET, forward as POST };

Express#

server/relayer-proxy.ts
ts
import express from "express";
import { mainnet } from "@zama-fhe/sdk/chains";

const UPSTREAM = `${mainnet.relayerUrl}/`;
const app = express();

// Browser clients point relayerUrl at https://<this host>/api/relayer.
app.use("/api/relayer", express.text({ type: "*/*", limit: "5mb" }), async (req, res) => {
  const target = new URL(req.url.replace(/^\//, ""), UPSTREAM);
  const hasBody = req.method !== "GET" && req.method !== "HEAD";

  const upstream = await fetch(target, {
    method: req.method,
    headers: {
      "content-type": req.get("content-type") ?? "application/json",
      "x-api-key": process.env.RELAYER_API_KEY!,
    },
    body: hasBody ? (req.body as string) : undefined,
  });

  res
    .status(upstream.status)
    .type(upstream.headers.get("content-type") ?? "application/json")
    .send(await upstream.text());
});

app.listen(3001);

Point the browser encryptor at the proxy#

relayerUrl must be absolute: @fhevm/sdk parses it with new URL(relayerUrl) and throws InvalidUrlError on a bare path such as "/api/relayer". No auth on the browser side: the proxy adds the key.

import { createSepoliaEncryptorWeb, MAINNET_CHAIN_ID } from "@tokenops/sdk/fhe/web";

// window only exists in the browser: build this in an effect or event handler.
const encryptor = await createSepoliaEncryptorWeb({
  publicClient,
  walletClient,
  chainId: MAINNET_CHAIN_ID,
  relayerUrl: new URL("/api/relayer", window.location.origin).href,
});

With @zama-fhe/react-sdk's ZamaProvider, put the proxy URL on the chain you pass to createConfig and hand the hooks encryptor: () => zamaSDK as usual.

import { mainnet as mainnetFhe, type FheChain } from "@zama-fhe/sdk/chains";

const mainnetViaProxy = {
  ...mainnetFhe,
  // Absolute URL of your proxy route; no auth here, the proxy adds the key.
  relayerUrl: `${process.env.NEXT_PUBLIC_APP_URL}/api/relayer`,
} as const satisfies FheChain;

Checklist#

  • RELAYER_API_KEY lives only in server environment variables.
  • Node encryptors on mainnet pass auth: { __type: "ApiKeyHeader", value }.
  • Browser encryptors on mainnet use an absolute relayerUrl pointing at your proxy, and no auth.
  • The proxy forwards all sub-paths and both GET and POST, and is rate-limited and authenticated.
  • CSP connect-src allows the proxy origin and the FHE key host.

See also