# DSS Public API — full documentation Concatenation of every published documentation page, in canonical reading order. Generated by `docs/site/scripts/build_llms_full.py`. Spec source of truth: https://api.digitalseaservice.com/openapi.json --- # DSS Public API Source: https://docs.digitalseaservice.com/introduction The DSS Public API lets approved partner platforms display a crew member's Digital Sea Service record alongside their own product, with the crew member's explicit consent. It's a thin, read-only REST API behind an OAuth 2.0 Authorization Code flow with PKCE. If you've integrated with Stripe, Plaid, or Google sign-in before, the shape will be familiar. Zero to a successful `/v1/me` call in 15 minutes. OAuth 2.0 + PKCE, end-to-end, with samples. Auto-generated from our live OpenAPI 3.1 spec. Signed, retried event delivery when records change. ## Base URLs | Environment | Base URL | |-------------|-------------------------------------------| | Production | `https://api.digitalseaservice.com` | | Sandbox | `https://api.dev.digitalseaservice.com` | The sandbox is a fully isolated environment with seeded test crew records. Sandbox credentials are issued immediately on partner onboarding; production credentials follow integration review. ## What's in V1 - **OAuth 2.0 Authorization Code + PKCE** — single canonical flow for every partner. No API keys, no client credentials. - **Opaque tokens** stored server-side. Revocation is instant. - **Three read scopes** — `profile:read`, `seatime:read`, `vessels:read`. - **Four read endpoints** — `/v1/me`, `/v1/me/sea-time`, `/v1/me/sea-time/recent`, `/v1/me/vessels`. - **Signed, retried webhooks** with a 5-minute replay window and a documented retry schedule. - **Conditional GETs** — `ETag` + `If-Modified-Since` on every resource for cheap polling. - **Cursor pagination** — stable under concurrent writes. - **RFC 7807 Problem Details** errors with a `type:` URL that resolves into these docs. ## What's not in V1 These are deliberately out of scope so we don't paint ourselves into a corner. If your integration needs any of them, [get in touch](mailto:admin@digitalseaservice.com). - Write endpoints (e.g. partner logging sea time on a user's behalf). - Multi-user / fleet endpoints. - Self-serve partner registration. - Certificates / reports. - SDKs — partners hit REST directly. ## OpenAPI The live spec is served from the API itself, prominently: - [`https://api.digitalseaservice.com/openapi.json`](https://api.digitalseaservice.com/openapi.json) - [`https://api.digitalseaservice.com/openapi.yaml`](https://api.digitalseaservice.com/openapi.yaml) A snapshot is also available at [`/api-reference/openapi.json`](/api-reference/openapi.json) on this docs site, which is what the [API reference](/api-reference) tab is built from. ## Built for AI agents too These docs are designed to be ingested by AI coding agents — Claude, Cursor, Copilot — as easily as by humans. Every page is available as raw markdown by appending `.md` to its URL (e.g. [`/quickstart.md`](https://docs.digitalseaservice.com/quickstart.md)), there's an [`llms.txt`](https://docs.digitalseaservice.com/llms.txt) manifest at the root, and a single [`llms-full.txt`](https://docs.digitalseaservice.com/llms-full.txt) bundle of every page concatenated, ready to drop into a model's context. ## Support - Email — [admin@digitalseaservice.com](mailto:admin@digitalseaservice.com). One address for everything: technical questions, partner onboarding, and sandbox/production credential requests. - Status — [status.digitalseaservice.com](https://status.digitalseaservice.com) - Every API response carries an `X-Request-Id`. Include it when you write in and we can pull the exact log entry. --- # Quickstart Source: https://docs.digitalseaservice.com/quickstart This walks you from "I have credentials" to "I can read a crew member's profile" in about 15 minutes. We use the sandbox throughout. The flow is identical in production — swap the base URL. During onboarding you'll have received: - `client_id` (e.g. `ywc_a1b2c3d4`) - `client_secret` - `webhook_secret` (only if you registered a webhook URL) - One or more registered `redirect_uri`s If you don't have these yet, [email us](mailto:admin@digitalseaservice.com) — sandbox is issued same-day. Every authorization request needs a fresh `code_verifier` / `code_challenge` pair. The verifier stays in your server; the challenge goes on the wire. See [Authentication](/authentication#pkce) for samples in other languages. ```bash cURL CODE_VERIFIER=$(python -c 'import secrets;print(secrets.token_urlsafe(64)[:64])') CODE_CHALLENGE=$(python -c " import base64, hashlib, sys v = sys.argv[1] print(base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode()) " "$CODE_VERIFIER") echo "verifier=$CODE_VERIFIER" echo "challenge=$CODE_CHALLENGE" ``` ```python Python import base64, hashlib, secrets code_verifier = secrets.token_urlsafe(64)[:64] code_challenge = base64.urlsafe_b64encode( hashlib.sha256(code_verifier.encode()).digest() ).rstrip(b"=").decode() ``` ```javascript Node import { createHash, randomBytes } from "node:crypto"; const codeVerifier = randomBytes(48).toString("base64url").slice(0, 64); const codeChallenge = createHash("sha256") .update(codeVerifier) .digest("base64url"); ``` Store the `code_verifier` against the user's session — you'll need it in step 4. Build this URL and redirect the user's browser to it. The DSS dashboard handles sign-in and shows them a consent screen listing the scopes you requested. On approval they're redirected back to your `redirect_uri` with a one-shot `code` and the `state` you passed. ``` https://api.dev.digitalseaservice.com/oauth/authorize?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https://yourapp.example.com/integrations/dss/callback &scope=profile:read+seatime:read+vessels:read &state=RANDOM_PER_REQUEST_CSRF_TOKEN &code_challenge=GENERATED_IN_STEP_2 &code_challenge_method=S256 ``` Validate `state` on the callback before doing anything else. If it doesn't match the value you sent, drop the request — it's the standard defence against CSRF on the OAuth dance. The user's browser hits your `redirect_uri` with `?code=...&state=...`. Your server exchanges the code for an access token + refresh token. ```bash cURL curl -sS -X POST https://api.dev.digitalseaservice.com/oauth/token \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri=https://yourapp.example.com/integrations/dss/callback \ -d client_id="$CLIENT_ID" \ -d client_secret="$CLIENT_SECRET" \ -d code_verifier="$CODE_VERIFIER" ``` ```python Python import httpx r = httpx.post( "https://api.dev.digitalseaservice.com/oauth/token", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": "https://yourapp.example.com/integrations/dss/callback", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "code_verifier": code_verifier, }, timeout=10, ) r.raise_for_status() tokens = r.json() ``` ```javascript Node const res = await fetch( "https://api.dev.digitalseaservice.com/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: "https://yourapp.example.com/integrations/dss/callback", client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: codeVerifier, }), } ); if (!res.ok) throw new Error(`token exchange failed: ${res.status}`); const tokens = await res.json(); ``` Response: ```json { "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "...", "scope": "profile:read seatime:read vessels:read" } ``` Store the tokens against the user record on your side. **The `refresh_token` rotates on every use** — when you refresh, swap the stored value for the new one immediately. See [Authentication / refreshing](/authentication#refreshing-tokens). You now have an access token. Try it against the simplest endpoint. ```bash cURL curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ https://api.dev.digitalseaservice.com/v1/me ``` ```python Python import httpx r = httpx.get( "https://api.dev.digitalseaservice.com/v1/me", headers={"Authorization": f"Bearer {access_token}"}, timeout=10, ) r.raise_for_status() me = r.json() ``` ```javascript Node const res = await fetch( "https://api.dev.digitalseaservice.com/v1/me", { headers: { Authorization: `Bearer ${accessToken}` } } ); if (!res.ok) throw new Error(`me failed: ${res.status}`); const me = await res.json(); ``` Successful response: ```json { "user_id": "65a1f0e2c3b4d5e6f7a8b9c0", "name": "Alex Crew", "role": "Mate", "country": "GB", "photo_url": "https://cdn.digitalseaservice.com/u/65a1f0e2.jpg", "record_updated_at": "2026-05-22T14:21:00Z" } ``` If you see this — you're integrated. ## What to build next In rough order of value: 1. **Render the data in your UI.** Pull `/v1/me/sea-time` for the dashboard totals and `/v1/me/vessels` for the history list. Each returns `record_updated_at` and supports conditional GETs — use them. 2. **Wire conditional GETs.** Cache the `ETag` and replay it on the next request as `If-None-Match`. A `304` costs you nothing against your rate limit budget beyond a single round trip. 3. **Register a webhook URL** so you don't have to poll. See [Webhooks](/webhooks/overview). 4. **Add the disconnect path.** Call `POST /oauth/revoke` with the user's token when they want to disconnect from your side (revoking one token revokes the whole pair), and handle `user.consent.revoked` webhooks for when they disconnect from ours. ## Common gotchas Three causes account for ~all of these. The authorization code expired (60-second TTL — exchange immediately), the code was already used (codes are single-use), or the `redirect_uri` you passed at token exchange doesn't exactly match the one you passed at authorize. Trailing slashes count. Access tokens are 1-hour TTL. Refresh them with `grant_type=refresh_token`. The previous access + refresh pair is revoked atomically when you refresh — if you keep using the old refresh token, you'll lose the connection and the user will have to reconsent. See [Authentication / refreshing](/authentication#refreshing-tokens). Your access token was issued without the scope this endpoint needs. Send the user back through the authorize flow with the missing scope requested. Refresh cannot widen scope by design. ## Going to production Once your sandbox integration is working end-to-end (auth, all four read endpoints, at least one webhook handled), email [admin@digitalseaservice.com](mailto:admin@digitalseaservice.com) to schedule the integration review. We check your redirect URI is HTTPS, your webhook handler verifies signatures correctly, and that you've covered the disconnect path. Production credentials are issued same-day on approval. --- # Authentication Source: https://docs.digitalseaservice.com/authentication The DSS Public API uses [OAuth 2.0 Authorization Code with PKCE](https://datatracker.ietf.org/doc/html/rfc7636). Every partner uses the same flow. We do not issue API keys and we do not offer client credentials. ## Why this flow OAuth Authorization Code + PKCE is the right shape for "user X grants third-party app B access to their data on service A." It's what Google, Stripe, Plaid, Slack, and every dashboard-style consumer API uses. PKCE protects the authorization code if it's ever intercepted in transit. We use **opaque tokens** stored server-side, not JWTs. Revocation is instant — when a user disconnects, every token we've issued to your client for that user stops working on the next request. There's no token expiry window where revoked-but-not-expired tokens still work. ## Endpoints | Method | Path | Auth | Purpose | |--------|----------------------|----------------------|----------------------------------------| | GET | `/oauth/authorize` | DSS user session | Render the consent screen | | POST | `/oauth/authorize` | DSS user session | Record consent, issue authorization code | | POST | `/oauth/token` | `client_secret` | Exchange code or refresh for tokens | | POST | `/oauth/revoke` | `client_secret` | RFC 7009 token revocation | | GET | `/oauth/userinfo` | bearer | OIDC-style basic profile | ## The flow at a glance ```mermaid sequenceDiagram autonumber participant U as Crew member participant P as Your app participant DSS as DSS auth server participant API as DSS API U->>P: Clicks "Connect Digital Sea Service" P->>P: Generate state + PKCE pair, store verifier P->>U: Redirect to /oauth/authorize (challenge, state) U->>DSS: Browser hits /oauth/authorize DSS->>U: Show consent screen U->>DSS: Approve DSS->>P: Redirect to redirect_uri with code + state P->>P: Validate state matches P->>DSS: POST /oauth/token (code, client_secret, verifier) DSS-->>P: { access_token, refresh_token, expires_in: 3600, scope } P->>API: GET /v1/me with Bearer access_token API-->>P: 200 { user_id, name, role, ... } ``` ## Scopes | Scope | Grants | |----------------|-------------------------------------------------------| | `profile:read` | Name, role, country, photo URL | | `seatime:read` | Aggregate sea-time totals and 12-month trend | | `vessels:read` | Vessel history | Scopes are additive — request only what your UI actually renders. Granular scope requests get higher consent rates and a tighter blast radius if anything ever leaks. The set of scopes you may request is configured per partner at onboarding. Asking for a scope you weren't granted at onboarding returns `invalid_scope` on the authorize call. ## PKCE PKCE (Proof Key for Code Exchange) closes the loophole where an intercepted authorization code could be redeemed by an attacker. Every request needs a fresh, single-use pair: - **`code_verifier`** — a high-entropy random string, 43-128 chars, kept on your server. - **`code_challenge`** — `base64url(sha256(code_verifier))`, sent on the authorize call. We require `code_challenge_method=S256`. `plain` is rejected. ### Generating a PKCE pair ```bash cURL CODE_VERIFIER=$(python -c 'import secrets;print(secrets.token_urlsafe(64)[:64])') CODE_CHALLENGE=$(python -c " import base64, hashlib, sys v = sys.argv[1] print(base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b'=').decode()) " "$CODE_VERIFIER") ``` ```python Python import base64 import hashlib import secrets def generate_pkce_pair() -> tuple[str, str]: verifier = secrets.token_urlsafe(64)[:64] challenge = base64.urlsafe_b64encode( hashlib.sha256(verifier.encode()).digest() ).rstrip(b"=").decode() return verifier, challenge ``` ```javascript Node import { createHash, randomBytes } from "node:crypto"; export function generatePkcePair() { const verifier = randomBytes(48).toString("base64url").slice(0, 64); const challenge = createHash("sha256").update(verifier).digest("base64url"); return { verifier, challenge }; } ``` ```php PHP $verifier, 'challenge' => $challenge]; } ``` ```go Go package pkce import ( "crypto/rand" "crypto/sha256" "encoding/base64" ) func Generate() (verifier, challenge string, err error) { b := make([]byte, 48) if _, err = rand.Read(b); err != nil { return "", "", err } verifier = base64.RawURLEncoding.EncodeToString(b)[:64] sum := sha256.Sum256([]byte(verifier)) challenge = base64.RawURLEncoding.EncodeToString(sum[:]) return verifier, challenge, nil } ``` The reference vectors come from [RFC 7636 §4.4](https://datatracker.ietf.org/doc/html/rfc7636#appendix-B). Our verification is tested against them — if your implementation matches the spec, it'll match ours. ## Step 1 — Send the user to /oauth/authorize This is a browser redirect, not a server-to-server call. The user has to sign in to DSS and approve consent in their own browser session. ``` GET https://api.digitalseaservice.com/oauth/authorize?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https://yourapp.example.com/integrations/dss/callback &scope=profile:read+seatime:read+vessels:read &state=RANDOM_PER_REQUEST_CSRF_TOKEN &code_challenge=GENERATED_CHALLENGE &code_challenge_method=S256 ``` ### Parameters | Parameter | Required | Notes | |-------------------------|----------|------------------------------------------------------------------------------------------------| | `response_type` | yes | Always `code`. | | `client_id` | yes | The client_id we issued. | | `redirect_uri` | yes | Must exactly match one of the URIs you registered. HTTPS required outside `http://localhost*`. | | `scope` | yes | Space-separated. Subset of your allowed scopes. | | `state` | yes | Anti-CSRF token. We round-trip it unchanged. Validate on callback. | | `code_challenge` | yes | From PKCE step. | | `code_challenge_method` | yes | Always `S256`. | ### What the user sees A consent screen listing your partner name and the requested scopes in plain English. On approval the browser is redirected back to your `redirect_uri`: ``` https://yourapp.example.com/integrations/dss/callback?code=ONE_SHOT_CODE&state=YOUR_STATE_VALUE ``` On denial, the same redirect with `?error=access_denied&state=...`. Render a useful message and offer to retry. **Validate `state` first thing on the callback.** If it doesn't match the value you stashed against the user's session, drop the request — treat it as a CSRF attempt, not a flow you can recover. ## Step 2 — Exchange the code for tokens Authorization codes are **single-use** and **expire after 60 seconds**. Exchange immediately on receipt. ```bash cURL curl -sS -X POST https://api.digitalseaservice.com/oauth/token \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri=https://yourapp.example.com/integrations/dss/callback \ -d client_id="$CLIENT_ID" \ -d client_secret="$CLIENT_SECRET" \ -d code_verifier="$CODE_VERIFIER" ``` ```python Python import httpx def exchange_code(code: str, code_verifier: str) -> dict: r = httpx.post( "https://api.digitalseaservice.com/oauth/token", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "code_verifier": code_verifier, }, timeout=10, ) r.raise_for_status() return r.json() ``` ```javascript Node export async function exchangeCode(code, codeVerifier) { const res = await fetch( "https://api.digitalseaservice.com/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: codeVerifier, }), } ); if (!res.ok) throw new Error(`token exchange failed: ${res.status}`); return res.json(); } ``` ```php PHP true, CURLOPT_RETURNTRANSFER => true, CURLOPT_POSTFIELDS => http_build_query([ 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => REDIRECT_URI, 'client_id' => CLIENT_ID, 'client_secret' => CLIENT_SECRET, 'code_verifier' => $verifier, ]), ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status >= 400) { throw new RuntimeException("token exchange failed: $status $body"); } return json_decode($body, true, flags: JSON_THROW_ON_ERROR); } ``` ```go Go package dss import ( "encoding/json" "fmt" "net/http" "net/url" "strings" ) type TokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token"` Scope string `json:"scope"` } func ExchangeCode(code, verifier string) (*TokenResponse, error) { form := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {RedirectURI}, "client_id": {ClientID}, "client_secret": {ClientSecret}, "code_verifier": {verifier}, } resp, err := http.Post( "https://api.digitalseaservice.com/oauth/token", "application/x-www-form-urlencoded", strings.NewReader(form.Encode()), ) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Errorf("token exchange: %s", resp.Status) } out := &TokenResponse{} return out, json.NewDecoder(resp.Body).Decode(out) } ``` ### Response ```json { "access_token": "Aq3...redacted", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "Vf9...redacted", "scope": "profile:read seatime:read vessels:read" } ``` Both tokens are opaque. Don't try to parse them — they have no meaning client-side. ## Step 3 — Call the API ```http GET /v1/me HTTP/1.1 Host: api.digitalseaservice.com Authorization: Bearer Aq3...redacted ``` Every authenticated response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. See [Rate limits](/rate-limits). ## Refreshing tokens Access tokens live for 1 hour. Refresh them with `grant_type=refresh_token` to get a new pair. ```bash cURL curl -sS -X POST https://api.digitalseaservice.com/oauth/token \ -d grant_type=refresh_token \ -d refresh_token="$REFRESH_TOKEN" \ -d client_id="$CLIENT_ID" \ -d client_secret="$CLIENT_SECRET" ``` ```python Python def refresh(refresh_token: str) -> dict: r = httpx.post( "https://api.digitalseaservice.com/oauth/token", data={ "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, }, timeout=10, ) r.raise_for_status() return r.json() ``` ```javascript Node export async function refresh(refreshToken) { const res = await fetch( "https://api.digitalseaservice.com/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, }), } ); if (!res.ok) throw new Error(`refresh failed: ${res.status}`); return res.json(); } ``` ```mermaid sequenceDiagram autonumber participant P as Your app participant DSS as DSS auth server participant Store as Your token store P->>Store: Read current refresh_token P->>DSS: POST /oauth/token grant_type=refresh_token DSS->>DSS: Revoke old access + refresh pair (atomic) DSS-->>P: { access_token, refresh_token (new), expires_in: 3600 } P->>Store: Replace refresh_token with new value ``` ### Rules - **Refresh tokens rotate.** Every successful refresh revokes both the old access token and the old refresh token, and issues a new pair. Always swap your stored value for the new one immediately. - **Refresh cannot widen scope.** You can request a narrower `scope` parameter to issue a more limited token, but not a broader one. - **Refresh tokens are 90-day TTL.** If a user hasn't been seen for 90 days the refresh token expires and they need to reconsent. - **One concurrent refresh per token.** If you race two refreshes with the same `refresh_token`, only one wins and the other returns `invalid_grant`. Treat that as "use the value the winning call returned" — don't reconsent. ## Revoking a token You can revoke either the access token or the refresh token via [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009). Revoking one revokes the paired other. ```bash cURL curl -sS -X POST https://api.digitalseaservice.com/oauth/revoke \ -d token="$ACCESS_TOKEN_OR_REFRESH" \ -d client_id="$CLIENT_ID" \ -d client_secret="$CLIENT_SECRET" ``` ```python Python httpx.post( "https://api.digitalseaservice.com/oauth/revoke", data={ "token": token, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, }, timeout=10, ) ``` ```javascript Node await fetch("https://api.digitalseaservice.com/oauth/revoke", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, }), }); ``` Per RFC 7009 the response is always `200 OK` with no body — revoking an unknown token is a no-op, not an error. ## What happens when the user disconnects A user can disconnect your integration from their DSS dashboard at any time. When they do: 1. Every access and refresh token issued to your client for that user is revoked immediately. Subsequent calls return [`invalid_token`](/errors/invalid_token) → `401`. 2. A signed `user.consent.revoked` webhook fires to your registered webhook URL — see [Webhooks](/webhooks/events#userconsentrevoked). 3. Under our Data Processing Agreement you have 30 days to delete any cached DSS data for that user. To trigger the same flow from your side (e.g. the user disconnects from your settings UI), call `POST /oauth/revoke` with the user's token. Because revoking either token in a pair revokes both, a single call tears down that user's connection on our side — no separate cleanup call is needed. ## Token storage on your side - Store both tokens encrypted at rest, keyed by your user record. - Never log them. The audit log on our side has everything we need; logging on your side just creates a leak risk. - Treat the access token as bearer credentials. Anyone with it can read whatever the granted scopes allow until it expires or is revoked. ## Failure modes | OAuth error | When you'll see it | |----------------------------|-----------------------------------------------------------------------------------------------------| | `invalid_request` | Missing or malformed parameter (e.g. no `code_challenge`). | | `invalid_client` | Wrong `client_id` / `client_secret` pair. | | `invalid_grant` | Expired code, replayed code, wrong `redirect_uri`, mismatched PKCE verifier, or revoked refresh. | | `unsupported_grant_type` | Anything other than `authorization_code` or `refresh_token`. | | `invalid_scope` | Requested scope isn't in your allowed set. | | `access_denied` | The user clicked Deny on the consent screen. | OAuth errors follow [RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) — shape is `{ "error": "...", "error_description": "..." }`, **not** RFC 7807 Problem Details. Resource endpoint errors do use Problem Details — see [Errors](/errors). --- # Rate limits Source: https://docs.digitalseaservice.com/rate-limits The DSS Public API rate-limits per `client_id` on a sliding window. There are two windows; whichever has the tighter remaining budget wins. ## Default budgets | Window | Budget per `client_id` | |------------|------------------------| | Per minute | 600 | | Per day | 10,000 | These apply to every authenticated `/v1/*` endpoint. Public endpoints (`/healthz`, OAuth, OpenAPI) are not rate-limited per-partner — they have infrastructure-level protection instead. Need higher limits? Premium-tier partners can negotiate. Talk to your DSS account contact. ## Headers we return Every authenticated response (success, `304`, or `429`) carries: | Header | Meaning | |-------------------------|---------------------------------------------------------------| | `X-RateLimit-Limit` | The window's budget (e.g. `600` for per-minute). | | `X-RateLimit-Remaining` | How many requests you have left in this window. | | `X-RateLimit-Reset` | Unix timestamp (seconds) when the window resets. | | `Retry-After` | Only on `429`. Seconds to wait before retrying. | The values reflect the *tightest* window at the time of the response — when the per-minute budget is healthier than the per-day budget, you'll see the per-day numbers, and vice versa. ## The 429 response ```http HTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 36 X-RateLimit-Limit: 600 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1748535696 X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/rate_limit_exceeded", "title": "Rate limit exceeded", "status": 429, "detail": "Rate limit of 600 requests exceeded. Retry after 36s.", "instance": "req_01HV9XK..." } ``` See [Errors / rate_limit_exceeded](/errors/rate_limit_exceeded) for the detail. ## Staying under budget The default budget is generous for any reasonable integration. Here's how to not waste it. ### 1. Use webhooks instead of polling Webhooks cost zero rate-limit budget. A well-integrated partner sets up webhooks on day one and only calls the API in response to events (or when the user actively opens a page that needs fresh data). See [Webhooks overview](/webhooks/overview). ### 2. Use conditional GETs Every resource endpoint returns `ETag` and `Last-Modified` and supports `If-None-Match` / `If-Modified-Since`. A `304 Not Modified` response counts as **one request** against your budget but returns no body — much cheaper than the alternative of "always full payload." ```bash # First request curl -i -H "Authorization: Bearer $TOKEN" \ https://api.digitalseaservice.com/v1/me/sea-time # Note the ETag in the response. On the next poll: curl -i -H "Authorization: Bearer $TOKEN" \ -H 'If-None-Match: "8f1e6c..."' \ https://api.digitalseaservice.com/v1/me/sea-time # -> 304 Not Modified, no body ``` If you're polling on a schedule, cache the ETag per (user, endpoint) and replay it on every request. ### 3. Don't tight-loop on 429 A retry loop that ignores `Retry-After` will keep hitting `429` and never recover. Always respect the header. ```python import time def with_rate_limit_backoff(call, max_retries: int = 5): for _ in range(max_retries): r = call() if r.status_code != 429: return r time.sleep(int(r.headers.get("Retry-After", "1"))) return r # final 429 returned to caller for handling ``` ### 4. Batch background work to natural intervals If you're refreshing N users' records every M minutes, schedule the batches so they don't pile up in the same second. Spreading 1,000 users evenly across a minute is a much smaller blast radius than 1,000 requests in the same second. ## Recommended polling intervals If you must poll (no webhooks available on your side): | Endpoint | Recommended max polling interval | |--------------------------------|----------------------------------| | `GET /v1/me` | 1 hour | | `GET /v1/me/sea-time` | 15 minutes | | `GET /v1/me/sea-time/recent` | 1 hour | | `GET /v1/me/vessels` | 1 hour | Combined with `If-None-Match`, the realistic cost of these is small — sea-time records change a few times per month for the average crew member. ## What happens on persistent overuse If your `client_id` consistently saturates its budget we'll get in touch before anything changes. We may suggest pushing more of your reads onto webhooks + conditional GETs, or talk through a higher-tier plan. We don't hard-suspend partners for rate-limit reasons without a conversation first. --- # Webhooks overview Source: https://docs.digitalseaservice.com/webhooks/overview When a crew member's record changes, we POST a signed event to every partner who holds active consent and the relevant scope. You re-fetch via the API to get the latest state. We retry on non-2xx with a documented schedule. Webhooks are the recommended way to stay fresh — partners who poll instead will hit conditional GETs (304 Not Modified) far more often than not and burn rate-limit budget for no payload. ## What you receive Every delivery looks like this: ``` POST https://yourapp.example.com/integrations/dss/webhook Content-Type: application/json X-DSS-Signature: t=1716714840,v1=99d56ccfe6de640971036fc31a8bb476415322e6b687301c96fe15ac81e3fcff User-Agent: dss-public-api-webhooks/1 {"created_at":"2026-05-26T09:14:00Z","data":{"user_id":"65a1f0e2c3b4d5e6f7a8b9c0"},"id":"evt_3f4a9c8e2b1d4f5a8c9e0d1f2a3b4c5d","type":"user.sea_time.updated"} ``` The body is a **pointer**, not data. You call back via the API with the user's access token to fetch the latest state. Two reasons: 1. Payloads stay small and our signing surface stays minimal. 2. Scope is re-evaluated at fetch time. If the user revoked between event firing and your fetch, the fetch fails cleanly (`401 invalid_token`) instead of leaking stale data. The body is serialised with **sorted keys and no whitespace** so the canonical form is stable across any intermediates that might re-serialise. ## Delivery model ```mermaid sequenceDiagram autonumber participant U as Crew member participant DSS as DSS dashboard participant API as DSS API participant Q as Cloud Tasks participant P as Your webhook URL U->>DSS: Updates profile / sea time / vessel DSS->>API: POST /internal/events/publish (event) API->>API: Fan out by consent + scope loop For each consenting partner API->>Q: Enqueue delivery Q->>P: POST signed event alt 2xx within 5s P-->>Q: 200 OK else non-2xx or timeout P--xQ: error Q->>Q: Schedule retry (1m → 5m → 30m → 2h → 6h) end end ``` ### Reliability - **At-least-once delivery.** You may see the same event twice — dedupe on `id`. - **Retry schedule:** `+1m, +5m, +30m, +2h, +6h`. We give up after 24h (or 6 attempts total, whichever comes first). - **Response budget:** 5 seconds. If your handler takes longer we treat it as a failure and retry. - **Idempotency key** is the event `id`, stable across every attempt. ### Order Webhooks are **not ordered**. Multiple events for the same user can arrive out of order, especially after retries. Always fetch the latest state from the API rather than trying to apply an event as a delta — the event tells you "something changed," the API call tells you "this is the new value." ## Registering your endpoint During onboarding you'll give us: - A single HTTPS URL. - A signing secret rotation contact (an email we ping when we plan to rotate). We give you back a **webhook signing secret**, separate from your `client_secret`. Store it next to your other secrets. The `client_secret` and `webhook_secret` rotate independently — rotating either one does not affect the other. To change your webhook URL or rotate the secret in sandbox, email [admin@digitalseaservice.com](mailto:admin@digitalseaservice.com). Production rotation is handled by your DSS account contact. ## Testing your handler Once your URL and secret are registered, call `GET /v1/webhooks/test` with a valid access token and we'll fire a `webhook.test` event to your URL. It's the same shape and signing as every other event — use it to validate signature verification before any real user authorises you. ```bash curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \ https://api.dev.digitalseaservice.com/v1/webhooks/test ``` A successful call returns `200 OK` with the `event_id`, `delivery_id`, and the `target_url` we enqueued the delivery against: ```json { "event_id": "evt_3f4a9c8e2b1d4f5a8c9e0d1f2a3b4c5d", "delivery_id": "dlv_77f1bb31c0c64e6db4f5e8a9c2d3e4f5", "target_url": "https://yachtworkerscouncil.com/integrations/dss/webhook" } ``` Watch your handler logs for the delivery. ## What's next Every event we send, with payload examples and recommended action. Drop-in code samples in Python and Node — copy these verbatim. --- # Event catalogue Source: https://docs.digitalseaservice.com/webhooks/events Every event shares the same shape: ```json { "id": "evt_<32-hex>", "type": "", "created_at": "", "data": { "user_id": "" } } ``` Bodies are JSON, UTF-8, **sorted keys, no whitespace** — the canonical form that the signature is computed over. See [Signature verification](/webhooks/verification) for why that matters. ## `user.profile.updated` Fires when name, role, country, or photo URL change. **Scope required for delivery:** `profile:read` ```json { "id": "evt_3f4a9c8e2b1d4f5a8c9e0d1f2a3b4c5d", "type": "user.profile.updated", "created_at": "2026-05-26T09:14:00Z", "data": { "user_id": "65a1f0e2c3b4d5e6f7a8b9c0" } } ``` **Recommended action:** `GET /v1/me` with the user's access token, replace your cached profile. --- ## `user.sea_time.updated` Fires when sea-time totals change — a new sea time entry verified, an existing entry amended, or a recalculation. **Scope required for delivery:** `seatime:read` ```json { "id": "evt_a1b2c3d4e5f6789012345678abcdef01", "type": "user.sea_time.updated", "created_at": "2026-05-26T09:14:00Z", "data": { "user_id": "65a1f0e2c3b4d5e6f7a8b9c0" } } ``` **Recommended action:** `GET /v1/me/sea-time` (and `/recent` if you display the trend chart). --- ## `user.vessels.updated` Fires when a vessel period is added or amended. **Scope required for delivery:** `vessels:read` ```json { "id": "evt_77f1bb31c0c64e6db4f5e8a9c2d3e4f5", "type": "user.vessels.updated", "created_at": "2026-05-26T09:14:00Z", "data": { "user_id": "65a1f0e2c3b4d5e6f7a8b9c0" } } ``` **Recommended action:** `GET /v1/me/vessels`. If you paginate, start from the first page — a vessel period change can shift the sort order of any page. --- ## `user.consent.revoked` Fires when the user disconnects your integration from the DSS dashboard. **Scope required for delivery:** none — this event delivers regardless of which scopes you held. A partner who never held `profile:read` still needs to know to clean up. ```json { "id": "evt_0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f", "type": "user.consent.revoked", "created_at": "2026-05-26T09:14:00Z", "data": { "user_id": "65a1f0e2c3b4d5e6f7a8b9c0" } } ``` **Recommended action:** delete any cached DSS data for this user within 30 days (DPA-enforced). Your stored OAuth tokens are already invalidated on our side — calls with them will return `401 invalid_token` even before this event reaches you. Disconnect handling is a frequent gap in V1 integrations. Build it before going to production — we check for it as part of integration review. --- ## `webhook.test` Fired by `GET /v1/webhooks/test`. Identical shape to real events; `user_id` is the calling user (the access token's subject). **Scope required for delivery:** none — the test endpoint always delivers. ```json { "id": "evt_test99887766554433221100ffeeddcc", "type": "webhook.test", "created_at": "2026-05-26T09:14:00Z", "data": { "user_id": "65a1f0e2c3b4d5e6f7a8b9c0" } } ``` **Recommended action:** none — this is a fire-and-forget test. A 2xx response proves your handler is reachable, signature verification works, and your endpoint stays under the 5-second budget. --- ## Delivery headers Every delivery, regardless of event type: | Header | Value | |--------------------|----------------------------------------------------| | `Content-Type` | `application/json` | | `X-DSS-Signature` | `t=,v1=` | | `User-Agent` | `dss-public-api-webhooks/1` | The `X-DSS-Signature` value is HMAC-SHA256 of `.` using your webhook signing secret. See [Verification](/webhooks/verification). ## Retry & give-up | Attempt | Delay since previous | |---------|----------------------| | 1 | (immediate) | | 2 | +1 minute | | 3 | +5 minutes | | 4 | +30 minutes | | 5 | +2 hours | | 6 | +6 hours | We give up after **24 hours** or 6 attempts total, whichever comes first. Failed deliveries persist in our webhook delivery log; we surface gives-up in the super-admin dashboard for the integration team to investigate together. ## Future events We add events conservatively and announce on the [changelog](/changelog). Plan your handler with an `unknown-event` fallback that returns 2xx and logs — adding a new event type isn't a breaking change. --- # Signature verification Source: https://docs.digitalseaservice.com/webhooks/verification Every webhook delivery carries an `X-DSS-Signature` header. Verify it on every request before doing anything with the body — a missing or invalid signature means the request didn't come from us (or it's a replay). ## The signature ``` X-DSS-Signature: t=1716714840,v1=99d56ccfe6de640971036fc31a8bb476415322e6b687301c96fe15ac81e3fcff ``` - `t` — Unix timestamp (seconds) of when we signed. - `v1` — HMAC-SHA256 of `.`, encoded as lowercase hex. The secret is the **webhook signing secret** we issued at onboarding — separate from your `client_secret`. Rotate it independently via your DSS account contact. ## The verification rules A verifier must enforce all four: 1. **Parse the header.** If `t` or `v1` is missing or malformed, reject with `400`. 2. **Reject stale events.** If `|now - t| > 300 seconds`, reject. This is the replay protection window. 3. **Recompute HMAC-SHA256** of `f"{t}.".encode() + raw_body_bytes` using the signing secret. Compare in **constant time** against `v1`. 4. **Reject mismatches.** Don't trust any field of the body until the signature checks. Use the **raw bytes** of the request body, not a re-serialised JSON view of it. JSON re-serialisation reorders keys or changes whitespace, both of which break the signature. In FastAPI: `await request.body()`. In Express: `express.raw({ type: "application/json" })` and read `req.body` as a Buffer. ## Python Copy this verbatim: ```python import hashlib import hmac import time from typing import Final REPLAY_WINDOW_S: Final = 300 def verify_dss_signature(raw_body: bytes, header: str, secret: bytes) -> bool: """Returns True iff the signature matches AND the timestamp is fresh.""" parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) try: ts = int(parts["t"]) sig_hex = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - ts) > REPLAY_WINDOW_S: return False signed = f"{ts}.".encode("ascii") + raw_body expected = hmac.new(secret, signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig_hex) ``` ### FastAPI handler ```python import json from fastapi import FastAPI, Header, HTTPException, Request, Response app = FastAPI() WEBHOOK_SECRET = b"..." # from your secret store @app.post("/integrations/dss/webhook") async def dss_webhook( request: Request, x_dss_signature: str | None = Header(default=None), ) -> Response: if x_dss_signature is None: raise HTTPException(status_code=400, detail="missing signature") # IMPORTANT: raw bytes, not re-serialised JSON. raw = await request.body() if not verify_dss_signature(raw, x_dss_signature, WEBHOOK_SECRET): raise HTTPException(status_code=400, detail="invalid signature") event = json.loads(raw) if seen_recently(event["id"]): return Response(status_code=200) # idempotent dedupe handle(event) return Response(status_code=200) ``` ### Flask handler ```python from flask import Flask, request app = Flask(__name__) WEBHOOK_SECRET = b"..." @app.post("/integrations/dss/webhook") def dss_webhook(): sig = request.headers.get("X-DSS-Signature") if sig is None: return "missing signature", 400 raw = request.get_data() # raw bytes if not verify_dss_signature(raw, sig, WEBHOOK_SECRET): return "invalid signature", 400 event = request.get_json(force=True) if not seen_recently(event["id"]): handle(event) return "", 200 ``` ### Tests against the spec Our canonical fixture: ```python import hmac import hashlib event_body = ( b'{"created_at":"2026-05-26T09:14:00Z",' b'"data":{"user_id":"65a1f0e2c3b4d5e6f7a8b9c0"},' b'"id":"evt_3f4a9c8e2b1d4f5a8c9e0d1f2a3b4c5d",' b'"type":"user.sea_time.updated"}' ) ts = 1716714840 secret = b"example-partner-webhook-secret-32" expected = hmac.new(secret, f"{ts}.".encode() + event_body, hashlib.sha256).hexdigest() assert expected == "99d56ccfe6de640971036fc31a8bb476415322e6b687301c96fe15ac81e3fcff" ``` Your verifier should accept this with header `t=1716714840,v1=99d56ccfe6de640971036fc31a8bb476415322e6b687301c96fe15ac81e3fcff` **only within 5 minutes of the timestamp**. If you're testing offline, freeze the clock at `1716714840`. ## Node ```javascript import { createHmac, timingSafeEqual } from "node:crypto"; import { Buffer } from "node:buffer"; const REPLAY_WINDOW_S = 300; export function verifyDssSignature(rawBody, header, secret) { if (typeof header !== "string") return false; const parts = Object.fromEntries( header.split(",").map((p) => { const i = p.indexOf("="); return i < 0 ? [p, ""] : [p.slice(0, i), p.slice(i + 1)]; }) ); const t = Number.parseInt(parts.t ?? "", 10); const sigHex = parts.v1; if (!Number.isFinite(t) || !sigHex) return false; if (Math.abs(Date.now() / 1000 - t) > REPLAY_WINDOW_S) return false; const signed = Buffer.concat([ Buffer.from(`${t}.`, "ascii"), Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody), ]); const expectedHex = createHmac("sha256", secret).update(signed).digest("hex"); const expected = Buffer.from(expectedHex, "hex"); const provided = Buffer.from(sigHex, "hex"); if (expected.length !== provided.length) return false; return timingSafeEqual(expected, provided); } ``` ### Express handler ```javascript import express from "express"; import { verifyDssSignature } from "./verify.js"; const app = express(); const WEBHOOK_SECRET = process.env.DSS_WEBHOOK_SECRET; app.post( "/integrations/dss/webhook", // IMPORTANT: raw, not express.json — we need the unmodified body bytes. express.raw({ type: "application/json", limit: "32kb" }), async (req, res) => { const sig = req.get("X-DSS-Signature"); if (!verifyDssSignature(req.body, sig, WEBHOOK_SECRET)) { return res.status(400).send("invalid signature"); } const event = JSON.parse(req.body.toString("utf8")); if (await seenRecently(event.id)) return res.status(200).end(); await handle(event); res.status(200).end(); } ); ``` ### Next.js Route Handler (App Router) ```typescript import { verifyDssSignature } from "@/lib/dss"; const WEBHOOK_SECRET = process.env.DSS_WEBHOOK_SECRET!; export async function POST(req: Request) { const sig = req.headers.get("x-dss-signature"); const raw = Buffer.from(await req.arrayBuffer()); if (!verifyDssSignature(raw, sig, WEBHOOK_SECRET)) { return new Response("invalid signature", { status: 400 }); } const event = JSON.parse(raw.toString("utf8")); if (await seenRecently(event.id)) return new Response(null, { status: 200 }); await handle(event); return new Response(null, { status: 200 }); } ``` ## What you must do (checklist) No verified signature, no body parsing. Return `400`. Even with a valid signature, reject events older than 300 seconds. `hmac.compare_digest` (Python) or `crypto.timingSafeEqual` (Node). String `==` is wrong. At-least-once delivery means you will see duplicates. The `id` is stable across attempts — store the last N you've seen in Redis or your DB. Hand off heavy work to a background queue. Acknowledging fast keeps you out of the retry schedule. A 4xx on a duplicate looks like a delivery failure to us and triggers pointless retries. ## What you must *not* do - **Do not** trust the body before the signature checks. - **Do not** verify against a re-serialised body. The signature is over the bytes we sent, in the order we sent them. - **Do not** log the signing secret. Treat it like any other long-lived credential. - **Do not** allow `http://` for your webhook URL outside local development. We won't deliver to plain HTTP in production. --- # Errors Source: https://docs.digitalseaservice.com/errors/index Resource-endpoint errors use [RFC 7807 Problem Details](https://datatracker.ietf.org/doc/html/rfc7807). Same shape every time: ```json { "type": "https://docs.digitalseaservice.com/errors/insufficient_scope", "title": "Insufficient scope", "status": 403, "detail": "This endpoint requires the seatime:read scope. Granted: profile:read.", "instance": "req_01HV9XK..." } ``` The `Content-Type` is `application/problem+json`. | Field | Meaning | |------------|------------------------------------------------------------------------------------------| | `type` | A URL that resolves into these docs — one page per type, listed below. | | `title` | Short, human-readable, machine-keyable label. Stable across status codes. | | `status` | HTTP status, mirrored from the response line. | | `detail` | Human-readable, context-specific message. Safe to surface to the partner-side support. | | `instance` | The `X-Request-Id` of this request. Quote it when contacting support. | **OAuth endpoint errors do not use Problem Details.** They follow [RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) — shape is `{ "error": "...", "error_description": "..." }`. OAuth client libraries key off `response["error"]` and break otherwise. See [Authentication / failure modes](/authentication#failure-modes). ## How to handle errors 1. **Branch on `type`, not on `detail`.** `type` URLs are stable. `detail` strings improve over time. 2. **Surface `instance` in your own logs.** Every API response (success or failure) includes `X-Request-Id` that matches `instance`. We can find the exact request in our audit log instantly given the request ID. 3. **Retry the right errors only.** `5xx` and `429` are retryable with backoff. `4xx` other than `429` are not — fix the request first. 4. **Map to your own messaging.** Don't show `type` URLs to end users. The URL is for your engineers and AI agents; users get your wording. ## Error catalog Every `type:` URL in a Problem Details response resolves to one of these pages: Malformed request — missing parameter, bad format, schema validation failure. Missing, malformed, expired, or revoked access token. Token is valid but doesn't carry the scope this endpoint requires. Your partner account is suspended — an account-level state, not a token scope issue. Resource or route does not exist. You called the webhook self-test before registering a URL and signing secret. You've exceeded your per-minute or per-day budget. Check `Retry-After`. Something broke on our side. Quote `X-Request-Id` to support. ## Request ID Every response — success or error — carries `X-Request-Id`. On errors it also matches the `instance` field of the Problem Details body. Quote it when you contact support and we can find the exact audit log entry in seconds. You can also send your own `X-Request-Id` on a request — we'll honour it and include it on the response, which makes correlating your logs with ours trivial. --- # invalid_request Source: https://docs.digitalseaservice.com/errors/invalid_request ```http HTTP/1.1 400 Bad Request Content-Type: application/problem+json X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/invalid_request", "title": "Invalid request", "status": 400, "detail": "Query parameter 'limit' must be between 1 and 200.", "instance": "req_01HV9XK..." } ``` ## What it means The request didn't pass our validation rules. The `detail` field tells you which field and why. ## Common causes | Cause | Fix | |------------------------------------------------------|----------------------------------------------------------------------------------------------| | Malformed cursor (`/v1/me/vessels?cursor=...`) | Don't hand-craft cursors. Use the `next_cursor` from the previous response verbatim. | | Out-of-range `limit` | Use 1–200. Defaults are sensible — only set this if you need a specific page size. | | Unrecognised query parameter | Drop it. We don't silently accept unknown params. | | Missing required header | Re-check the endpoint's reference page. | | Method not allowed (HTTP 405) | Most V1 endpoints are GET only. POST/PUT/DELETE return this rather than `not_found`. | ## What to do This is a programming error on your side — retrying without changes won't help. Fix the request and re-send. For 422-class errors (Pydantic-style field-level validation), we include an `errors` array with the specific failing fields: ```json { "type": "https://docs.digitalseaservice.com/errors/invalid_request", "title": "Request validation failed", "status": 422, "detail": "One or more fields failed validation.", "instance": "req_01HV9XK...", "errors": [ { "loc": ["query", "limit"], "msg": "Input should be less than or equal to 200", "type": "less_than_equal" } ] } ``` ## Related - [Errors overview](/errors) --- # invalid_token Source: https://docs.digitalseaservice.com/errors/invalid_token ```http HTTP/1.1 401 Unauthorized Content-Type: application/problem+json WWW-Authenticate: Bearer error="invalid_token" X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/invalid_token", "title": "Invalid or expired token", "status": 401, "detail": "Access token is expired or has been revoked.", "instance": "req_01HV9XK..." } ``` ## What it means We couldn't authenticate the request. Either there's no `Authorization` header, the bearer token we saw doesn't match anything we issued, it's expired, or it's been revoked. ## Common causes & fixes | Cause | Fix | |------------------------------------|--------------------------------------------------------------------------------------------------| | Missing `Authorization` header | Add `Authorization: Bearer `. | | Header format wrong | Must be exactly `Bearer `. Not `bearer`, no extra whitespace, no quoting. | | Access token expired (1-hour TTL) | Refresh via `grant_type=refresh_token`. See [Authentication / refreshing](/authentication#refreshing-tokens). | | Refresh token used after rotation | The previous refresh token is revoked the moment you refresh. Always store the new one immediately. | | User disconnected | The user clicked Disconnect on their DSS dashboard. Reconsent flow is needed — see `user.consent.revoked`. | | Partner suspended | Your client_id has been suspended by DSS support. Contact us. | ## Distinguishing "expired" vs "revoked" We don't expose this in the `detail` for security reasons (you shouldn't be able to probe which case you're in to enumerate users). Treat them the same: 1. Try a refresh. If it succeeds, you had a stale access token — store the new pair and retry the original request. 2. If the refresh itself returns `invalid_grant`, the user has either disconnected or your refresh token has expired (90-day TTL). In either case, you need to send the user through the authorize flow again. ```python def request_with_auto_refresh(url, access_token, refresh_token): r = httpx.get(url, headers={"Authorization": f"Bearer {access_token}"}) if r.status_code != 401: return r, access_token, refresh_token try: new_tokens = refresh(refresh_token) # POST /oauth/token except httpx.HTTPStatusError as exc: if exc.response.status_code == 400: raise NeedsReconsent() from exc raise r = httpx.get(url, headers={"Authorization": f"Bearer {new_tokens['access_token']}"}) return r, new_tokens["access_token"], new_tokens["refresh_token"] ``` ## Related - [Authentication / refreshing](/authentication#refreshing-tokens) - [`user.consent.revoked` event](/webhooks/events#userconsentrevoked) - [Errors overview](/errors) --- # insufficient_scope Source: https://docs.digitalseaservice.com/errors/insufficient_scope ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json WWW-Authenticate: Bearer error="insufficient_scope", scope="seatime:read" X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/insufficient_scope", "title": "Insufficient scope", "status": 403, "detail": "This endpoint requires the seatime:read scope. Granted: profile:read.", "instance": "req_01HV9XK..." } ``` ## What it means The access token is valid and matches a real user, but it wasn't issued with the scope this endpoint requires. The `detail` always names the scope the endpoint needs and lists the scopes the token actually carries. The `WWW-Authenticate` header on the response also carries the needed scope per [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1). ## Required scopes by endpoint | Endpoint | Required scope | |--------------------------------|----------------| | `GET /v1/me` | `profile:read` | | `GET /v1/me/sea-time` | `seatime:read` | | `GET /v1/me/sea-time/recent` | `seatime:read` | | `GET /v1/me/vessels` | `vessels:read` | | `GET /v1/webhooks/test` | (any valid token) | ## What to do Send the user back through the authorize flow with the missing scope included in the `scope` parameter. We'll prompt them to consent to the additional scope — they don't need to redo the whole connection. ``` GET /oauth/authorize?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=... &scope=profile:read+seatime:read+vessels:read ← add the missing one &state=... &code_challenge=... &code_challenge_method=S256 ``` **Refresh cannot widen scope** — there's no shortcut. Calling `/oauth/token` with a broader `scope` than the user originally granted returns `invalid_scope`. The user has to re-consent. ## Related - [Authentication / scopes](/authentication#scopes) - [Errors overview](/errors) --- # partner_suspended Source: https://docs.digitalseaservice.com/errors/partner_suspended ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/partner_suspended", "title": "Partner suspended", "status": 403, "detail": "This partner is suspended.", "instance": "req_01HV9XK..." } ``` ## What it means Your partner account has been suspended, so partner-level operations are refused. This is distinct from [`insufficient_scope`](/errors/insufficient_scope), which is about a single token's scopes — `partner_suspended` applies to the whole client regardless of the token presented. Today this is only returned by `GET /v1/webhooks/test`, since that is the one partner-level action in the public API. A suspended partner's existing access tokens continue to authenticate against the read endpoints until they expire. ## What to do There's nothing to fix in your request. Suspension is an account-level state set by DSS. Contact [admin@digitalseaservice.com](mailto:admin@digitalseaservice.com), quoting the `instance` request ID, to find out why and to get reinstated. ## Related - [Webhooks / testing your handler](/webhooks/overview#testing-your-handler) - [Errors overview](/errors) --- # not_found Source: https://docs.digitalseaservice.com/errors/not_found ```http HTTP/1.1 404 Not Found Content-Type: application/problem+json X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/not_found", "title": "Resource not found", "status": 404, "detail": "No sea time record exists for this user yet.", "instance": "req_01HV9XK..." } ``` ## What it means Either the URL path doesn't match any route we serve, or the resource identified by the URL doesn't exist (or hasn't been created yet). ## Common causes | Cause | Fix | |-------------------------------------------------------------|-----------------------------------------------------------------------| | Typo in the path (`/v1/me/seatime` vs `/v1/me/sea-time`) | Check the [API reference](/api-reference). | | Missing API version prefix (`/me/sea-time` vs `/v1/me/sea-time`) | Always include `/v1`. Future versions ship under `/v2`. | | User has no sea time record yet | This is a real-world state for new crew. Render an empty state. | | User has no vessel history yet | Same. The list endpoint returns `200` with an empty `vessels` array — a true `404` only fires on `/v1/me/sea-time` for users with no record. | | Sandbox test user doesn't exist | Sandbox is seeded with a small set of test users. Use one of those. | ## What to do Distinguish these two cases: - **Route doesn't exist** — programming error, fix the URL. - **Resource doesn't exist** — render an empty state. Don't retry. A new user with no sea-time record will receive `webhook user.sea_time.updated` the moment one is created. ## Related - [API reference](/api-reference) - [Errors overview](/errors) --- # no_webhook_configured Source: https://docs.digitalseaservice.com/errors/no_webhook_configured ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/no_webhook_configured", "title": "Partner has no webhook URL or signing secret registered", "status": 409, "detail": "Register a webhook URL and rotate a signing secret before calling /v1/webhooks/test.", "instance": "req_01HV9XK..." } ``` ## What it means `GET /v1/webhooks/test` fires a `webhook.test` event to your registered webhook URL, signed with your webhook signing secret. This `409` means one or both of those is missing — there's nowhere to deliver the test event, or nothing to sign it with. ## What to do Register a webhook URL and rotate a signing secret first, then retry. In sandbox, email [admin@digitalseaservice.com](mailto:admin@digitalseaservice.com) with your HTTPS URL; we return the signing secret and you're ready to test. See [Webhooks / registering](/webhooks/overview#registering-your-endpoint). ## Related - [Webhooks / testing your handler](/webhooks/overview#testing-your-handler) - [Signature verification](/webhooks/verification) - [Errors overview](/errors) --- # rate_limit_exceeded Source: https://docs.digitalseaservice.com/errors/rate_limit_exceeded ```http HTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 36 X-RateLimit-Limit: 600 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1748535696 X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/rate_limit_exceeded", "title": "Rate limit exceeded", "status": 429, "detail": "Rate limit of 600 requests exceeded. Retry after 36s.", "instance": "req_01HV9XK..." } ``` ## What it means Your `client_id` has burned through its per-minute or per-day budget. We return `429` until the sliding window opens back up. ## Budgets | Window | Default budget per `client_id` | |------------|-------------------------------| | Per minute | 600 | | Per day | 10,000 | Both windows apply; the tighter remaining budget wins. Premium-tier partners can negotiate higher limits — talk to your DSS account contact. ## Headers on every authenticated response Every `200`, `304`, and `429` carries: | Header | Meaning | |-------------------------|-----------------------------------------------------------| | `X-RateLimit-Limit` | The window's budget (e.g. `600` for per-minute). | | `X-RateLimit-Remaining` | How many requests you have left in this window. | | `X-RateLimit-Reset` | Unix timestamp (seconds) when the window resets. | `429` also includes `Retry-After: `. ## What to do 1. **Respect `Retry-After`.** Wait at least that long before retrying. 2. **Back off, don't retry tight.** A retry loop that ignores `Retry-After` will keep hitting `429` for the rest of the window. 3. **Use conditional GETs.** Pair every read with the prior `ETag` or `Last-Modified` — a `304` Not Modified counts as 1 request but returns no body. See [Rate limits](/rate-limits) for the full polling story. 4. **Subscribe to webhooks.** They cost zero rate-limit budget. Polling every user every minute will burn your budget fast; webhooks + a conditional GET on receipt is essentially free. ```python import time def with_rate_limit_backoff(call): while True: r = call() if r.status_code != 429: return r delay = int(r.headers.get("Retry-After", "1")) time.sleep(max(1, delay)) ``` ## Related - [Rate limits](/rate-limits) - [Errors overview](/errors) --- # internal_error Source: https://docs.digitalseaservice.com/errors/internal_error ```http HTTP/1.1 500 Internal Server Error Content-Type: application/problem+json X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/internal_error", "title": "Internal error", "status": 500, "detail": "An unexpected error occurred. Reference this request_id to support.", "instance": "req_01HV9XK..." } ``` ## What it means Something on our side failed in a way we didn't anticipate. We never leak internal details in `detail` — but every `500` is logged with full context, and the `instance` field matches the `X-Request-Id` header so we can find the exact request in our logs. ## What to do 1. **Retry with exponential backoff.** `500`-class responses are typically transient. Start at 1 second, double up to a cap of 30 seconds, give up after ~5 attempts. 2. **Keep the `X-Request-Id`.** If the error persists, send it to [admin@digitalseaservice.com](mailto:admin@digitalseaservice.com). We can pull the exact request in seconds. 3. **Check [status.digitalseaservice.com](https://status.digitalseaservice.com)** before raising — if we're in an incident, you're not alone and we're already on it. 4. **Don't surface the raw error to end users.** The `instance` is for your engineers. Show users a generic "Couldn't reach Digital Sea Service — please retry in a moment" message. ## Idempotency All V1 endpoints are read-only (`GET`), so retrying a `500` is always safe. When we add write endpoints in V2 they'll require explicit idempotency keys — until then, just retry. ## Related - [Errors overview](/errors) - [Status page](https://status.digitalseaservice.com) --- # API reference Source: https://docs.digitalseaservice.com/api-reference/overview This reference is generated from the live OpenAPI 3.1 spec for the DSS Public API. The spec is the source of truth — if the docs and the spec disagree, the spec wins (and please [tell us](mailto:admin@digitalseaservice.com) so we can fix the docs). ## Where to find the spec | Source | Format | Use it for | |-------------------------------------------------------------------|--------|--------------------------------------------| | [`https://api.digitalseaservice.com/openapi.json`](https://api.digitalseaservice.com/openapi.json) | JSON | Production-current spec from the live API. | | [`https://api.digitalseaservice.com/openapi.yaml`](https://api.digitalseaservice.com/openapi.yaml) | YAML | Same content, AI-ingestion-friendly. | | [`/api-reference/openapi.json`](/api-reference/openapi.json) | JSON | The build-time snapshot powering this site. | ## What the spec includes - All four V1 resource endpoints with full request/response schemas and examples. - The OAuth flow endpoints (`/oauth/authorize`, `/oauth/token`, `/oauth/revoke`, `/oauth/userinfo`). - A complete `oauth2` security scheme — IDE tools and "Try it" widgets pick up scopes from this. - The `servers` array — production and sandbox URLs, switchable from the dropdown above each endpoint. - Pydantic-typed response models with worked examples for every field. ## Suggested AI-agent flow If you're driving an AI coding agent through your integration: 1. Drop [`llms.txt`](/llms.txt) into context for a map of these docs. 2. Drop [`/openapi.yaml`](/api-reference/openapi.yaml) into context for the full API contract. 3. For specific topics, add the page's `.md` view — [`/authentication.md`](/authentication.md), etc. Mintlify also exposes "Copy for ChatGPT" and "Copy for Claude" actions on every page header, which package the page text + relevant context into a prompt you can paste straight into a chat. --- # Changelog Source: https://docs.digitalseaservice.com/changelog We version this API at the URL path. `/v1/*` runs **at least 12 months past /v2 launch**, with the deprecation schedule announced on this page and via email to every active partner contact. What counts as a breaking change: - Removing or renaming any field, endpoint, header, error code, or scope. - Tightening a validation rule on an existing field. - Changing the shape of a webhook payload. - Removing or changing the meaning of an event type. What does **not** count as a breaking change: - Adding a new endpoint, field, header, error code, or scope. - Adding a new event type. - Loosening a validation rule. - Adding a new optional query parameter. We try to be conservative about additive changes too — every new field is something partners may end up depending on by accident. We announce them here first. --- ## 2026-05-28 — V1 launch First public release of the DSS Public API. ### Authentication - OAuth 2.0 Authorization Code flow with PKCE (`S256` required). - Opaque access tokens (1-hour TTL) and rotating refresh tokens (90-day TTL). - RFC 7009 token revocation. ### Endpoints - `GET /v1/me` — basic profile (`profile:read`). - `GET /v1/me/sea-time` — totals, by-role breakdown, verified split (`seatime:read`). - `GET /v1/me/sea-time/recent` — rolling 12-month trend (`seatime:read`). - `GET /v1/me/vessels` — paginated vessel history (`vessels:read`). - `GET /v1/webhooks/test` — fires a `webhook.test` event to your registered URL. ### Caching - ETag + `If-None-Match` on every resource. - `Last-Modified` + `If-Modified-Since` on every resource. ### Rate limits - 600 / minute, 10,000 / day, per `client_id`. - `X-RateLimit-*` headers on every authenticated response. ### Webhooks - Event types: `user.profile.updated`, `user.sea_time.updated`, `user.vessels.updated`, `user.consent.revoked`, `webhook.test`. - HMAC-SHA256 signing with a 5-minute replay window. - Retry schedule: `+1m, +5m, +30m, +2h, +6h`. Give up after 24h. ### Errors - Resource endpoints: RFC 7807 Problem Details with stable `type` URLs. - OAuth endpoints: RFC 6749 §5.2 shape. ### Docs - Mintlify docs site live at [docs.digitalseaservice.com](https://docs.digitalseaservice.com). - `llms.txt`, `llms-full.txt`, and every page as `.md` for AI agents. - OpenAPI 3.1 spec at `/openapi.json` and `/openapi.yaml`. --- ## Subscribing to changes We email every partner contact at the address you gave us during onboarding when anything ships here. We will not surprise you with a breaking change — the V1 → V2 transition will have its own announcement, its own dedicated docs section, and at least 12 months of overlap. For the truly paranoid: [`https://docs.digitalseaservice.com/llms-full.txt`](https://docs.digitalseaservice.com/llms-full.txt) is the entire docs tree as a single file. Diff it. ---