Use this file to discover all available pages before exploring further.
Example Repository
A complete Next.js implementation showing this integration approach.
This guide covers integrating Sonar with a backend that stores OAuth tokens server-side.
All Sonar API calls are made via server actions, so tokens never reach the browser.
For SPAs without a backend, see the simpler Frontend-only approach.
You’ll need a way to persist OAuth tokens on the server, keyed by user ID.In production, you should use a database. For demonstration purposes, the example below uses in-memory storage.
// lib/token-store.tsexport interface SonarTokens { accessToken: string; refreshToken: string; expiresAt: number; // Unix timestamp in seconds}/** * In-memory token store implementation. * Tokens are stored in a Map and will be lost on server restart. * This can be easily swapped for a database-backed implementation. */class InMemoryTokenStore { private tokens: Map<string, SonarTokens> = new Map(); setTokens(userId: string, tokens: SonarTokens): void { this.tokens.set(userId, tokens); } getTokens(userId: string): SonarTokens | null { return this.tokens.get(userId) || null; } clearTokens(userId: string): void { this.tokens.delete(userId); }}// Singleton instancelet tokenStoreInstance: InMemoryTokenStore | null = null;export function getTokenStore(): InMemoryTokenStore { if (!tokenStoreInstance) { tokenStoreInstance = new InMemoryTokenStore(); } return tokenStoreInstance;}
See the example app token-store.ts for the full implementation.
During the OAuth flow, you need to store the PKCE code verifier between the initial redirect and the callback.
This must be stored server-side and associated with the OAuth state parameter, along with the user ID to verify session consistency.
// lib/pkce-store.tsinterface PKCEEntry { userId: string; codeVerifier: string;}// WARNING: In-memory storage is for demonstration only.// In production, use a database or session storage.const pkceStore = new Map<string, PKCEEntry>();// Store the verifier and user ID when starting the OAuth flowexport function setPKCEVerifier(state: string, userId: string, codeVerifier: string): void { pkceStore.set(state, { userId, codeVerifier });}// Retrieve the verifier when completing the OAuth flowexport function getPKCEVerifier(state: string): PKCEEntry | null { return pkceStore.get(state) || null;}// Clear the verifier after successful token exchangeexport function clearPKCEVerifier(state: string): void { pkceStore.delete(state);}
See the example app pkce-store.ts for the full implementation with expiry handling.
After the user authenticates with Echo, they’re redirected to a callback page. This page extracts the code and state parameters and calls a server action to complete the flow:
// app/actions/auth.ts (continued)import { createSonarClient } from "@/lib/sonar";import { getPKCEVerifier, clearPKCEVerifier } from "@/lib/pkce-store";import { getTokenStore, SonarTokens } from "@/lib/token-store";/** * Handle OAuth callback - exchange authorization code for tokens */export async function handleSonarCallback(code: string, state: string): Promise<void> { const session = await getSession(); if (!session) { throw new Error("Unauthorized: No active session"); } // Retrieve the PKCE verifier using the state token const stateData = getPKCEVerifier(state); if (!stateData) { throw new Error("Invalid state: OAuth state token not found or expired"); } // Verify the state belongs to the current session if (stateData.userId !== session.userId) { throw new Error("Invalid session: State token does not match current session"); } // Exchange the authorization code for tokens const client = createSonarClient(session.userId); const tokenData = await client.exchangeAuthorizationCode({ code, codeVerifier: stateData.codeVerifier, redirectURI: "YOUR_REDIRECT_URI", }); // Store the tokens const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in; const sonarTokens: SonarTokens = { accessToken: tokenData.access_token, refreshToken: tokenData.refresh_token, expiresAt, }; getTokenStore().setTokens(session.userId, sonarTokens); // Clean up the PKCE verifier clearPKCEVerifier(state);}/** * Disconnect Sonar account (remove stored tokens) */export async function disconnectSonar(): Promise<void> { const session = await getSession(); if (!session) { throw new Error("Unauthorized"); } getTokenStore().clearTokens(session.userId);}
The callback page itself is a client component that calls the server action:
// app/oauth/callback/page.tsx"use client";import { Suspense, useEffect, useState } from "react";import { useSearchParams } from "next/navigation";import { handleSonarCallback } from "@/app/actions/auth";function OAuthCallbackContent() { const searchParams = useSearchParams(); const [error, setError] = useState<string | null>(null); useEffect(() => { const processCallback = async () => { const code = searchParams.get("code"); const state = searchParams.get("state"); const oauthError = searchParams.get("error"); if (oauthError) { setError(`OAuth error: ${oauthError}`); return; } if (!code || !state) { setError("Missing authorization code or state"); return; } try { await handleSonarCallback(code, state); // Success - redirect to home window.location.href = "/"; } catch (err) { setError(err instanceof Error ? err.message : "Failed to process OAuth callback"); } }; processCallback(); }, [searchParams]); if (error) { return <p className="text-red-600">{error}</p>; } return <p>Connecting to Sonar...</p>;}export default function OAuthCallback() { return ( <Suspense fallback={<p>Loading...</p>}> <OAuthCallbackContent /> </Suspense> );}
See the example app auth.ts for the full implementation.
For the other Sonar API calls, we recommend wrapping these in React hooks.
Then these hooks can be called in a very similar way to if you were calling the Sonar API directly via the @echoxyz/sonar-react library.
See the frontend-only guide for an example.
// hooks/use-sonar-entities.ts"use client";import { ListAvailableEntitiesResponse } from "@echoxyz/sonar-core";import { saleUUID } from "@/lib/config";import { useSonarQuery } from "./use-sonar-query";import { getEntities } from "@/app/actions/sonar";export function useSonarEntities() { const { loading, data, error } = useSonarQuery<{ saleUUID: string }, ListAvailableEntitiesResponse>(getEntities, { saleUUID, }); return { loading, entities: data?.Entities, error, };}
Once you have a purchase permit, submit it to your sale contract.
This is the same as the frontend-only approach since the contract interaction happens directly from the browser—see the frontend-only guide for the full implementation.