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