Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.coinlist.co/llms.txt

Use this file to discover all available pages before exploring further.

Users authenticate with CoinList and authorize your application — no passwords are shared with you. CoinList uses OAuth 2.0 with PKCE (Proof Key for Code Exchange) to secure the redirect flow. This recipe assumes you run a backend you control: the client secret, refresh token, and token exchange stay on the server. The React SDK handles PKCE in the browser and session hydration through getAccessToken.

Prerequisites

  • A registered client_id and redirect_uri from CoinList — contact us if you need access
  • redirect_uri must match your OAuth callback page (for example https://your-domain.com/oauth/coinlist/callback)
  • client_secret available only on the server
  • @coinlist-co/react installed (SDK quickstart)
Working examples line up with the partner-demo app.

Wrap your app with CoinListProvider

Configure clientId, redirectUri, and getAccessToken so the SDK can run OAuth in the browser and read short-lived access tokens from your session API.
// app/providers.tsx
"use client";

import {
  ClientId,
  CoinListProvider,
  RedirectUri,
} from "@coinlist-co/react";
import type { ClientConfig } from "@coinlist-co/react";

export function Providers({ children }: { children: React.ReactNode }) {
  const config: ClientConfig = {
    clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!),
    redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!),
    getAccessToken: async () => {
      const res = await fetch("/api/coinlist/oauth/access-token", {
        credentials: "include",
      });
      if (res.status === 204) return null;
      if (!res.ok) {
        throw new Error("GET /api/coinlist/oauth/access-token failed");
      }
      const data = (await res.json()) as { value: string; expiresAt: string };
      return {
        value: data.value,
        expiresAt: new Date(data.expiresAt),
      };
    },
  };

  return <CoinListProvider config={config}>{children}</CoinListProvider>;
}
If you omit baseUrl in ClientConfig, the SDK uses its default API base URL. Set baseUrl when you need the SDK to call a different host (for example your own backend proxy).
Use the packaged sign-in card inside any subtree that already has CoinListProvider. It calls coinlist.startOAuth() when the user clicks Sign in with CoinList.
"use client";

import { CoinListSignInCard } from "@coinlist-co/react/client/components";

export function SignInPanel() {
  return <CoinListSignInCard />;
}
Importing from @coinlist-co/react/client/components pulls in the SDK styles needed for this UI. For auth gating, loading states, and OffersGrid after sign-in, see partner-demo app/page.tsx.

Server: CoinListServer and session storage

Create a small factory that binds createCoinListServer to your outgoing response and session store. The example below uses HTTP-only cookies; adjust to your security model.
// lib/coinlist-server.ts
import type { NextResponse } from "next/server";
import type { CoinListServer } from "@coinlist-co/react/server";
import {
  ClientId,
  ClientSecret,
  createCoinListServer,
  RedirectUri,
} from "@coinlist-co/react/server";
import { createSessionCookiesStore } from "./session-store";

export function coinListServer(outgoingResponse: NextResponse): CoinListServer {
  return createCoinListServer({
    clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!),
    clientSecret: ClientSecret(process.env.COINLIST_CLIENT_SECRET!),
    redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!),
    sessionStore: createSessionCookiesStore(outgoingResponse),
  });
}
// lib/session-store.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import type { OAuthSession, SessionStore } from "@coinlist-co/react/server";
import { OAuthRefreshToken } from "@coinlist-co/react/server";

const COINLIST_SESSION_COOKIE = "coinlist_session";

/** Copy Set-Cookie headers from the SDK scratch response onto the response you return (e.g. after refresh in `accessToken()`). */
export function copyCookiesFromTo(source: NextResponse, target: NextResponse) {
  for (const cookie of source.cookies.getAll()) {
    const { name, value, ...options } = cookie;
    target.cookies.set(name, value, options);
  }
}

