Skip to main content

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.

How it works

Installation

npm install @echoxyz/sonar-core
Note: Unlike the frontend-only approach, you don’t need @echoxyz/sonar-react since you’ll be making API calls through your backend.

Example storage interfaces

Token storage

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.ts

export 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 instance
let 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.

PKCE verifier storage

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.ts

interface 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 flow
export function setPKCEVerifier(state: string, userId: string, codeVerifier: string): void {
  pkceStore.set(state, { userId, codeVerifier });
}

// Retrieve the verifier when completing the OAuth flow
export function getPKCEVerifier(state: string): PKCEEntry | null {
  return pkceStore.get(state) || null;
}

// Clear the verifier after successful token exchange
export function clearPKCEVerifier(state: string): void {
  pkceStore.delete(state);
}
See the example app pkce-store.ts for the full implementation with expiry handling.

Implementing the OAuth flow

The OAuth flow uses two server actions: one to generate the authorization URL, and one to handle the callback.
The createSonarClient helper used below is defined in the Server actions section.

Starting the OAuth flow

When the user clicks “Connect with Sonar”, a server action generates the authorization URL with PKCE parameters:
// app/actions/auth.ts
"use server";

import { generatePKCEParams, buildAuthorizationUrl } from "@echoxyz/sonar-core";
import { getSession } from "@/lib/session";
import { setPKCEVerifier } from "@/lib/pkce-store";

/**
 * Generate Sonar OAuth authorization URL with PKCE
 */
export async function getSonarAuthorizationUrl(): Promise<string> {
  const session = await getSession();
  if (!session) {
    throw new Error("Unauthorized");
  }

  // Generate PKCE parameters (code verifier, challenge, and state)
  const { codeVerifier, codeChallenge, state } = await generatePKCEParams();

  // Store code verifier linked to state token (will be retrieved in callback)
  setPKCEVerifier(state, session.userId, codeVerifier);

  // Build the authorization URL
  const authorizationUrl = buildAuthorizationUrl({
    clientUUID: "YOUR_OAUTH_CLIENT_UUID",
    redirectURI: "YOUR_REDIRECT_URI",
    state,
    codeChallenge,
  });

  return authorizationUrl.toString();
}

Handling the OAuth callback

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.

Server actions for Sonar API requests

All Sonar API requests go through server actions so that access tokens are never exposed to the browser.

Sonar client factory

Create helper functions to instantiate and refresh the Sonar client:
// lib/sonar.ts
import { getTokenStore, SonarTokens } from "@/lib/token-store";
import { SonarClient } from "@echoxyz/sonar-core";

/**
 * Create a SonarClient instance for a specific user.
 * Sets the access token from our server-side token store.
 */
export function createSonarClient(userId: string): SonarClient {
  const client = new SonarClient({
    apiURL: "YOUR_SONAR_API_URL",
    opts: {
      onUnauthorized: () => {
        getTokenStore().clearTokens(userId);
      },
    },
  });

  const tokens = getTokenStore().getTokens(userId);
  if (tokens?.accessToken) {
    client.setToken(tokens.accessToken);
  }

  return client;
}

Defining server actions

Create a createSonarServerAction wrapper that handles session authentication and automatic token refresh:
// lib/sonar.ts (continued)
import { getSession } from "@/lib/session";

type ServerActionHandler<I, O> = (client: SonarClient, input: I) => Promise<O>;

/**
 * Creates a Sonar server action with authentication and automatic token refresh.
 */
export function createSonarServerAction<I, O>(
  handler: ServerActionHandler<I, O>
): (input: I) => Promise<O> {
  return async (input: I) => {
    // Check session authentication
    const session = await getSession();
    if (!session) {
      throw new Error("Unauthorized");
    }

    // Get tokens from store
    let tokens = getTokenStore().getTokens(session.userId);
    if (!tokens) {
      throw new Error("Sonar account not connected");
    }

    // Check if token needs refresh (within 5 minutes of expiry)
    const now = Math.floor(Date.now() / 1000);
    if (tokens.expiresAt - now < 300) {
      tokens = await refreshSonarToken(session.userId, tokens.refreshToken);
      getTokenStore().setTokens(session.userId, tokens);
    }

    const client = createSonarClient(session.userId);
    return handler(client, input);
  };
}

/**
 * Refresh Sonar access token using refresh token.
 * NOTE: Consider how to prevent multiple concurrent refresh attempts for the
 * same user. The example app uses promise coalescing as one solution.
 */
export async function refreshSonarToken(userId: string, refreshToken: string): Promise<SonarTokens> {
  const client = new SonarClient({ apiURL: "YOUR_SONAR_API_URL" });
  const tokenData = await client.refreshToken({ refreshToken });
  const expiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in;

  return {
    accessToken: tokenData.access_token,
    refreshToken: tokenData.refresh_token || refreshToken,
    expiresAt,
  };
}
With the wrapper in place, each server action is simple to define:
// app/actions/sonar.ts
"use server";

import { createSonarServerAction } from "@/lib/sonar";
import {
  ReadEntityResponse,
  PrePurchaseCheckResponse,
  GeneratePurchasePermitResponse,
  APIError,
} from "@echoxyz/sonar-core";

type GetEntityInput = { saleUUID: string; walletAddress: string };

