# 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", "membership": { "tier": "premium", "source": "sponsored", "since": "2026-07-25", "expires_at": "2027-07-25T09:14:00Z", "sponsored_by_you": true } } ``` If you see this — you're integrated. The `membership` block tells you whether this crew member already holds Premium. Gate any upgrade prompt on it — see [Membership status](/membership). ## 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. An upstream DSS system was unreachable. Retry with backoff — never treat it as an empty answer. Three further types are returned only by the partner-restricted [Sponsorship API](/sponsorships/overview): No DSS account yet — send the member to `signup_url` for normal signup. The crew member you tried to sponsor already pays for Premium themselves. Your program's seat cap is full. We could not grant Premium to the member, so no seat was billed. Retry. ## 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": "Authenticated user not found in DSS records.", "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 | `GET /v1/me/sea-time` and `/recent` return **200** with zero totals and an empty `months` array. Do not treat this as unreachable. | | User has no vessel history yet | `GET /v1/me/vessels` returns **200** with an empty `vessels` array. | | 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 Connected member with no sea days yet gets `200` zeros on sea-time endpoints, then `webhook user.sea_time.updated` when the first day lands. ## 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) --- # account_requires_signup Source: https://docs.digitalseaservice.com/errors/account_requires_signup **Partner-restricted.** Only returned by the [Sponsorship API](/sponsorships/overview), which requires a sponsorship program. ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/account_requires_signup", "title": "DSS account required", "status": 409, "detail": "This email does not have a Digital Sea Service account yet. Send the member to signup_url to create one, then Connect.", "instance": "req_01HV9XK...", "signup_url": "https://app.digitalseaservice.com/signin?intent=signup&partner=ywc&email=jane%40example.com&first_name=Jane&last_name=Doe" } ``` ## What it means You called [`POST /v1/partners/sponsorships`](/sponsorships/provision) for an email that is **not yet a DSS member**. We no longer create a passwordless account or send a “set your password” email. The member must complete **normal DSS signup** (password + verify-email), then Connect. Premium is granted on Connect. ## What to do 1. Redirect the member’s browser to `signup_url` (or build the same URL yourself — see [Set up a DSS account](/sponsorships/sso#set-up-a-dss-account)). 2. After they verify email, run your existing **Connect** flow. 3. Call `POST /v1/partners/sponsorships` again only once they already have a DSS account (or rely on entitle-on-Connect). Do not treat this as unreachable. It is a setup step. ## Related - [Open DSS (YWC SSO)](/sponsorships/sso) - [Provision a seat](/sponsorships/provision) --- # email_already_premium Source: https://docs.digitalseaservice.com/errors/email_already_premium **Partner-restricted.** Only returned by the [Sponsorship API](/sponsorships/overview), which requires a sponsorship program. ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/email_already_premium", "title": "Email already Premium", "status": 409, "detail": "This email already self-pays for Premium.", "instance": "req_01HV9XK..." } ``` ## What it means You called [`POST /v1/partners/sponsorships`](/sponsorships/provision) for a crew member who already holds a **self-paid** Premium subscription. There is nothing to sponsor — DSS won't take over billing for a subscription the member is already paying for themselves, and won't double-charge you for a seat that delivers no additional benefit. This is distinct from an idempotent replay: a member who already holds one of **your** open sponsored seats returns `200` with that existing seat (see [Provision / idempotency](/sponsorships/provision#idempotency)). `email_already_premium` is specifically a member paying for Premium on their own card. ## What to do - **Nothing to fix in the request** — this member doesn't need a sponsored seat. Skip them and move on. - If you believe the member should be on a sponsored seat instead of self-pay (for example they want to stop paying and let you cover it), that's a conversion the member initiates from their own account, or a change DSS can help arrange — [contact us](mailto:admin@digitalseaservice.com), quoting the `instance` request ID. ## Related - [Provision a seat](/sponsorships/provision) - [Errors overview](/errors) --- # sponsorship_cap_reached Source: https://docs.digitalseaservice.com/errors/sponsorship_cap_reached **Partner-restricted.** Only returned by the [Sponsorship API](/sponsorships/overview), which requires a sponsorship program. ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json X-Request-Id: req_01HV9XK... { "type": "https://docs.digitalseaservice.com/errors/sponsorship_cap_reached", "title": "Sponsorship cap reached", "status": 409, "detail": "This sponsorship program has no seats available.", "instance": "req_01HV9XK..." } ``` ## What it means Your program has a configured seat cap, and every seat is currently occupied, so [`POST /v1/partners/sponsorships`](/sponsorships/provision) can't provision a new one. Seats in the `active` [status](/sponsorships/overview#seat-lifecycle) occupy the cap; `converted`, `cancelled` and `expired` seats free it. **Many programs are uncapped.** A capped program is a per-program setting, not a default. If your program is uncapped you will never see this error — new seats are always accepted (DSS monitors growth for you rather than blocking at a ceiling). ## What to do - **Free up a seat.** [Remove a seat](/sponsorships/remove) you no longer need — `cancelled`, `converted` and `expired` seats don't count. Then retry the provision. - **Reconcile first.** Use [`GET /v1/partners/sponsorships?status=active`](/sponsorships/reconciliation) to see exactly which seats are occupied. - **Raise or remove the cap.** If you need more seats, [contact DSS](mailto:admin@digitalseaservice.com) — the cap (or making the program uncapped) is a program-configuration change on our side. Quote the `instance` request ID. ## Related - [Provision a seat](/sponsorships/provision) - [Reconcile your roster](/sponsorships/reconciliation) - [Errors overview](/errors) --- # Sponsorship API Source: https://docs.digitalseaservice.com/sponsorships/overview **Restricted — requires a sponsorship program.** These endpoints are only available to partners who run a DSS sponsorship program. If you don't have one and want one, [contact DSS](mailto:admin@digitalseaservice.com). The rest of the API (OAuth, `/v1/me/*`, webhooks) needs no program. A **sponsorship program** lets a partner pay for Premium on behalf of their crew. You provision a crew member into a **seat** only after they already have a Digital Sea Service account. New members complete **normal DSS signup** (password + verify-email), then Connect. Provision attaches the vessel, turns on Premium, and bills the seat back to you. An unknown email returns [`account_requires_signup`](/errors/account_requires_signup) with a `signup_url`. ## Base path & authentication All partner endpoints live under `/v1/partners/` and authenticate with **HTTP Basic**, not the OAuth bearer flow the rest of the API uses. Send your partner `client_id` as the username and `client_secret` as the password: ```http POST /v1/partners/sponsorships HTTP/1.1 Host: api.digitalseaservice.com Authorization: Basic Content-Type: application/json ``` ```bash curl -u "$DSS_CLIENT_ID:$DSS_CLIENT_SECRET" \ https://api.digitalseaservice.com/v1/partners/sponsorships ``` The `client_secret` is a bearer-equivalent credential for your whole program — anyone holding it can provision and remove seats. Store it in a secret manager, never in source control, and rotate it with DSS if it leaks. Bad or missing Basic credentials return [`invalid_token` (401)](/errors/invalid_token); a suspended partner returns [`insufficient_scope` (403)](/errors/insufficient_scope). ## Endpoints | Method & path | Purpose | |--------------------------------------------|-----------------------------------------------------| | `POST /v1/partners/sponsorships` | [Provision a seat](/sponsorships/provision) | | `GET /v1/partners/sponsorships` | [List your roster](/sponsorships/reconciliation) | | `GET /v1/partners/sponsorships/{id}` | [Get one seat](/sponsorships/reconciliation#get-one-seat) | | `DELETE /v1/partners/sponsorships/{id}` | [Remove a seat](/sponsorships/remove) | | `POST /v1/partners/members/left` | [Member left (YWC)](/sponsorships/member-left) — Connect-billed seat close | | `POST /v1/partners/sso/tickets` | [Open DSS (YWC SSO)](/sponsorships/sso) — one-time auto-login URL | Every response is a **public projection** of a seat. Internal fields — such as the Stripe subscription-item id — never cross this boundary. ## Seat lifecycle A seat moves through a small set of states. You'll see these in the `status` field of every response. | Status | Meaning | Premium? | Occupies a seat? | |----------------------|---------------------------------------------------------------------------------------------|----------|------------------| | `active` | Provisioned. Entitlement granted; the seat is billed to you. | Yes | Yes | | `converted` | The member started paying for themselves (added their own card, e.g. at the end of the sponsorship term), so the seat leaves your subscription — you get a prorated credit — and the member keeps Premium. | Yes (self-paid) | No | | `cancelled` | You removed the seat, or it was removed for GDPR/admin reasons. | No | No | | `expired` | The per-member term elapsed with no self-pay card; the member was downgraded to Free. | No | No | `active` is the only status that **occupies your billable seat**. `converted`, `cancelled` and `expired` do not. ## Billing model - Each **active** seat is invoiced to the partner on a recurring basis, for as long as it stays active. - **Removing** a seat (or a member converting to self-pay) issues a **prorated credit** to the partner for the unused remainder of that seat's period. - Billing is **partner-facing only**. An unpaid partner invoice never downgrades a crew member before their own term ends — the member keeps Premium for the term you provisioned, and billing disputes are settled partner-to-DSS. - The per-member sponsored **term** (see `expires_at`) is independent of your invoice cadence: it starts when the seat activates and runs for the program's configured term length. Commercial terms — the per-seat rate, invoice cadence, and settlement terms — are covered in your program agreement, not in these docs. ## Idempotency Provisioning is **idempotent per email within your program**. Re-sending the same member never creates a second seat or a second bill — you get the existing open seat back with `200` instead of `201`. See [Provision a seat](/sponsorships/provision#idempotency) for the `Idempotency-Key` header and the exact replay semantics. ## Webhooks There are **no partner-subscribable webhook events for sponsorship seats** in V1. Reconcile seat state by polling [`GET /v1/partners/sponsorships`](/sponsorships/reconciliation) on a schedule. The webhook [events catalogue](/webhooks/events) covers the `user.*` events only, which are delivered against OAuth scopes and are unrelated to sponsorship billing. ## Related - [Provision a seat](/sponsorships/provision) - [Reconcile your roster](/sponsorships/reconciliation) - [Remove a seat](/sponsorships/remove) - [Member left (YWC)](/sponsorships/member-left) - [Open DSS (YWC SSO)](/sponsorships/sso) - [Errors overview](/errors) --- # Provision a seat Source: https://docs.digitalseaservice.com/sponsorships/provision **Restricted — requires a sponsorship program.** See the [Sponsorship API overview](/sponsorships/overview) for access and auth. ```http POST /v1/partners/sponsorships Authorization: Basic Content-Type: application/json ``` Attaches an **existing** DSS member to a sponsored Premium seat. DSS does **not** create a new account here. If the email is unknown, the call returns [`account_requires_signup` (409)](/errors/account_requires_signup) with `signup_url` — send the member through normal signup, then Connect. When the account already exists: attaches the vessel by IMO (optional), turns on Premium, and starts billing the seat to you. The member is `active` on success. ## Request body | Field | Type | Required | Notes | |---------------|-----------------|----------|--------------------------------------------------------------| | `email` | string | Yes | Crew member's email. Normalised (trimmed, lower-cased) and used as the idempotency key. | | `first_name` | string | Yes | Given name for the account. | | `last_name` | string | Yes | Family name for the account. | | `position` | string | Yes | Crew rank/role, e.g. `Chief Stewardess`. | | `vessel_imo` | string | No | Exactly 7 digits (`^\d{7}$`) when present. See [Members with no vessel](#members-with-no-vessel). | | `phone` | string | No | E.164, e.g. `+447700900123`. | | `vessel_name` | string | No | Display name; helps when the IMO is new to DSS. | | `join_date` | string (date-time) | No | ISO 8601. When the member joined the vessel. | | `external_ref`| string | No | Your own opaque reference. Echoed back on the roster for reconciliation. | ```json { "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "position": "Chief Stewardess", "vessel_imo": "9074729", "vessel_name": "MY Example", "phone": "+447700900123", "join_date": "2026-07-01T00:00:00Z", "external_ref": "ref-0042" } ``` ## Response `201 Created` when a new seat is provisioned; `200 OK` on an idempotent replay (an open seat already existed for this email — see below). | Field | Type | Notes | |------------------|-------------------|-----------------------------------------------------------------------| | `sponsorship_id` | string | The seat id. Use it with the [get](/sponsorships/reconciliation#get-one-seat) and [remove](/sponsorships/remove) endpoints. | | `status` | string | Seat [lifecycle status](/sponsorships/overview#seat-lifecycle) — typically `active`. | | `vessel_resolved`| boolean | `false` when the IMO didn't match a known vessel. This is a **soft failure**: the seat is still created and the member still gets Premium; tracking attaches once the vessel is resolved. | | `expires_at` | string \| null | The member's sponsored term end (`activated_at` + the program term). | ```json { "sponsorship_id": "665f00000000000000000042", "status": "active", "vessel_resolved": true, "expires_at": "2027-07-24T09:00:00Z" } ``` **`vessel_resolved: false` is not an error.** A 7-digit IMO that DSS hasn't seen yet still yields a provisioned, Premium seat. Pass `vessel_name` to help, and DSS resolves and back-fills tracking asynchronously. Poll the seat on the [roster](/sponsorships/reconciliation) if you want to confirm resolution. ## Idempotency Provisioning is idempotent **per email within your program**: - If the email already holds an **open seat** (`active`), the call returns that existing seat with `200` and does **not** create a second seat or a second bill. - Otherwise a new seat is created and returned with `201`. You may also send an optional `Idempotency-Key` header for your own tracing/retry safety: ```http Idempotency-Key: 5f3c...your-uuid... ``` The header is accepted for convenience, but **the email is the primary idempotency key** — an open seat for the same email is never duplicated, regardless of the header value. ## Members with no vessel `vessel_imo` is **optional**. A member who already has a DSS account but is between jobs, shoreside, or on a hull with no IMO still gets Premium — provision them without an IMO: ```json { "email": "alex@example.com", "first_name": "Alex", "last_name": "Crew", "position": "Deckhand" } ``` The seat comes back with `vessel_resolved: false` and no `vessel_id`. Everything else works normally: the member is Premium immediately and is billed to you from that moment. When they join a vessel, **re-submit the same email with the IMO**. That is treated as a vessel move — it attaches the vessel and starts tracking, and does **not** create a second seat or a second charge. `vessel_resolved: false` also comes back when an IMO is supplied that we cannot validate. We do not create a placeholder vessel for an IMO we could not confirm, because tracking a member against a hull that may not exist is worse than reporting the vessel as unresolved. Re-submit once the IMO is correct. Omitting `vessel_imo` is not the same as sending a wrong one. A malformed value is rejected with `422` rather than ignored — silently dropping a typo'd IMO would leave the member with no vessel and no signal that anything went wrong. ## Errors | Status | `type` | When | |--------|---------------------------------------------------------------|----------------------------------------------------------------------| | 400 / 422 | [`invalid_request`](/errors/invalid_request) | Missing/invalid field, e.g. `vessel_imo` present but not exactly 7 digits. | | 401 | [`invalid_token`](/errors/invalid_token) | Missing or bad Basic credentials. | | 403 | [`insufficient_scope`](/errors/insufficient_scope) | The partner account is suspended. | | 409 | [`account_requires_signup`](/errors/account_requires_signup) | No DSS account yet — redirect to `signup_url`. Do not create one yourself. | | 409 | [`email_already_premium`](/errors/email_already_premium) | The email already self-pays for Premium — nothing to sponsor. | | 409 | [`sponsorship_cap_reached`](/errors/sponsorship_cap_reached) | The program's seat cap is full. (Programs may be uncapped.) | ## Related - [Reconcile your roster](/sponsorships/reconciliation) - [Remove a seat](/sponsorships/remove) - [`email_already_premium`](/errors/email_already_premium) - [`sponsorship_cap_reached`](/errors/sponsorship_cap_reached) --- # Reconcile your roster Source: https://docs.digitalseaservice.com/sponsorships/reconciliation **Restricted — requires a sponsorship program.** See the [Sponsorship API overview](/sponsorships/overview) for access and auth. Two read endpoints let you reconcile your program against your own records: list the whole roster, or fetch one seat. Both are scoped to your partner — you only ever see your own seats. ## List seats ```http GET /v1/partners/sponsorships Authorization: Basic ``` Returns your seats, newest first, keyset-paginated. ### Query parameters | Parameter | Type | Default | Notes | |-----------|---------|---------|--------------------------------------------------------------------------| | `status` | string | — | Filter to one [lifecycle status](/sponsorships/overview#seat-lifecycle), e.g. `active`. | | `limit` | integer | `50` | Page size, `1`–`200`. | | `cursor` | string | — | Opaque cursor from a prior page's `next_cursor`. Omit for the first page. | ### Response | Field | Type | Notes | |---------------|-------------------|----------------------------------------------------------------| | `items` | array | The seats on this page (see fields below). | | `next_cursor` | string \| null | Pass back as `cursor` for the next page. `null` on the last page. | Each item: | Field | Type | Notes | |------------------|-------------------|---------------------------------------------------| | `sponsorship_id` | string | The seat id. | | `email` | string | Normalised member email. | | `crew_name` | string \| null | Member's display name, once known. | | `status` | string | [Lifecycle status](/sponsorships/overview#seat-lifecycle). | | `external_ref` | string \| null | The reference you supplied at provision time. | | `invited_at` | string \| null | When the seat record was created. | | `activated_at` | string \| null | When Premium was granted. | | `expires_at` | string \| null | The member's sponsored term end. | ```json { "items": [ { "sponsorship_id": "665f00000000000000000042", "email": "jane@example.com", "crew_name": "Jane Doe", "status": "active", "external_ref": "ref-0042", "invited_at": "2026-07-03T09:00:00Z", "activated_at": "2026-07-04T10:30:00Z", "expires_at": "2027-07-04T10:30:00Z" } ], "next_cursor": null } ``` ### Paging through the whole roster ```bash cursor="" while :; do page=$(curl -s -u "$DSS_CLIENT_ID:$DSS_CLIENT_SECRET" \ "https://api.digitalseaservice.com/v1/partners/sponsorships?limit=200&cursor=$cursor") echo "$page" | jq -c '.items[]' cursor=$(echo "$page" | jq -r '.next_cursor // empty') [ -z "$cursor" ] && break done ``` Filter by `status=active` to reconcile exactly the seats you're currently being billed for. Match rows to your own records on `external_ref`. ## Get one seat ```http GET /v1/partners/sponsorships/{sponsorship_id} Authorization: Basic ``` Returns a single seat in the same item shape as the list. A `sponsorship_id` that belongs to a different partner resolves to [`not_found` (404)](/errors/not_found), never `403` — we don't confirm the existence of another partner's seats. ```json { "sponsorship_id": "665f00000000000000000042", "email": "jane@example.com", "crew_name": "Jane Doe", "status": "active", "external_ref": "ref-0042", "invited_at": "2026-07-03T09:00:00Z", "activated_at": "2026-07-04T10:30:00Z", "expires_at": "2027-07-04T10:30:00Z" } ``` ## Errors | Status | `type` | When | |--------|----------------------------------------------------|-------------------------------------------------| | 401 | [`invalid_token`](/errors/invalid_token) | Missing or bad Basic credentials. | | 403 | [`insufficient_scope`](/errors/insufficient_scope) | The partner account is suspended. | | 404 | [`not_found`](/errors/not_found) | No such seat for this partner (get-one only). | ## Related - [Provision a seat](/sponsorships/provision) - [Remove a seat](/sponsorships/remove) - [Sponsorship API overview](/sponsorships/overview) --- # Remove a seat Source: https://docs.digitalseaservice.com/sponsorships/remove **Restricted — requires a sponsorship program.** See the [Sponsorship API overview](/sponsorships/overview) for access and auth. ```http DELETE /v1/partners/sponsorships/{sponsorship_id} Authorization: Basic ``` Removes a seat you no longer want to sponsor. Three things happen: 1. **The member is downgraded to Free immediately.** Their sponsored Premium entitlement ends as soon as the removal succeeds. 2. **You receive a prorated credit** for the unused remainder of that seat's billing period — the seat leaves your recurring subscription. 3. **Vessel tracking is paused** for the member's vessel, on a best-effort basis. If another active crew member still keeps that vessel tracked, it stays on. The seat ends in the `cancelled` [status](/sponsorships/overview#seat-lifecycle) and no longer counts toward your program's seat usage. Removal is a downgrade, not a deletion of history. The member keeps their account and their own data; they simply return to the Free tier. If you're removing a member for data-erasure reasons, contact DSS — GDPR erasure is a separate, stronger operation. ## Response `200 OK`. | Field | Type | Notes | |------------------|--------|------------------------------------------------| | `sponsorship_id` | string | The seat that was removed. | | `status` | string | `cancelled`. | ```json { "sponsorship_id": "665f00000000000000000042", "status": "cancelled" } ``` ## Scoping The seat is resolved under your partner before any teardown runs. A `sponsorship_id` that belongs to a different partner resolves to [`not_found` (404)](/errors/not_found) — you can't remove another partner's seat, and we don't confirm it exists. ## Errors | Status | `type` | When | |--------|----------------------------------------------------|-----------------------------------------------| | 401 | [`invalid_token`](/errors/invalid_token) | Missing or bad Basic credentials. | | 403 | [`insufficient_scope`](/errors/insufficient_scope) | The partner account is suspended. | | 404 | [`not_found`](/errors/not_found) | No such seat for this partner. | ## Related - [Provision a seat](/sponsorships/provision) - [Reconcile your roster](/sponsorships/reconciliation) - [Sponsorship API overview](/sponsorships/overview) --- # Member left (YWC) Source: https://docs.digitalseaservice.com/sponsorships/member-left **Yacht Workers Council only.** Other partners receive [`insufficient_scope` (403)](/errors/insufficient_scope). Auth is the same HTTP Basic as the rest of `/v1/partners/*`. ```http POST /v1/partners/members/left Authorization: Basic Content-Type: application/json ``` Call this when a member is no longer in YWC — they left, their membership lapsed, or they deleted their **YWC** account. DSS closes the billed Crew Premium seat (James stops paying from that UTC day) and drops sponsored Premium unless the member already pays for Crew Premium themselves. This is **not** a DSS account deletion. `reason: "account_deleted"` means the YWC account is gone, not the DSS user. Do **not** call the dashboard internal URL. One successful call is enough; retries are safe. ## Request | Field | Type | Required | Notes | |----------------|--------|----------|--------| | `user_id` | string | If no resolvable `external_ref` | DSS user id from Connect (Firebase uid). Preferred. | | `external_ref` | string | If no `user_id` | Your id, e.g. `ywc-user-123`. Used only when `user_id` is omitted; we look up the DSS user from a prior sponsorship row. | | `reason` | string | No | `membership_lapsed` or `account_deleted`. Both close the billed seat the same way. | ```json { "user_id": "aQ0mtT8kRgVv7C3o1p2q3r4s5t6u", "external_ref": "ywc-user-123", "reason": "membership_lapsed" } ``` ```bash curl -u "$DSS_CLIENT_ID:$DSS_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{"user_id":"","external_ref":"ywc-user-123","reason":"membership_lapsed"}' \ https://api.digitalseaservice.com/v1/partners/members/left ``` Sandbox: `https://api.dev.digitalseaservice.com/v1/partners/members/left`. ## Response `200 OK` — including when the seat was already closed. | Field | Type | Notes | |--------------|---------|--------| | `user_id` | string | DSS user we closed the seat for. | | `ended` | boolean | `true` when an open billed seat was closed this call. | | `downgraded` | boolean | `true` when sponsored Crew Premium was dropped. `false` if they already self-pay, or the seat was already closed. | ```json { "user_id": "aQ0mtT8kRgVv7C3o1p2q3r4s5t6u", "ended": true, "downgraded": true } ``` We also revoke that member's live YWC OAuth tokens so a later reconcile cannot re-open James's seat. ## Errors | Status | `type` | When | |--------|--------|------| | 400 | [`invalid_request`](/errors/invalid_request) | Neither `user_id` nor a resolvable `external_ref`. | | 401 | [`invalid_token`](/errors/invalid_token) | Missing or bad Basic credentials. | | 403 | [`insufficient_scope`](/errors/insufficient_scope) | Not Yacht Workers Council, or the partner is suspended. | | 422 | [`invalid_request`](/errors/invalid_request) | `reason` is present but not `membership_lapsed` or `account_deleted`. | | 503 | [`service_unavailable`](/errors/service_unavailable) | Dashboard member-left failed. Retry with backoff; the call is idempotent. | ## Related - [Sponsorship API overview](/sponsorships/overview) - [Remove a seat](/sponsorships/remove) — JIT sponsorship program seats, not Connect-billed YWC Premium --- # Open DSS (YWC SSO) Source: https://docs.digitalseaservice.com/sponsorships/sso **Yacht Workers Council only.** Other partners receive [`insufficient_scope` (403)](/errors/insufficient_scope). Auth is HTTP Basic, same as the rest of `/v1/partners/*`. ```http POST /v1/partners/sso/tickets Authorization: Basic Content-Type: application/json ``` Use this when a member is **already Connected** and taps DSS in your app — including the **DSS Homepage** button. Do **not** link that button to `https://digitalseaservice.com` (marketing site, no session). Do **not** put `user_id` on a public URL. Your **server** mints a ticket, then you 302 the browser to `login_url`. No vessel or crew list is required. The member opens the DSS app signed in and adds their vessel there. Empty sea-time on Refresh is a valid 200, not a reason to block Homepage. ## Set up a DSS account **Set up my DSS account** is normal DSS signup. Do **not** call `POST /v1/partners/sponsorships` on that click (that used to create a passwordless account and send “account is ready”). Send the browser to: ``` https://app.digitalseaservice.com/signin?intent=signup&partner=ywc&email={email}&first_name={first}&last_name={last} ``` They set a password, accept terms, and receive **Verify your email**. After they verify, run **Connect**. Premium is granted on Connect. They add a vessel inside the DSS app if they do not have one yet. Members who already have a DSS account should **Connect** or use Homepage SSO above — not signup. The ticket is opaque, hashed at rest, **single use**, and valid for **60 seconds**. A new mint for the same member invalidates any unused previous ticket. We only issue a ticket when that member has a **live YWC Connect consent**. ## Request | Field | Type | Required | Notes | |-----------|--------|----------|--------| | `user_id` | string | Yes | DSS user id from Connect (Firebase uid). | ```json { "user_id": "aQ0mtT8kRgVv7C3o1p2q3r4s5t6u" } ``` ```bash curl -u "$DSS_CLIENT_ID:$DSS_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{"user_id":""}' \ https://api.digitalseaservice.com/v1/partners/sso/tickets ``` ## Response `200 OK` | Field | Type | Notes | |--------------|---------|--------| | `login_url` | string | Send the member's browser here immediately. Host is `app.digitalseaservice.com`. | | `expires_in` | integer | Always `60`. | ```json { "login_url": "https://app.digitalseaservice.com/auth/partner?ticket=…", "expires_in": 60 } ``` Sandbox login URLs use the sandbox app host from that environment's sign-in URL. ## Errors | Status | `type` | When | |--------|--------|------| | 400 | [`invalid_request`](/errors/invalid_request) | Missing `user_id`. | | 401 | [`invalid_token`](/errors/invalid_token) | Missing or bad Basic credentials. | | 403 | [`insufficient_scope`](/errors/insufficient_scope) | Not YWC, partner suspended, or the member has no live Connect consent. | | 429 | [`rate_limit_exceeded`](/errors/rate_limit_exceeded) | Per-client budget exhausted. | ## Related - [Member left (YWC)](/sponsorships/member-left) - [Authentication](/authentication) --- # 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-09-12 — New members use normal DSS signup `POST /v1/partners/sponsorships` no longer creates a Digital Sea Service account. An unknown email returns [`account_requires_signup`](/errors/account_requires_signup) (409) with `signup_url`. Send **Set up my DSS account** to `/signin?intent=signup&partner=ywc&…`. Premium is granted on Connect. ## 2026-09-12 — Account exists is enough Additive / loosening. Partners that already handled `404` still work. A Connected member with a DSS account is a valid member even with **no vessel and no crew list**. They add vessels and discharge dates inside the DSS app. Do not treat an empty record as unreachable or incomplete. `GET /v1/me/sea-time` and `/recent` therefore return **200** with zero totals (and an empty `months` array) when the member exists but has no qualifying sea days yet. `GET /v1/me/vessels` returns **200** with an empty list. A true `404` means the token's user is unknown to DSS. The **DSS Homepage** button for an already-Connected YWC member must mint `POST /v1/partners/sso/tickets` and redirect to `login_url`. That opens the app signed in; with no fleet they land on add-vessel. Do not link `https://digitalseaservice.com`. ## 2026-09-09 — YWC partner SSO Additive. Nothing existing changed shape. ### Endpoints - `POST /v1/partners/sso/tickets` — Yacht Workers Council only. Mint a 60-second, single-use login URL so an already-Connected member opens the DSS app signed in. HTTP Basic. Body: `{ "user_id": "" }`. Requires live Connect consent. See [Open DSS (YWC SSO)](/sponsorships/sso). --- ## 2026-09-09 — YWC member-left Additive. Nothing existing changed shape. ### Endpoints - `POST /v1/partners/members/left` — Yacht Workers Council only. Close the Connect-billed Crew Premium seat when a member leaves, lapses, or deletes their YWC account. HTTP Basic (`client_id:client_secret`). Body: `user_id` (DSS Firebase uid from Connect), optional `external_ref` (`ywc-user-{id}`), optional `reason` (`account_deleted` \| `membership_lapsed`). Idempotent. See [Member left (YWC)](/sponsorships/member-left). --- ## 2026-08-29 — Membership status on `/v1/me` Additive. Nothing existing changed shape. ### Endpoints - `GET /v1/me` now returns a **`membership`** block — `tier`, `source` (`self` / `sponsored` / `vessel`), `since`, plus `expires_at` and `sponsored_by_you` when you are the member's sponsor. See [Membership status](/membership). - It rides on the existing `profile:read` scope. **No reconsent needed** — tokens you already hold can read it. Until now there was no way to tell whether a crew member already held DSS Premium, so partners mirroring DSS content had no signal to gate an upgrade prompt on and showed it to everyone, including members they were sponsoring themselves. `/v1/me` now depends on a downstream membership lookup, so it can return [`service_unavailable` (503)](/errors/service_unavailable) where it previously could not. Treat that as *unknown*, not as `"free"` — retry with backoff and leave any upgrade prompt hidden. ### Errors - Documented [`service_unavailable` (503)](/errors/service_unavailable). The type is not new — `/v1/me/sea-time`, `/v1/me/sea-time/recent` and `/v1/me/vessels` have always been able to return it — but it was missing from this catalog. - Added [`entitlement_write_failed` (502)](/errors/entitlement_write_failed) to the partner Sponsorship API. Returned when we cannot grant Premium to a crew member; the reserved seat is credited back before we respond, so nothing is billed and the request is safe to retry. --- ## 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. ---