Docs/Guides/Building a custom dealer portal
◆ Guide

Building a custom dealer portal.

OAuth 2.0, scoped tokens, eight endpoints, and a short list of opinions on how to make it not feel like a portal.

  • Customer-authorized OAuth 2.0 (Auth Code + PKCE)
  • Eight portal endpoints covering the whole lifecycle
  • React + fetch sample, no SDK required
  • Session strategy and refresh handling
In this guide
  1. Use cases
  2. OAuth 2.0 flow
  3. Available portal endpoints
  4. React fetch example
  5. Session management
  6. Design tokens

1. Use cases

A portal is the single best investment in customer retention we've measured. Customers who authenticate at least twice post-delivery have a 2.1× higher rate of returning for service and a 1.4× higher rate of trading up within five years. Common surfaces:

2. OAuth 2.0 flow

Use Authorization Code with PKCE. Never embed a static API key in a customer-facing app — those keys are dealership-scoped and can read everything. Portal tokens are scoped to a single contact_id.

oauth.md
# 1. Redirect the user to the authorize endpoint
https://auth.boater.os/oauth/authorize
  ?response_type=code
  &client_id=cli_3F2K...
  &redirect_uri=https://yourdealer.com/portal/callback
  &scope=customer.read+customer.write+service.read+documents.read
  &code_challenge=$(pkce_challenge)
  &code_challenge_method=S256
  &state=$(random)

# 2. Exchange the returned `code` for tokens
curl https://auth.boater.os/oauth/token \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "redirect_uri=https://yourdealer.com/portal/callback" \
  -d "client_id=$CLIENT_ID" \
  -d "code_verifier=$VERIFIER"

# Response
{ "access_token": "eyJhbG...", "refresh_token": "rt_...",
  "expires_in": 3600, "token_type": "Bearer" }

Access tokens expire in one hour. Refresh tokens rotate on every use; if you use one twice, all tokens for that grant are revoked. Defense in depth.

3. Available endpoints

Method Path Scope Description
GET /v1/portal/me customer.read Current customer profile and preferences.
GET /v1/portal/saved-hulls customer.read Wishlist / saved inventory. Paginated.
POST /v1/portal/saved-hulls customer.write Save a hull. Body: { hull_id }.
GET /v1/portal/deals customer.read Open and historical deals for the customer.
GET /v1/portal/service-orders service.read Past and scheduled service visits.
POST /v1/portal/service-requests service.write Create a service request. Body: problem, preferred_date.
GET /v1/portal/warranty-claims warranty.read Warranty claim status with OEM reference numbers.
GET /v1/portal/documents documents.read Signed URLs for purchase docs, registrations, receipts.

4. React fetch example

No SDK — the portal API is small enough that fetch + a 30-line client is cleaner. This renders the saved-hulls list.

SavedHulls.tsx
import { useEffect, useState } from "react";
import { usePortalToken } from "./auth";

export function SavedHulls() {
  const token = usePortalToken();
  const [hulls, setHulls] = useState<Hull[]>([]);

  useEffect(() => {
    if (!token) return;
    fetch("https://api.boater.os/v1/portal/saved-hulls", {
      headers: { "Authorization": `Bearer ${token}` },
    })
      .then(r => r.json())
      .then(d => setHulls(d.data));
  }, [token]);

  return (
    <ul>{hulls.map(h => <HullCard key={h.id} hull={h} />)}</ul>
  );
}

5. Session management

Store tokens in an httpOnly, secure, SameSite=Lax cookie — not localStorage. Refresh on every navigation if the access token is <5 minutes from expiry; silent refresh in a background tab every 45 minutes otherwise. If a refresh returns invalid_grant, redirect to /oauth/authorize without error-toasting — sessions expire, it's fine.

6. Design tokens

Bring your own brand. If you don't have one, start with these — they match the BoaterOS core product and look right next to the AI Companion widget.

tokens.css
:root {
  --surface: #FAF6ED;
  --surface-2: #FFFFFF;
  --ink: #061826;
  --ink-2: #38546A;
  --accent: #A83A35;
  --line: #E6DECD;
  --radius: 10px;
  --serif: "Tiempos", Georgia, serif;
  --mono: "JetBrains Mono", ui-monospace, monospace;
}

The portal surface lives next to your existing website, not inside it. Subdomain (portal.yourdealer.com) is cleaner than a path — separate cookie scope, separate CDN cache, separate deploy cadence.

◆ Next step

Your brand, our plumbing.

Most dealers ship a v1 portal in two weeks. If you need a reference implementation to fork, just ask.

Book a demo Back to docs