The Redbelly Network EligibilitySDK is the compliance backbone for any dApp that needs to verify user eligibility (KYC, KYB, investor accreditation) before letting a wallet in. The official documentation covers each piece well on its own reference page, but there is no single walkthrough connecting the frontend widget to the backend verifier to the on-chain permission check to a production deployment. This guide is that walkthrough.
Everything here was verified against the live documentation at https://docs.redbelly.network/ in July 2026: contract addresses, route names, config fields, issuer DIDs and every error string in the reference section. Every code example was then compiled against the published SDK package (v0.0.31) on React 19 with Vite and on Next.js 16 with the App Router, and the backend verifier was booted and exercised for real. Where the docs and reality diverge (a quickstart repo that is not publicly visible, a credential faucet still under development, three undocumented behaviours the builds surfaced), the guide says so and gives you the workaround.
What you will build, in order:
- A mental model of the two verification mechanisms (and why conflating them costs you a day)
- A working backend verifier with the three routes the widget demands
- A plain React integration with full loading and error states
- A production-grade Next.js App Router setup: secure proxy, SIWE sessions, request gating, and both static and dynamic rendering approaches
- An end-to-end test run on Redbelly Testnet
- The decision logic for choosing between the three SDK flows, and the pattern for combining them
- A complete error reference: every documented error, its cause, and its fix
A developer following this guide should have the widget running inside an existing dApp within about four hours.
1. Overview and Architecture
What the EligibilitySDK actually is
The Redbelly "Onboarding and Eligibility Kit" (@redbellynetwork/eligibility-sdk) is a set of React components and hooks for proving and checking user eligibility on Redbelly Network:
- EligibilityWidget: a client-side widget that runs a zero-knowledge proof flow against your own backend verifier. It renders a QR code, a compatible identity wallet scans it and submits a proof, and the widget polls your backend until the proof resolves.
- IndividualOnboarding: a one-widget flow covering wallet authentication, KYC verification and Redbelly on-chain permission setup for retail users.
- BusinessOnboarding: a KYB flow provided by Averer for institutional users. On success it automatically deploys a Business Identifier contract on Redbelly and supports delegated wallet access.
- useHasChainPermission / useBusinessDetails: hooks that read permission state directly from Redbelly contracts.
The single most important concept
There are two separate mechanisms, not one pipeline:
- The credential verification flow (widget, backend, wallet). Your backend builds an authorisation request, the wallet answers it with a ZK proof, your backend cryptographically verifies the proof. This proves something about the user's credentials at a point in time.
-
The on-chain permission read (hooks).
useHasChainPermission(address)anduseBusinessDetails(address)are direct contract reads (wagmiuseReadContractunder the hood). They never touch your backend. They tell you whether an address currently holds on-chain permission, typically granted as the final step of an onboarding flow.
Conflating these two is the most common source of confusion. A user can pass a widget proof without holding on-chain permission, and an address can hold on-chain permission without your backend ever having seen a proof. Decide which question you are asking before you pick the tool.
Data-flow architecture
+--------------+ +--------------------+ +----------------------+
| Frontend | | Your Backend | | On-Chain Contracts |
| (React / | | (Verifier) | | (Redbelly Network) |
| Next.js) | | | | |
+------+-------+ +---------+----------+ +----------+-----------+
| | |
| 1. queryHandler() | |
|--------------------------->| POST /auth-request |
| | creates session, |
| | builds ZKP request |
|<---------------------------| { sessionId, request } |
| | |
| 2. widget renders QR code | |
| (encodes request) | |
| | |
| +---------------------------+ |
| | Privado identity wallet | |
| | scans QR, generates and | |
| | submits ZK proof | |
| +------------+--------------+ |
| | |
| | POST /callback?sessionId=... |
| +------------>| |
| | js-iden3-auth fullVerify() |
| | via EthStateResolver ---------+--> reads issuer
| | | state contract
| 3. authStatusHandler(id) | |
|--------------------------->| GET /status/:sessionId |
|<---------------------------| { status, proof } |
| | |
| 4. onSuccess(data) fires | |
| | |
| 5. useHasChainPermission(address) |
|------------------------------------------------------------>|
|<------------------------------------------------------------|
| direct contract read, no backend involved |
| |
| 6. useBusinessDetails(address) |
|------------------------------------------------------------>|
|<------------------------------------------------------------|
Steps 1 to 4 are the credential verification flow. Steps 5 and 6 are independent on-chain reads.
For readers on platforms that render Mermaid, the same flow:
sequenceDiagram
participant F as Frontend (widget)
participant B as Your backend (verifier)
participant W as Privado wallet
participant C as Redbelly contracts
F->>B: POST /auth-request (queryHandler)
B-->>F: { sessionId, request }
Note over F: Widget renders QR encoding the request
W->>B: POST /callback?sessionId=... (ZK proof)
B->>C: fullVerify() resolves issuer state (EthStateResolver)
F->>B: GET /status/:sessionId (authStatusHandler, polled)
B-->>F: { status: "success", proof }
Note over F: onSuccess(data) fires
F->>C: useHasChainPermission(address) - separate direct read
C-->>F: boolean
Why the backend is yours
The SDK does not ship a hosted verifier. Your backend owns three things:
- The verification scope: which credential, from which issuers, with which constraints. This must never come from the browser, otherwise a user could weaken their own eligibility check.
-
Session state: mapping
sessionIdto the pending request and eventually the verified proof. -
Cryptographic verification:
js-iden3-auth'sfullVerify(), resolving issuer state through Redbelly's state contract.
Section 3 builds this backend. Sections 4 and 5 connect the frontend to it.
Networks at a glance
| Chain ID | RPC | Issuer DID | |
|---|---|---|---|
| Testnet | 153 | https://governors.testnet.redbelly.network |
did:receptor:redbelly:testnet:31K82iKCtE6ciDc7oAr3T5EpjZb4S1EFM7c4xJaWkM2 |
| Mainnet | 151 | https://governors.mainnet.redbelly.network |
did:receptor:redbelly:mainnet:31AAH8sSaGd6fpnG1TcB6yQ4UZnNeyHzTk5aM2P7rjv |
| Staging | 153 (shared with testnet) | as testnet | same DID as testnet |
Chain IDs are confirmed against wagmi, chainid.network and thirdweb. Always cross-check contract addresses against the live Backend Setup page before deploying; the docs have carried inconsistent values on secondary pages in the past.
2. Installation and Setup
Prerequisites
- Node.js v18 or later
- react and react-dom v18.x or higher
- viem v2.x, wagmi v2.x, @tanstack/react-query v5.x
- A bundler: Vite for plain React, or Next.js with the App Router
- A GitHub personal access token (classic) with the
read:packagesscope. The SDK is published to GitHub Packages, not the public npm registry, so installation fails without it. - A Redbelly API key (
REDBELLY_API_KEY). Request one through Redbelly support. The BusinessOnboarding flow needs a separate API key issued by Averer Customer Support (averer.co).
Configure the GitHub Packages registry
Create or update .npmrc in your project root:
@redbellynetwork:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
always-auth=true
Export your token (do not commit it):
export GITHUB_TOKEN=ghp_your_token_with_read_packages
Install the SDK and peer dependencies
npm install @redbellynetwork/eligibility-sdk
npm install @reown/appkit @reown/appkit-adapter-wagmi wagmi viem @tanstack/react-query
@reown/appkit (formerly WalletConnect) handles EVM wallet connection for the on-chain hooks and onboarding flows. You will need a free Reown Cloud project ID for createAppKit.
Environment variables
For a typical integration you will end up with:
# .env.local (never commit)
REDBELLY_API_KEY=your_api_key # server side only
ALLOWED_ISSUER_DID=did:receptor:redbelly:testnet:31K82iKCtE6ciDc7oAr3T5EpjZb4S1EFM7c4xJaWkM2
VITE_PROJECT_ID=your_reown_project_id # NEXT_PUBLIC_PROJECT_ID in Next.js
Two rules that prevent real incidents:
- The API key is server-side only. The SDK emits development-mode warnings if it detects an API key exposed client-side. In Next.js, route it through the proxy pattern in Section 5 instead.
-
Pin the issuer DID per network. The example backends ship with
allowedIssuers: ["*"]. Replace the wildcard with the exact DID for your network (table in Section 1) before anything leaves your machine. Accepting any issuer defeats the point of the check.
A note on the quickstart repository
The official Getting Started page instructs you to clone https://github.com/redbellynetwork/eligibility-sdk-quickstart. As of July 2026 that repository is not publicly visible on GitHub. If you hit a 404, request access through Redbelly support or Discord, or build from the code in Sections 3 to 5 of this guide, which is self-contained and does not depend on the quickstart repo.
3. Backend Verifier
The widget is backend-agnostic but contract-strict: it expects exactly three routes, and the docs warn that mismatched route names break the widget silently (no thrown error, the flow just never resolves).
| Route | Method | Purpose |
|---|---|---|
/auth-request |
POST | Creates a new proof session, returns { request, sessionId }
|
/callback |
POST | Receives the ZK proof token from the identity wallet |
/status/:sessionId |
GET | Returns current session status and the resolved proof or error |
3.1 Dependencies
npm install express cors @iden3/js-iden3-auth uuid
3.2 Verification keys
fullVerify() requires the Iden3 trusted-setup public verification keys in a folder named keys/ at your backend project root. The official docs point to the Iden3 documentation and the Privado ID verifier setup guide for these, but in practice you do not need to download anything: the @iden3/js-iden3-auth package ships them. After npm install:
cp -r node_modules/@iden3/js-iden3-auth/circuits keys
If the folder is missing the verifier fails at initialisation, not at request time.
backend/
server.js
keys/
credentialAtomicQuerySigV2/
verification_key.json
credentialAtomicQuerySigV2OnChain/
verification_key.json
...
3.3 Register the Redbelly DID method
Redbelly credentials use the receptor DID method, which js-iden3-auth does not know about by default. Register it before constructing the verifier:
const { core } = require("@iden3/js-iden3-auth");
core.registerDidMethodNetwork({
method: "receptor",
methodByte: 0b10000011,
blockchain: "redbelly",
network: "testnet",
networkFlag: 0b10000011,
chainId: 153,
});
core.registerDidMethodNetwork({
method: "receptor",
methodByte: 0b01010111,
blockchain: "redbelly",
network: "mainnet",
networkFlag: 0b01010111,
chainId: 151,
});
3.4 State resolvers
The verifier resolves issuer state through a state contract per network. Values below are from the live Backend Setup page (verified July 2026); re-check them there before deploying, since other doc pages have carried different values:
| Network | RPC URL | State contract |
|---|---|---|
| Redbelly Testnet | https://governors.testnet.redbelly.network |
0x69376715FB5E2B924a33e9C27302F52DEa178CDC |
| Redbelly Mainnet | https://governors.mainnet.redbelly.network |
0x1cc7261e1777D69505Cb6413a91bb27ca9eb1456 |
| Privado Mainnet | https://rpc-mainnet.privado.id |
0x3C9acB2205Aa72A05F6D77d708b5Cf85FCa3a896 |
The Privado resolver is included because wallet identities live on the Privado ID network; the verifier may need to resolve both.
3.5 Complete verifier server
// server.js
const express = require("express");
const cors = require("cors");
const { randomUUID } = require("crypto");
const path = require("path");
const { auth, resolver, core } = require("@iden3/js-iden3-auth");
// --- DID method registration (Section 3.3) must run first ---
core.registerDidMethodNetwork({
method: "receptor", methodByte: 0b10000011,
blockchain: "redbelly", network: "testnet",
networkFlag: 0b10000011, chainId: 153,
});
core.registerDidMethodNetwork({
method: "receptor", methodByte: 0b01010111,
blockchain: "redbelly", network: "mainnet",
networkFlag: 0b01010111, chainId: 151,
});
const app = express();
app.use(express.text());
app.use(cors());
// In-memory session state. Use Redis or similar in production.
const requestMap = new Map(); // sessionId -> auth request
const statusMap = new Map(); // sessionId -> { status, proof?, error? }
// PUBLIC_URL must be reachable by the wallet (ngrok in local dev, Section 6).
const PUBLIC_URL = process.env.PUBLIC_URL || "https://your-tunnel.ngrok.app";
const AUDIENCE_DID = process.env.SENDER_DID ||
"did:receptor:redbelly:testnet:31Jz2omB1fL33eGkuwi8vXKuxfS3XTck7X58XWuovE";
const ALLOWED_ISSUER_DID = process.env.ALLOWED_ISSUER_DID ||
"did:receptor:redbelly:testnet:31K82iKCtE6ciDc7oAr3T5EpjZb4S1EFM7c4xJaWkM2";
const resolvers = {
"redbelly:testnet": new resolver.EthStateResolver(
"https://governors.testnet.redbelly.network",
"0x69376715FB5E2B924a33e9C27302F52DEa178CDC"
),
"privado:main": new resolver.EthStateResolver(
"https://rpc-mainnet.privado.id",
"0x3C9acB2205Aa72A05F6D77d708b5Cf85FCa3a896"
),
};
// Initialised asynchronously at startup; see the bottom of the file.
let verifier;
// 1. Widget calls this via queryHandler
app.post("/auth-request", (req, res) => {
const sessionId = randomUUID();
const callbackUrl = `${PUBLIC_URL}/callback?sessionId=${sessionId}`;
const request = auth.createAuthorizationRequest(
"Verify eligibility", // reason shown in the wallet
AUDIENCE_DID,
callbackUrl
);
// The scope is defined HERE, server side, and nowhere else.
request.body.scope = [
{
id: 1,
circuitId: "credentialAtomicQuerySigV2",
query: {
allowedIssuers: [ALLOWED_ISSUER_DID], // never leave this as ["*"]
context:
"https://raw.githubusercontent.com/redbellynetwork/receptor-schema/main/json-ld/AMLCTFCredential.jsonld",
type: "AMLCTFCredential",
credentialSubject: {
amlCheckStatus: { $eq: "passed" },
},
},
},
];
requestMap.set(sessionId, request);
statusMap.set(sessionId, { status: "idle" });
res.status(200).json({ request, sessionId });
});
// 2. The identity wallet posts the proof here
app.post("/callback", async (req, res) => {
const sessionId = req.query.sessionId;
const authRequest = requestMap.get(sessionId);
if (!authRequest) {
return res.status(400).send("Invalid session ID");
}
statusMap.set(sessionId, { status: "verifying" });
try {
const authResponse = await verifier.fullVerify(req.body, authRequest);
statusMap.set(sessionId, { status: "success", proof: authResponse });
res.status(200).json(authResponse);
} catch (err) {
console.error("Error verifying token", err);
statusMap.set(sessionId, { status: "failed", error: err.message });
res.status(500).send(err.message);
}
});
// 3. Widget polls this via authStatusHandler
app.get("/status/:sessionId", (req, res) => {
const state = statusMap.get(req.params.sessionId);
if (!state) {
return res.status(400).send("Invalid session ID");
}
res.status(200).json(state);
});
async function start() {
// Fails here if the keys/ folder is missing; see Section 3.2.
verifier = await auth.Verifier.newVerifier({
stateResolver: resolvers,
circuitsDir: path.join(__dirname, "keys"),
});
app.listen(8080, () => console.log("Verifier listening on :8080"));
}
start().catch((err) => {
console.error("Verifier failed to start", err);
process.exit(1);
});
Adapted from the working example on the official Backend Setup page; use that page's version as your baseline and diff against this one.
3.6 Things that will bite you
-
Route names are a hard contract.
/auth-requests(plural) or/status?id=instead of/status/:sessionIdproduce no error anywhere; the widget simply polls forever. -
The scope stays server side. If the browser can influence
request.body.scope, a user can substitute a weaker query and pass verification they should fail. Map a stable flow ID to a predefined scope server-side if you need multiple eligibility flows. allowedIssuers: ["*"]is for local experiments only.-
Keep the scope small. The request is encoded into a QR code with roughly 3 KB of reliable capacity. One or two queries maximum; oversized
credentialSubjectobjects cause the QR encoder to fail with "Data too long" (see Section 8). -
Revocation checks stay on in production. Only set
skipClaimRevocationCheck: truein an explicitly marked test environment, and never copy it into production config.
4. React Integration
This section builds a plain React (Vite) integration: provider setup, the EligibilityWidget wired to the backend from Section 3, and both on-chain hooks with proper loading and error states.
4.1 Provider setup (App.tsx)
The root must nest three providers in this order: WagmiProvider (wallet connections), QueryClientProvider (async state), EligibilitySDKProvider (SDK config).
// src/App.tsx
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiAdapter } from "@reown/appkit-adapter-wagmi";
import { createAppKit } from "@reown/appkit";
import { defineChain } from "@reown/appkit/networks";
import { EligibilitySDKProvider } from "@redbellynetwork/eligibility-sdk";
import Home from "./Home";
export const projectId = import.meta.env.VITE_PROJECT_ID || "your-reown-project-id";
const redbellyTestnet = defineChain({
id: 153,
caipNetworkId: "eip155:153",
chainNamespace: "eip155",
name: "Redbelly Network Testnet",
nativeCurrency: { decimals: 18, name: "Redbelly Token", symbol: "RBNT" },
rpcUrls: { default: { http: ["https://governors.testnet.redbelly.network/"] } },
});
const wagmiAdapter = new WagmiAdapter({ projectId, networks: [redbellyTestnet] });
const queryClient = new QueryClient();
createAppKit({
adapters: [wagmiAdapter],
projectId,
networks: [redbellyTestnet],
metadata: {
name: "Eligibility SDK Example",
description: "EligibilitySDK with Redbelly Network",
url: "https://example.com",
icons: [],
},
themeMode: "light",
});
export default function App() {
return (
<WagmiProvider config={wagmiAdapter.wagmiConfig}>
<QueryClientProvider client={queryClient}>
<EligibilitySDKProvider
config={{
network: "testnet", // "staging" | "testnet" | "mainnet"
}}
>
{/* AppKit's web component wallet button */}
<appkit-button />
<Home />
</EligibilitySDKProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
Notes:
-
networkselects which Redbelly environment the SDK and hooks target. - The official example passes
apiKeyhere for demo purposes. Anything in client bundle is public; for production use theproxyUrlpattern from Section 5 instead, and keep the key server side. The SDK warns in development mode if it detects a client-exposed key. -
<appkit-button />is a web component registered bycreateAppKit; in strict TypeScript setups declare it in a.d.tsor use AppKit's hooks instead.
4.2 The widget with full state handling (Home.tsx)
// src/Home.tsx
import { useState } from "react";
import { useAccount } from "wagmi";
import { EligibilityWidget } from "@redbellynetwork/eligibility-sdk";
import PermissionStatus from "./PermissionStatus";
import BusinessStatus from "./BusinessStatus";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:8080";
type Profile = {
userDID: string;
sessionId: string;
status: string;
};
export default function Home() {
const { address } = useAccount();
const [profile, setProfile] = useState<Profile | null>(null);
const [flowError, setFlowError] = useState<string | null>(null);
// Asks YOUR backend to open a session and build the ZKP request.
// The scope is decided server side; the browser only relays it.
const queryHandler = async () => {
const res = await fetch(`${BACKEND_URL}/auth-request`, { method: "POST" });
if (!res.ok) throw new Error(`auth-request failed: ${res.status}`);
const data = await res.json();
return { request: data.request, sessionId: data.sessionId };
};
// Polled by the widget until status resolves.
const authStatusHandler = async (sessionId: string) => {
const res = await fetch(`${BACKEND_URL}/status/${sessionId}`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error(`status failed: ${res.status}`);
const data = await res.json();
if (data.status === "failed") setFlowError(data.error ?? "Verification failed");
return { status: data.status, proof: data?.proof, error: data?.error };
};
const onSuccessHandler = (data?: {
userDID: string;
sessionId: string;
status: string;
}) => {
if (!data) return;
setFlowError(null);
setProfile({
userDID: data.userDID,
sessionId: data.sessionId,
status: data.status,
});
};
return (
<main>
<h1>Eligibility check</h1>
<EligibilityWidget
onSuccess={onSuccessHandler}
config={{ queryHandler, authStatusHandler }}
/>
{flowError && (
<p role="alert">Verification failed: {flowError}. Try again or contact support.</p>
)}
{profile && (
<section>
<h2>Credential verified</h2>
<p>DID: {profile.userDID}</p>
<p>Session: {profile.sessionId}</p>
</section>
)}
{/* Separate mechanism: direct on-chain reads for the connected wallet */}
{address && (
<>
<PermissionStatus userAddress={address} />
<BusinessStatus businessAddress={address} />
</>
)}
</main>
);
}
4.3 useHasChainPermission: individual permission read
Direct on-chain read; returns the shape of wagmi's useReadContract.
// src/PermissionStatus.tsx
import { useHasChainPermission } from "@redbellynetwork/eligibility-sdk";
export default function PermissionStatus({ userAddress }: { userAddress: string }) {
const { data, error, isLoading, refetch } = useHasChainPermission(userAddress);
if (isLoading) return <p>Checking on-chain permission...</p>;
if (error) {
// Typical causes: RPC unreachable, wallet on the wrong network,
// malformed address. See the error reference, Section 8.
return (
<div role="alert">
<p>Could not read permission state: {error.message}</p>
<button onClick={() => refetch()}>Retry</button>
</div>
);
}
return data ? (
<p>{userAddress} holds Redbelly chain permission.</p>
) : (
<p>{userAddress} does not hold chain permission yet. Complete onboarding first.</p>
);
}
4.4 useBusinessDetails: business permission read
Same pattern, richer payload. Note the refetch function is named refetchBusinessIdentifier.
// src/BusinessStatus.tsx
import { useBusinessDetails } from "@redbellynetwork/eligibility-sdk";
export default function BusinessStatus({ businessAddress }: { businessAddress: string }) {
const { data, error, isLoading, refetchBusinessIdentifier } =
useBusinessDetails(businessAddress);
if (isLoading) return <p>Checking business registration...</p>;
if (error) {
// Unlike useHasChainPermission, this hook types error as unknown,
// so narrow it before reading a message.
const message = error instanceof Error ? error.message : String(error);
return (
<div role="alert">
<p>Could not read business state: {message}</p>
<button onClick={() => refetchBusinessIdentifier()}>Retry</button>
</div>
);
}
if (!data?.isBusinessUser) {
return <p>{businessAddress} is not registered as a business user.</p>;
}
return (
<section>
<h3>Registered business</h3>
<p>Business Identifier contract: {data.businessContractAddress}</p>
<pre>{JSON.stringify(data.businessDetails, null, 2)}</pre>
</section>
);
}
data.businessDetails carries company metadata: name, identifier and identifier type, incorporated name, beneficial-owner flag and company address. It can be null, so keep the optional chaining.
Two type details confirmed against the published package (v0.0.31) that the docs do not spell out: useHasChainPermission returns wagmi's UseReadContractReturnType directly (its error carries a message), while useBusinessDetails types error as unknown, so narrow it as above before rendering.
4.5 Checklist before moving on
- [ ] Widget renders a QR code (if it fails with "Data too long", shrink the backend scope)
- [ ] Wallet scan reaches your backend (needs the ngrok tunnel from Section 6 when local)
- [ ]
onSuccessfires and the profile renders - [ ] Hooks show loading, error and success states correctly
- [ ] No API key in the client bundle (
grepyour build output if unsure)
5. Next.js App Router Integration
The App Router integration adds what plain React cannot give you: a server boundary. That is where the API key lives, where proxy requests get validated, and where sessions are enforced.
5.1 Division of responsibilities
The SDK handles: the client-side UI, calling your configured backend endpoints, secure proxying via proxyUrl, attaching X-App-Auth when getAuthToken() is supplied, and development warnings if an API key leaks client side.
You must handle: validating proxy requests (sessions, tokens or signed headers), rate limiting, domain allowlisting, injecting x-api-key server side only, CORS and origin checks.
5.2 Provider config reference
type EligibilitySDKConfig = {
network: "staging" | "testnet" | "mainnet";
proxyUrl?: string; // route SDK traffic through your own API route
apiKey?: string; // server side only; never in client bundles
getAuthToken?: () => string | undefined; // attached as X-App-Auth if defined
customHeaders?: Record<string, string>;
getCustomHeaders?: () => Promise<Record<string, string>>;
includeCredentials?: boolean; // send cookies with SDK requests
};
5.3 The client boundary: why ssr: false
The widget manipulates browser-only APIs (wallet connections, QR rendering), so it cannot render on the server. Load the provider dynamically with SSR disabled:
// app/providers.tsx
"use client";
import dynamic from "next/dynamic";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { wagmiAdapter } from "@/lib/appkit"; // AppKit setup as in 4.1; see note below
const EligibilitySDKProvider = dynamic(
() =>
import("@redbellynetwork/eligibility-sdk").then(
(mod) => mod.EligibilitySDKProvider
),
{ ssr: false }
);
const queryClient = new QueryClient();
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={wagmiAdapter.wagmiConfig}>
<QueryClientProvider client={queryClient}>
<EligibilitySDKProvider
config={{
network: "testnet",
proxyUrl: "/api/sdk", // all SDK traffic goes through our proxy
getAuthToken: () => undefined, // wire to your session if needed
includeCredentials: true, // send the session cookie
}}
>
{children}
</EligibilitySDKProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
One adjustment to the Section 4.1 AppKit setup when it moves into Next.js: guard the createAppKit call with if (typeof window !== "undefined"). Client components still execute on the server during prerendering, and an unguarded module-scope createAppKit throws a noisy (non-fatal) "Telemetry is not supported in non-browser environments" error into every build. The WagmiAdapter construction itself is safe on both sides; only the createAppKit call needs the guard. Verified against a production next build on Next.js 16.
5.4 Static vs dynamic rendering: the two required approaches
The task of "static vs dynamic" maps cleanly onto two page patterns.
Approach A: static shell, client-only widget
The page itself is statically prerendered (fast, cacheable, indexable). Only the widget island hydrates client side. Use this for public pages: marketing, token sale landing pages, anything where the eligibility check is an action on the page rather than a gate in front of it.
// app/verify/page.tsx (Server Component, statically prerendered)
import VerifyClient from "./verify-client";
export const metadata = { title: "Verify your eligibility" };
export default function VerifyPage() {
return (
<main>
<h1>Verify your eligibility</h1>
<p>This page is static. The widget below loads in your browser only.</p>
<VerifyClient />
</main>
);
}
// app/verify/verify-client.tsx
"use client";
import dynamic from "next/dynamic";
// The widget subtree is client-only; the page around it stays static.
const EligibilityFlow = dynamic(() => import("@/components/eligibility-flow"), {
ssr: false,
loading: () => <p>Loading verification widget...</p>,
});
export default function VerifyClient() {
return <EligibilityFlow />;
}
@/components/eligibility-flow renders <EligibilityWidget> exactly as in Section 4.2, with handlers pointing at relative routes (/auth-request etc. through the proxy) instead of an absolute backend URL.
Approach B: dynamic, gated route
The page is rendered per request and sits behind a SIWE session: the request gate (proxy.ts in Next.js 16, middleware.ts before that; see 5.7) rejects anyone without a valid JWT cookie before the page or the proxy route runs. Use this for authenticated dashboards and any route where eligibility gates access rather than being an action.
// app/dashboard/page.tsx (dynamic: reads per-request cookies)
import { cookies } from "next/headers";
import { verifySession } from "@/lib/session";
import DashboardClient from "./dashboard-client";
export default async function DashboardPage() {
const token = (await cookies()).get("session")?.value;
const session = await verifySession(token); // throws/redirects if invalid
return (
<main>
<h1>Compliance dashboard</h1>
<p>Signed in as {session.address}</p>
<DashboardClient address={session.address} />
</main>
);
}
Reading cookies() opts the route into dynamic rendering automatically; no extra config needed.
5.5 The secure proxy route
All SDK traffic flows through one API route that validates the target, enforces HTTPS, blocklists IPs, rate limits, and injects the API key server side.
// app/api/sdk/route.ts
import { NextRequest, NextResponse } from "next/server";
const allowedDomains = ["api.your-verifier.example.com"];
const blockedIPs = new Set<string>([]);
const rateLimit = new Map<string, { count: number; windowStart: number }>();
const WINDOW_MS = 60_000;
const MAX_REQUESTS = 30;
function clientIp(req: NextRequest) {
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
}
async function proxy(req: NextRequest) {
const ip = clientIp(req);
if (blockedIPs.has(ip)) {
return new NextResponse("Forbidden IP", { status: 403 });
}
const bucket = rateLimit.get(ip) ?? { count: 0, windowStart: Date.now() };
if (Date.now() - bucket.windowStart > WINDOW_MS) {
bucket.count = 0;
bucket.windowStart = Date.now();
}
bucket.count += 1;
rateLimit.set(ip, bucket);
if (bucket.count > MAX_REQUESTS) {
return new NextResponse("Too Many Requests", { status: 429 });
}
const backend = req.headers.get("x-api-backend");
if (!backend) {
return new NextResponse("Missing x-api-backend", { status: 400 });
}
let target: URL;
try {
target = new URL(backend);
} catch {
return new NextResponse("Invalid x-api-backend", { status: 400 });
}
if (target.protocol !== "https:" || !allowedDomains.includes(target.hostname)) {
return new NextResponse("Blocked domain", { status: 400 });
}
const res = await fetch(target, {
method: req.method,
headers: {
"Content-Type": req.headers.get("content-type") ?? "application/json",
"x-api-key": process.env.REDBELLY_API_KEY!, // never reaches the client
},
body: req.method === "GET" ? undefined : await req.text(),
});
return new NextResponse(await res.text(), { status: res.status });
}
export { proxy as GET, proxy as POST };
The official docs wrap this in a createSecureProxy helper; the checks are the same. Every error string above (Missing x-api-backend, Invalid x-api-backend, Blocked domain, Forbidden IP, Too Many Requests) is documented in Section 8.
5.6 SIWE session route
Sign-In with Ethereum gives the proxy something to authenticate. viem/siwe verifies the signed message; jose mints an HS256 JWT stored as an httpOnly cookie.
// app/api/session/route.ts
import { NextRequest, NextResponse } from "next/server";
import { SignJWT } from "jose";
import { createPublicClient, http } from "viem";
import { parseSiweMessage, verifySiweMessage } from "viem/siwe";
const secret = new TextEncoder().encode(process.env.SESSION_SECRET!);
const client = createPublicClient({
transport: http("https://governors.testnet.redbelly.network"),
});
export async function POST(req: NextRequest) {
const { message, signature } = await req.json();
const valid = await verifySiweMessage(client, { message, signature });
if (!valid) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { address } = parseSiweMessage(message);
const jwt = await new SignJWT({ address })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);
const res = NextResponse.json({ ok: true });
res.cookies.set("session", jwt, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 60 * 60,
});
return res;
}
5.7 The request gate (proxy.ts, formerly middleware.ts)
Next.js 16 renamed the request interception file: middleware.ts became proxy.ts, the exported function middleware became proxy, and the exported config became proxyConfig. Same capabilities, new names. On Next.js 16 or later:
// proxy.ts (project root, or src/ if you use a src directory)
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.SESSION_SECRET!);
export async function proxy(req: NextRequest) {
const token = req.cookies.get("session")?.value;
if (!token) {
return new NextResponse("Unauthorized: No token", { status: 401 });
}
try {
await jwtVerify(token, secret);
return NextResponse.next();
} catch {
return new NextResponse("Invalid session token", { status: 401 });
}
}
export const proxyConfig = {
matcher: ["/api/sdk/:path*", "/dashboard/:path*"],
};
On Next.js 14 or 15, the identical logic lives in middleware.ts with export async function middleware and export const config. The official SDK docs show the older form; npx @next/codemod@latest upgrade renames it for you when you upgrade.
Do not confuse this file with the /api/sdk proxy route from 5.5: this one intercepts requests before they reach any route; that one forwards SDK traffic to your verifier. Unfortunate naming overlap, two different jobs.
5.8 Putting it together
Request path in production:
- User signs a SIWE message;
/api/sessionsets the JWT cookie. - The request gate (5.7) admits requests to
/api/sdk/*and/dashboard/*only with a valid cookie. - The widget (configured with
proxyUrl: "/api/sdk",includeCredentials: true) sends all verifier traffic through the proxy. - The proxy validates target domain, rate limits, and injects
x-api-keyserver side. - On-chain hooks still read directly from the RPC; they need no session.
6. Testnet End-to-End Testing
Nothing in this stack is fully testable in isolation: the wallet needs to reach your backend, the backend needs to resolve issuer state on chain, and the widget needs both. This section wires it all together on Redbelly Testnet (chain ID 153).
6.0 Read this first: your localhost is invisible to the wallet
The proof is submitted by a mobile identity wallet, over the internet, to your /callback route. http://localhost:3000 means nothing to a phone. Expose your dev server with a tunnel before testing the flow:
ngrok http 3000
Use the resulting https://xxxx.ngrok.app URL as the PUBLIC_URL your backend embeds in the callback URL (Section 3.5). This is the single most commonly missed requirement in the whole integration; if the widget's QR scans fine but the status never leaves "idle", start here.
6.1 Prerequisites
- Node.js v18+
- GitHub PAT (classic) with
read:packages(Section 2) -
REDBELLY_API_KEYfrom Redbelly support - ngrok (or an equivalent tunnel)
- A compatible identity wallet: Privado Web or Privado Mobile. Guardian wallet support is listed as coming soon. The wallet should start empty so you can observe the full issuance-then-verification flow.
6.2 Setup options
Option A: the official quickstart. The docs point to https://github.com/redbellynetwork/eligibility-sdk-quickstart (Node v18+, .npmrc per Section 2, then cp env.local.example .env.local and npm run dev, which starts the verifier and the Next.js frontend together). As of July 2026 the repository is not publicly visible; if you get a 404, ask in the Redbelly Discord or use Option B.
Option B: this guide's stack. Run the Section 3 verifier and the Section 4 or 5 frontend. Same result, fully self-contained.
Either way, .env.local needs:
REDBELLY_API_KEY=your_api_key
ALLOWED_ISSUER_DID="did:receptor:redbelly:testnet:31K82iKCtE6ciDc7oAr3T5EpjZb4S1EFM7c4xJaWkM2"
SENDER_DID="did:receptor:redbelly:testnet:31Jz2omB1fL33eGkuwi8vXKuxfS3XTck7X58XWuovE"
PUBLIC_URL=https://your-tunnel.ngrok.app
6.3 Obtaining a test credential
To pass verification the wallet must hold a credential issued by the allowed testnet issuer.
- Open the demo credential faucet linked from the Getting Started page. Caveat: the docs mark the faucet as under development as of this writing; if it is unavailable, ask in the Redbelly Discord for the current way to obtain a test "KYC Verified" credential.
- Scan the faucet's QR code with your (empty) Privado wallet and accept the credential.
6.4 The end-to-end run
- Start the backend verifier (confirm it logs a clean start; a missing
keys/folder fails here, not later). - Start the frontend (
npm run dev). - Start ngrok and confirm
PUBLIC_URLmatches the live tunnel URL (it changes on every ngrok restart unless you have a reserved domain). - Open the app, connect a wallet, and trigger the eligibility check.
- Scan the widget's QR with the wallet that holds the test credential.
- Approve proof sharing in the wallet.
- Watch the widget move through
verifyingtosuccessand confirmonSuccessfires with the expected DID and claims. - Confirm the on-chain hooks: after onboarding,
useHasChainPermission(address)should flip totruefor the onboarded wallet.
6.5 What to check when it does not work
| Symptom | First place to look |
|---|---|
| QR never renders, "Data too long" | Scope too large; trim to 1 or 2 queries (Section 8) |
| Wallet scans, nothing happens server side | ngrok down or PUBLIC_URL stale |
Status stuck on idle forever |
Route name mismatch: the three routes are a hard contract |
/callback returns 400 Invalid session ID
|
Backend restarted (in-memory map wiped) or wrong sessionId in callback URL |
/callback returns 500 |
Proof failed fullVerify(): wrong issuer DID, revoked credential, wrong network resolver, or missing keys |
| Hooks error out | RPC unreachable, wallet on wrong chain (needs 153), malformed address |
Full error catalogue in Section 8.
7. Integration Patterns: Choosing and Combining the SDKs
The kit ships three user-facing flows. Picking the wrong one costs days; this section is the routing logic.
7.1 The three flows in one line each
- IndividualOnboarding: one widget that takes a retail user from nothing to fully permissioned: wallet auth, KYC, on-chain permission setup.
-
BusinessOnboarding (provided by Averer): KYB verification of a business officer, automatic deployment of a Business Identifier contract on Redbelly, then delegated access so additional company wallets can act for the business without individual KYB. Note the import name: the package exports this component as
BusinessOnboardingSdk(confirmed against v0.0.31), notBusinessOnboarding. - EligibilityWidget (standalone): runs one ZK proof flow against your backend with a scope you define. Deploys nothing, changes no on-chain state; it answers a question.
7.2 Decision tree
What kind of user must pass eligibility?
|
+-------------------------+--------------------------+
| |
Retail / individual end user Institutional / business entity
| |
v v
IndividualOnboarding BusinessOnboarding (Averer)
wallet auth + KYC + on-chain KYB + auto-deployed Business
permission, one widget Identifier contract + delegated
| wallet access
| |
v v
Check status afterwards with Check status afterwards with
useHasChainPermission(address) useBusinessDetails(address)
| |
+-------------------------+--------------------------+
|
Need a one-off eligibility check NOT tied
to onboarding? (gate a single feature,
page or offering)
|
v
Standalone EligibilityWidget with a custom
server-side scope: runs the proof flow only,
no onboarding state created
Routing rules of thumb:
- The user is a person acting for themselves: Individual path.
- The entity is a company, and several employees' wallets will act for it: Business path. Delegated access is the deciding feature; without it every employee needs their own KYB.
- You need to ask a specific question of an already-onboarded user ("is this wallet an accredited investor?"): standalone widget with a narrow scope.
- You only need to know whether an address is already permissioned: no widget at all; just the hook. Cheapest option and often overlooked.
7.3 The onboard-once, prove-per-feature pattern
The most useful combination: use an Onboarding SDK for the one-time "become eligible" event, then the standalone EligibilityWidget for repeated, feature-specific checks afterwards.
Example: an RWA platform onboards a retail investor once with IndividualOnboarding (KYC plus chain permission). Months later that investor opens a wholesale-only offering page. The page does not re-run onboarding; it renders an EligibilityWidget whose backend scope asks only for AUSophisticatedWholesaleInvestorCredential. Ten seconds in the wallet instead of a full KYC round trip.
Implementation notes for this pattern:
- Give each feature check its own scope server side, keyed by a stable flow ID (Section 3.6). Do not accept scope hints from the client.
- Gate the page itself with
useHasChainPermissionfirst; only render the widget for permissioned wallets. This avoids offering a proof flow to users who have not onboarded at all. - Both widgets share the same
EligibilitySDKProvider; you do not need separate provider trees.
7.4 Why the onboarding SDKs use ZK proofs (and when selective disclosure fits)
Redbelly's credential system (Receptor) supports two proof methods, and the docs dedicate a page to choosing between them:
- Selective disclosure (off-chain): the user reveals specific credential fields to a verifier that they choose to trust, e.g. sharing a verified birthday with a registration form. Data is revealed; the trust model is "trust the verifier".
- Proof by query / zero-knowledge (Iden3 circuits, on-chain compatible): the user proves a statement ("AML check passed", "over 18") without revealing underlying values. Trustless, auditable, suitable for smart-contract gating.
The onboarding SDKs sit on the ZK path because their end state is on-chain permission: a contract has to be able to rely on the outcome without anyone re-inspecting personal data. Use selective disclosure only for off-chain workflows where a human or service legitimately needs field values (account registration, compliance records), and keep it out of anything a contract enforces.
7.5 Credential types available for custom scopes
When you build a custom scope for the standalone widget, these credential types are documented in the redbellynetwork/receptor-schema repository: AMLCTFCredential, AUSophisticatedWholesaleInvestorCredential, DriversLicenceCredential, EssentialIdCredential, NationalIdCredential, PassportCredential, ProofOfAddressCredential. Query operators: $eq, $ne, $lt, $lte, $gt, $gte, $between, $nonbetween, $in, $nin, $exists; selective disclosure uses an empty object for the field instead of an operator.
8. Error Reference
Every error condition documented across the official SDK pages, compiled into one place. The official docs have no consolidated error page; these were collected from the Backend Setup, React, Next.js App Router, API Reference and hook pages, plus failure modes the docs describe without an error string.
Format: error as heading, then Cause, Example scenario, Fix.
Widget session status: "idle" | "verifying" | "failed" | "success"
Cause: not an error but the enum every authStatusHandler response must use. Anything else is ignored by the widget.
Example scenario: your backend returns { status: "pending" }; the widget treats the session as never progressing.
Fix: map backend states onto exactly these four strings. failed should carry an error string; success should carry the proof.
Invalid session ID (HTTP 400, backend /callback and /status/:sessionId)
Cause: the sessionId is not present in the backend's request map.
Example scenario: you restarted the verifier between rendering the QR and the wallet submitting the proof; the in-memory map was wiped, so the wallet's callback hits a session that no longer exists.
Fix: retry the flow from the start so a fresh session is created. In production move session state to a shared store (Redis) so restarts and multi-instance deployments do not orphan sessions. Also confirm the callback URL embeds the same sessionId the widget received from /auth-request.
fullVerify() throws (HTTP 500 from /callback, message from err.message; console logs "Error verifying token")
Cause: the submitted proof failed cryptographic verification.
Example scenario: the wallet holds a credential from a different issuer than your allowedIssuers pins; or the credential was revoked; or your resolver points at the wrong network's state contract so issuer state cannot be resolved.
Fix: check, in order: (1) allowedIssuers matches the exact DID for your target network; (2) the credential in the wallet is for the same network (testnet credential against testnet verifier); (3) the EthStateResolver RPC and contract address match the live Backend Setup page; (4) the credential is not revoked; (5) the keys/ folder contains the verification keys for the circuit named in your scope.
Data too long (QR encoder failure, client side)
Cause: the authorisation request is too large to encode. QR codes reliably hold about 3 KB.
Example scenario: a scope with three queries, each with a multi-field credentialSubject, renders a blank space where the QR should be and the console shows the encoder error.
Fix: keep the scope to 1 or 2 queries and keep credentialSubject minimal. If you need many checks, split them across separate, feature-specific widget flows (Section 7.3).
Missing x-api-backend (HTTP 400, Next.js proxy route)
Cause: a request reached the proxy without the x-api-backend header naming the target.
Example scenario: you call /api/sdk from your own code (not via the SDK) and forget the header; or a scanner probes the route directly.
Fix: requests originating from the SDK set this header when proxyUrl is configured. For manual calls, set x-api-backend to the full HTTPS URL of the verifier endpoint.
Invalid x-api-backend (HTTP 400, Next.js proxy route)
Cause: the header value is not a parseable URL.
Example scenario: the header carries a bare hostname (api.example.com) instead of a full URL.
Fix: send an absolute HTTPS URL.
Blocked domain (HTTP 400, Next.js proxy route)
Cause: the target URL's hostname is not on the proxy's allowedDomains allowlist, or the URL is not HTTPS.
Example scenario: you moved the verifier to a new subdomain and updated the SDK config but not the proxy allowlist.
Fix: add the exact hostname to allowedDomains and confirm the scheme is https:. Treat this error as the allowlist doing its job; never widen the list to a wildcard.
Forbidden IP (HTTP 403, Next.js proxy route)
Cause: the caller's IP is on the proxy's blocklist.
Example scenario: an abusive IP was blocklisted during an incident and a legitimate user behind the same NAT is now rejected.
Fix: review the blocklist entry. If you rate limit per IP as well, prefer expiring blocks over permanent ones.
Too Many Requests (HTTP 429, Next.js proxy route)
Cause: the per-IP rate limit window was exceeded.
Example scenario: the widget polls authStatusHandler every couple of seconds and your limit is set lower than the polling budget for a slow wallet round trip.
Fix: size the limit to the polling cadence (the widget polls status repeatedly during verifying). If users legitimately hit 429 mid-flow, raise MAX_REQUESTS or lengthen the window.
Unauthorized (HTTP 401, Next.js session route)
Cause: SIWE message verification failed.
Example scenario: the signature was produced for a different message (stale nonce), or the wrong account signed.
Fix: regenerate the SIWE message immediately before signing and verify the connected account matches the message's address field.
Unauthorized: No token (HTTP 401, Next.js request gate: proxy.ts, middleware.ts pre-16)
Cause: no session cookie on a request to a protected route.
Example scenario: the user's 1-hour session JWT expired while the page sat open; the next SDK call through the proxy is rejected.
Fix: send the user back through the SIWE sign-in to mint a fresh session. Configure the SDK with includeCredentials: true so the cookie is attached at all.
Invalid session token (HTTP 401, Next.js request gate: proxy.ts, middleware.ts pre-16)
Cause: the JWT failed verification (bad signature, expired, malformed).
Example scenario: SESSION_SECRET differs between the instance that minted the token and the instance verifying it after a deploy.
Fix: ensure every instance shares the same SESSION_SECRET; on rotation, expect all sessions to invalidate and route users through sign-in again.
Hook errors: error from useHasChainPermission / useBusinessDetails
Cause: these hooks wrap wagmi's useReadContract, so any contract-read failure surfaces here: RPC unreachable, wrong network, malformed address.
Example scenario: the connected wallet is on chain 1 while the app targets Redbelly Testnet (153); the read reverts or times out and error.message reports the failure.
Fix: verify the wallet is connected to the right chain (Testnet 153, Mainnet 151), the RPC in your chain definition is https://governors.testnet.redbelly.network (or mainnet equivalent), and the address is a valid 0x address. Expose the refetch / refetchBusinessIdentifier functions in your UI for transient RPC failures.
Silent failure: widget never progresses, no error anywhere
Cause: the three backend routes are a hard contract (/auth-request, /callback, /status/:sessionId). A misnamed route does not throw; the flow just dead-ends.
Example scenario: your framework mounts the router under /api, so the widget posts to /auth-request while the handler lives at /api/auth-request. The QR renders (if queryHandler points somewhere valid) but status polling 404s quietly, or the wallet's callback never lands.
Fix: confirm all three routes respond at exactly the paths the SDK calls: watch the network tab for the widget's requests and your backend access log for the wallet's callback.
Verifier fails to initialise: missing keys/ folder
Cause: js-iden3-auth requires Iden3 trusted-setup public verification keys in a keys/ directory.
Example scenario: fresh clone, npm install, node server.js, and the verifier throws at startup before serving a single request.
Fix: the keys ship inside the @iden3/js-iden3-auth package; run cp -r node_modules/@iden3/js-iden3-auth/circuits keys at the backend root (or download them per the Iden3 docs), and confirm a subfolder exists matching the circuitId used in your scope.
Telemetry is not supported in non-browser environments (Next.js build log)
Cause: Reown AppKit's createAppKit runs at module scope in a file imported by a client component, and client components also execute on the server during next build prerendering. Discovered during a verification build for this guide; not documented by the SDK or AppKit docs.
Example scenario: you copy the plain-React AppKit setup (Section 4.1) into a Next.js lib/appkit.ts, run next build, and the static generation step prints this error once per prerendered page. The build still succeeds, which makes it easy to ship the noise to CI forever.
Fix: wrap only the createAppKit(...) call in if (typeof window !== "undefined") { ... }. Constructing the WagmiAdapter needs no guard.
Development warning: API key exposed client side
Cause: the SDK detects an apiKey reachable from the browser bundle and warns in development mode.
Example scenario: you copied the plain-React demo config (which passes apiKey to the provider) into a production Next.js app.
Fix: move the key behind the proxy pattern (Section 5.5): the provider gets proxyUrl, the proxy injects x-api-key server side from an environment variable.
9. Production Checklist
Run through this before mainnet.
Identity and issuers
- [ ]
allowedIssuerspins the exact mainnet DID (did:receptor:redbelly:mainnet:31AAH8sSaGd6fpnG1TcB6yQ4UZnNeyHzTk5aM2P7rjv); no wildcards anywhere - [ ] DID method registration includes the mainnet entry (chain ID 151, methodByte
0b01010111) - [ ] All contract addresses re-confirmed against the live Backend Setup page on the day you deploy; do not trust any guide (including this one) as the source of truth for hex addresses
Scope and verification
- [ ] Scope defined server side only; no code path lets the client influence it
- [ ]
skipClaimRevocationCheckabsent from all production config - [ ] Scope kept to 1 or 2 queries (QR size)
- [ ]
keys/folder deployed with the verifier and matching your circuit IDs
Secrets and transport
- [ ]
REDBELLY_API_KEY(and the separate Averer key if using BusinessOnboarding) live only in server environment variables - [ ] Client bundle audited for leaked keys (search the built output)
- [ ] Proxy allowlist contains exactly your verifier hostnames, HTTPS enforced
- [ ]
SESSION_SECRETidentical across instances, rotated with a plan for invalidating sessions
State and operations
- [ ] Session maps moved from in-memory to a shared store (Redis or similar); a restart must not orphan in-flight verifications
- [ ] Rate limits sized to the widget's status-polling cadence
- [ ] Callback route reachable from the public internet and monitored (the wallet is your client too)
- [ ] Backend logs distinguish
Invalid session ID(user retry) fromfullVerifyfailures (potential issuer/config problems)
Frontend
- [ ] Widget subtree loaded with
ssr: false; server rendering of the widget is not supported - [ ] Loading, failed and success states all rendered;
failedshows the backend'serrorstring with a retry path - [ ] On-chain hooks target the right chain (Mainnet 151) and expose refetch on transient RPC errors
Top comments (0)