/**
 * Fetch a specific entity by wallet address
 */
export const getEntity = createSonarServerAction<GetEntityInput, ReadEntityResponse>(
  async (client, { saleUUID, walletAddress }) => {
    if (!saleUUID || !walletAddress) {
      throw new Error("Missing saleUUID or walletAddress");
    }

    try {
      return await client.readEntity({ saleUUID, walletAddress });
    } catch (error) {
      // Treat 404 as "no entity" rather than an error
      if (error instanceof APIError && error.status === 404) {
        return { Entity: null } as unknown as ReadEntityResponse;
      }
      throw error;
    }
  }
);

type PrePurchaseCheckInput = { saleUUID: string; entityID: string; walletAddress: string };

/**
 * Perform pre-purchase check for an entity
 */
export const prePurchaseCheck = createSonarServerAction<
  PrePurchaseCheckInput,
  PrePurchaseCheckResponse
>(async (client, { saleUUID, entityID, walletAddress }) => {
  if (!saleUUID || !entityID || !walletAddress) {
    throw new Error("Missing required parameters");
  }
  return client.prePurchaseCheck({ saleUUID, entityID, walletAddress });
});

type GeneratePurchasePermitInput = { saleUUID: string; entityID: string; walletAddress: string };

/**
 * Generate a purchase permit for an entity
 */
export const generatePurchasePermit = createSonarServerAction<
  GeneratePurchasePermitInput,
  GeneratePurchasePermitResponse
>(async (client, { saleUUID, entityID, walletAddress }) => {
  if (!saleUUID || !entityID || !walletAddress) {
    throw new Error("Missing required parameters");
  }
  return client.generatePurchasePermit({ saleUUID, entityID, walletAddress });
});
See the example app sonar.ts for the full implementation.

Frontend implementation

With the server actions in place, the client-side components can be implemented as follows:

Authentication button

The auth button calls the getSonarAuthorizationUrl server action and redirects to the returned URL:
// components/SonarAuthButton.tsx
"use client";

import { getSonarAuthorizationUrl, disconnectSonar } from "@/app/actions/auth";

export function SonarAuthButton({ sonarConnected }: { sonarConnected: boolean }) {
  const handleConnect = async () => {
    try {
      const url = await getSonarAuthorizationUrl();
      window.location.href = url;
    } catch (error) {
      console.error("Failed to get Sonar authorization URL:", error);
    }
  };

  const handleDisconnect = async () => {
    try {
      await disconnectSonar();
      window.location.reload();
    } catch (error) {
      console.error("Failed to disconnect Sonar:", error);
    }
  };

  return (
    <button onClick={sonarConnected ? handleDisconnect : handleConnect}>
      {sonarConnected ? "Disconnect from Sonar" : "Connect with Sonar"}
    </button>
  );
}

Fetching entity state

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-entity.ts
"use client";

import { useState, useEffect } from "react";
import { getEntity } from "@/app/actions/sonar";
import { Entity } from "@echoxyz/sonar-core";

export function useSonarEntity(saleUUID: string, walletAddress?: string) {
  const [loading, setLoading] = useState(true);
  const [entity, setEntity] = useState<Entity | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (!walletAddress) {
      setLoading(false);
      return;
    }

    const fetchEntity = async () => {
      setLoading(true);
      try {
        const response = await getEntity({ saleUUID, walletAddress });
        setEntity(response.Entity);
      } catch (err) {
        setError(err instanceof Error ? err : new Error(String(err)));
      } finally {
        setLoading(false);
      }
    };

    fetchEntity();
  }, [saleUUID, walletAddress]);

  return { loading, entity, error };
}

Running pre-purchase checks and generating purchase permits

Also create a hook for the purchase flow:
// hooks/use-sonar-purchase.ts
"use client";

import { useCallback, useState, useEffect } from "react";
import { prePurchaseCheck, generatePurchasePermit } from "@/app/actions/sonar";
import { GeneratePurchasePermitResponse, PrePurchaseFailureReason } from "@echoxyz/sonar-core";

export function useSonarPurchase(args: {
  saleUUID: string;
  entityID: string;
  walletAddress: string;
}) {
  const [loading, setLoading] = useState(true);
  const [readyToPurchase, setReadyToPurchase] = useState(false);
  const [failureReason, setFailureReason] = useState<PrePurchaseFailureReason>();
  const [error, setError] = useState<Error>();

  useEffect(() => {
    const checkPurchase = async () => {
      setLoading(true);
      try {
        const result = await prePurchaseCheck(args);
        setReadyToPurchase(result.ReadyToPurchase);
        if (!result.ReadyToPurchase) {
          setFailureReason(result.FailureReason as PrePurchaseFailureReason);
        }
      } catch (err) {
        setError(err instanceof Error ? err : new Error(String(err)));
      } finally {
        setLoading(false);
      }
    };

    checkPurchase();
  }, [args.saleUUID, args.entityID, args.walletAddress]);

  const generatePermit = useCallback(async (): Promise<GeneratePurchasePermitResponse> => {
    return generatePurchasePermit(args);
  }, [args.saleUUID, args.entityID, args.walletAddress]);

  return { loading, readyToPurchase, failureReason, error, generatePurchasePermit: generatePermit };
}

Submitting the purchase transaction

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.