DEV Community

Isaac Atunbi
Isaac Atunbi

Posted on

The Complete Redbelly EligibilitySDK Integration Guide: Widget to Backend to On-Chain

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:

  1. A mental model of the two verification mechanisms (and why conflating them costs you a day)
  2. A working backend verifier with the three routes the widget demands
  3. A plain React integration with full loading and error states
  4. A production-grade Next.js App Router setup: secure proxy, SIWE sessions, request gating, and both static and dynamic rendering approaches
  5. An end-to-end test run on Redbelly Testnet
  6. The decision logic for choosing between the three SDK flows, and the pattern for combining them
  7. 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:

  1. 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.
  2. The on-chain permission read (hooks). useHasChainPermission(address) and useBusinessDetails(address) are direct contract reads (wagmi useReadContract under 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)                             |
       |------------------------------------------------------------>|
       |<------------------------------------------------------------|
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Why the backend is yours

The SDK does not ship a hosted verifier. Your backend owns three things:

  1. 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.
  2. Session state: mapping sessionId to the pending request and eventually the verified proof.
  3. Cryptographic verification: js-iden3-auth's fullVerify(), 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:packages scope. 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
Enter fullscreen mode Exit fullscreen mode

Export your token (do not commit it):

export GITHUB_TOKEN=ghp_your_token_with_read_packages
Enter fullscreen mode Exit fullscreen mode

Install the SDK and peer dependencies

npm install @redbellynetwork/eligibility-sdk
npm install @reown/appkit @reown/appkit-adapter-wagmi wagmi viem @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

@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
Enter fullscreen mode Exit fullscreen mode

Two rules that prevent real incidents:

  1. 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.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    ...
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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/:sessionId produce 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 credentialSubject objects cause the QR encoder to fail with "Data too long" (see Section 8).
  • Revocation checks stay on in production. Only set skipClaimRevocationCheck: true in 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • network selects which Redbelly environment the SDK and hooks target.
  • The official example passes apiKey here for demo purposes. Anything in client bundle is public; for production use the proxyUrl pattern 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 by createAppKit; in strict TypeScript setups declare it in a .d.ts or 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)
  • [ ] onSuccess fires and the profile renders
  • [ ] Hooks show loading, error and success states correctly
  • [ ] No API key in the client bundle (grep your 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
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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 />;
}
Enter fullscreen mode Exit fullscreen mode

@/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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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*"],
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. User signs a SIWE message; /api/session sets the JWT cookie.
  2. The request gate (5.7) admits requests to /api/sdk/* and /dashboard/* only with a valid cookie.
  3. The widget (configured with proxyUrl: "/api/sdk", includeCredentials: true) sends all verifier traffic through the proxy.
  4. The proxy validates target domain, rate limits, and injects x-api-key server side.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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_KEY from 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
Enter fullscreen mode Exit fullscreen mode

6.3 Obtaining a test credential

To pass verification the wallet must hold a credential issued by the allowed testnet issuer.

  1. 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.
  2. Scan the faucet's QR code with your (empty) Privado wallet and accept the credential.

6.4 The end-to-end run

  1. Start the backend verifier (confirm it logs a clean start; a missing keys/ folder fails here, not later).
  2. Start the frontend (npm run dev).
  3. Start ngrok and confirm PUBLIC_URL matches the live tunnel URL (it changes on every ngrok restart unless you have a reserved domain).
  4. Open the app, connect a wallet, and trigger the eligibility check.
  5. Scan the widget's QR with the wallet that holds the test credential.
  6. Approve proof sharing in the wallet.
  7. Watch the widget move through verifying to success and confirm onSuccess fires with the expected DID and claims.
  8. Confirm the on-chain hooks: after onboarding, useHasChainPermission(address) should flip to true for 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), not BusinessOnboarding.
  • 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
Enter fullscreen mode Exit fullscreen mode

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 useHasChainPermission first; 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

  • [ ] allowedIssuers pins 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
  • [ ] skipClaimRevocationCheck absent 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_SECRET identical 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) from fullVerify failures (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; failed shows the backend's error string with a retry path
  • [ ] On-chain hooks target the right chain (Mainnet 151) and expose refetch on transient RPC errors

Top comments (0)