export function createSessionCookiesStore(
  outgoingResponse: NextResponse,
): SessionStore {
  return {
    async getSession(): Promise<OAuthSession | null> {
      const raw = (await cookies()).get(COINLIST_SESSION_COOKIE)?.value;
      if (!raw) return null;

      let parsed: {
        accessToken?: { value?: string; expiresAt?: string };
        refreshToken?: string;
      };
      try {
        parsed = JSON.parse(raw);
      } catch {
        return null;
      }

      if (!parsed.accessToken?.value || !parsed.accessToken?.expiresAt)
        return null;
      const expiresAt = new Date(parsed.accessToken.expiresAt);
      if (Number.isNaN(expiresAt.getTime())) return null;

      return {
        accessToken: { value: parsed.accessToken.value, expiresAt },
        ...(parsed.refreshToken
          ? { refreshToken: OAuthRefreshToken(parsed.refreshToken) }
          : {}),
      };
    },

    async setSession(session: OAuthSession | null): Promise<void> {
      if (session == null) {
        outgoingResponse.cookies.delete(COINLIST_SESSION_COOKIE);
        return;
      }

      outgoingResponse.cookies.set(
        COINLIST_SESSION_COOKIE,
        JSON.stringify({
          accessToken: {
            value: session.accessToken.value,
            expiresAt: session.accessToken.expiresAt.toISOString(),
          },
          ...(session.refreshToken
            ? { refreshToken: String(session.refreshToken) }
            : {}),
        }),
        {
          httpOnly: true,
          secure: process.env.NODE_ENV === "production",
          sameSite: "lax",
          path: "/",
        },
      );
    },
  };
}

Callback route

On the URL you registered as redirect_uri, use useCompleteCoinListOAuth: it validates the redirect (state / PKCE via coinlist.completeOAuth), POSTs to your complete endpoint, then runs coinlist.init() so getAccessToken sees the new session. This matches partner-demo app/oauth/coinlist/callback/page.tsx:
// app/oauth/coinlist/callback/page.tsx
"use client";

import {
  CompleteCoinListOAuthFailureReason,
  useCompleteCoinListOAuth,
} from "@coinlist-co/react";
import { useRouter } from "next/navigation";

export default function CoinListCallbackPage() {
  const router = useRouter();

  useCompleteCoinListOAuth({
    async postOAuthComplete({ code, codeVerifier }) {
      const res = await fetch("/api/coinlist/oauth/complete", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ code, codeVerifier }),
      });
      return res.ok;
    },
    onFailure(reason: CompleteCoinListOAuthFailureReason) {
      router.replace(`/?error=${encodeURIComponent(reason)}`);
    },
    onSuccess() {
      router.replace("/");
    },
  });

  return (
    <div className="flex min-h-screen items-center justify-center">
      <p>Completing sign-in…</p>
    </div>
  );
}
Put this file at a path that matches NEXT_PUBLIC_COINLIST_REDIRECT_URI. Read reason on your home page (for example via useSearchParams) if you want to show OAuth errors — see partner-demo app/page.tsx.

Exchange the code on your backend

The server SDK performs the token exchange — you do not call the token URL with raw fetch in application code.
// app/api/coinlist/oauth/complete/route.ts
import { NextResponse } from "next/server";
import { AuthorizationCode, CodeVerifier } from "@coinlist-co/react/server";

import { coinListServer } from "@/lib/coinlist-server";

interface CompleteOAuthRequest {
  code: string;
  codeVerifier: string;
}

export async function POST(req: Request) {
  const body: CompleteOAuthRequest = await req.json();
  const { code, codeVerifier } = body;

  if (typeof code !== "string" || typeof codeVerifier !== "string") {
    return NextResponse.json({ error: "missing_fields" }, { status: 400 });
  }

  const response = NextResponse.json({ ok: true });

  await coinListServer(response).completeOAuth(
    AuthorizationCode(code),
    CodeVerifier(codeVerifier),
  );

  return response;
}

Serve access tokens to the client

