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:
- My boats — owned + saved hulls, with status, location, and next-service countdown.
- Service — schedule a visit, track an open work order, see history.
- Warranty — file a claim, see status, upload photos.
- Documents — purchase agreement, registration, insurance binders, service invoices.
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.
# 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
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.
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.
: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.