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.
| Chain | Relayer (@zama-fhe/sdk preset) | API key |
|---|---|---|
| Sepolia (11155111) | https://relayer.testnet.zama.org | None, the relayer is open |
| Mainnet (1) | https://relayer.mainnet.zama.org | Required 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 };| Mode | Sends | For |
|---|---|---|
ApiKeyHeader | x-api-key: <value> (or header) | The Zama-hosted mainnet relayer. The only mode it accepts. |
ApiKeyCookie | An x-api-key cookie (or cookie) | The browser-to-proxy hop of a proxy you run. |
BearerToken | Authorization: Bearer <token> | A self-hosted relayer behind a bearer auth layer. |
Server side: pass auth#
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
authunset. - With
authset,@fhevm/sdkrefuses a plainhttp:relayer URL unless the host islocalhost. - A
ZamaSDKyou 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 theGETjob polls that follow eachPOST; - pass the request body through unchanged and return the upstream status and body;
- add
x-api-keyonly 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 - 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#
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_KEYlives only in server environment variables.- Node encryptors on mainnet pass
auth: { __type: "ApiKeyHeader", value }. - Browser encryptors on mainnet use an absolute
relayerUrlpointing at your proxy, and noauth. - The proxy forwards all sub-paths and both
GETandPOST, and is rate-limited and authenticated. - CSP
connect-srcallows the proxy origin and the FHE key host.