The browser SDK calls getAccessToken whenever it needs a Bearer token. Implement this route with coinlistServer(response).accessToken() so refresh happens server-side.
// app/api/coinlist/oauth/access-token/route.ts
import { coinListServer } from "@/lib/coinlist-server";
import { copyCookiesFromTo } from "@/lib/session-store";
import { NextResponse } from "next/server";

export async function GET() {
  const cookieSink = new NextResponse(null, { status: 204 });
  const token = await coinListServer(cookieSink).accessToken();
  if (token == null) {
    return cookieSink;
  }

  const response = NextResponse.json({
    value: token.value,
    expiresAt: token.expiresAt.toISOString(),
  });
  copyCookiesFromTo(cookieSink, response);
  return response;
}
When accessToken() refreshes the session, the SDK may set cookies on cookieSink. copyCookiesFromTo applies those to the JSON response so the browser gets the updated session cookie.

Log out

Clear the session with the server SDK from a route handler:
// app/api/coinlist/oauth/logout/route.ts
import { coinListServer } from "@/lib/coinlist-server";
import { NextResponse } from "next/server";

export async function POST() {
  const response = NextResponse.json({ ok: true });
  await coinListServer(response).logout();
  return response;
}
Call coinlist.logout() on the client when you need to drop the in-memory token after the server clears the session.

Token management

  • Access tokens are short-lived. The SDK attaches them as Authorization: Bearer on API requests.
  • Refresh tokens are long-lived. Keep them on the server. With the server SDK, accessToken() refreshes when possible.
  • Never expose client_secret or long-lived refresh tokens to the browser bundle.

Lower-level and custom integrations

Use the sections below only if you are not using the defaults above.
If you build your own button or entry point, call startOAuth() from useCoinList():
"use client";

import { useCoinList } from "@coinlist-co/react";

export function LoginButton() {
  const { coinlist } = useCoinList();

  return (
    <button type="button" onClick={() => void coinlist.startOAuth()}>
      Sign in with CoinList
    </button>
  );
}
You can call coinlist.completeOAuth() yourself, then POST code and codeVerifier to /api/coinlist/oauth/complete, await coinlist.init(), and redirect. Prefer useCompleteCoinListOAuth when possible — it centralizes failure reasons and avoids duplicate work in React Strict Mode.
"use client";

import { useCoinList } from "@coinlist-co/react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef } from "react";

function LegacyCallbackBody() {
  const { coinlist } = useCoinList();
  const searchParams = useSearchParams();
  const router = useRouter();
  const hasStarted = useRef(false);

  useEffect(() => {
    if (hasStarted.current) return;

    if (searchParams.get("error")) {
      router.replace("/?error=coinlist_denied");
      return;
    }

    if (!searchParams.get("code")) return;

    const oauthRes = coinlist.completeOAuth();
    if (oauthRes.type === "error") {
      router.replace("/?error=coinlist_oauth");
      return;
    }

    hasStarted.current = true;

    void (async () => {
      const res = await fetch("/api/coinlist/oauth/complete", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({
          code: oauthRes.code,
          codeVerifier: oauthRes.codeVerifier,
        }),
      });

      if (!res.ok) {
        hasStarted.current = false;
        router.replace("/?error=coinlist_complete_failed");
        return;
      }

      await coinlist.init();
      router.replace("/");
    })();
  }, [coinlist, searchParams, router]);

  return <p>Completing sign-in…</p>;
}

export default function LegacyCallbackPage() {
  return (
    <Suspense fallback={<p>Completing sign-in…</p>}>
      <LegacyCallbackBody />
    </Suspense>
  );
}
For non-React browser code, import createCoinListClient and ClientConfig from @coinlist-co/react or @coinlist-co/react/client/core and call the same methods (startOAuth, completeOAuth, fetchAllOffers, …). You still need a backend for token exchange in production.

Next step

Fetch offers

List offers for the signed-in user with OffersGrid or hooks, then open Display offer details for one offer.

Resources