The DSS Public API uses OAuth 2.0 Authorization Code with PKCE. 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

MethodPathAuthPurpose
GET/oauth/authorizeDSS user sessionRender the consent screen
POST/oauth/authorizeDSS user sessionRecord consent, issue authorization code
POST/oauth/tokenclient_secretExchange code or refresh for tokens
POST/oauth/revokeclient_secretRFC 7009 token revocation
GET/oauth/userinfobearerOIDC-style basic profile

The flow at a glance

Scopes

ScopeGrants
profile:readName, role, country, photo URL
seatime:readAggregate sea-time totals and 12-month trend
vessels:readVessel 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_challengebase64url(sha256(code_verifier)), sent on the authorize call.
We require code_challenge_method=S256. plain is rejected.

Generating a PKCE pair

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")
The reference vectors come from RFC 7636 §4.4. 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

ParameterRequiredNotes
response_typeyesAlways code.
client_idyesThe client_id we issued.
redirect_uriyesMust exactly match one of the URIs you registered. HTTPS required outside http://localhost*.
scopeyesSpace-separated. Subset of your allowed scopes.
stateyesAnti-CSRF token. We round-trip it unchanged. Validate on callback.
code_challengeyesFrom PKCE step.
code_challenge_methodyesAlways 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.
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"

Response

{
  "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

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.

Refreshing tokens

Access tokens live for 1 hour. Refresh them with grant_type=refresh_token to get a new pair.
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"

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. Revoking one revokes the paired other.
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"
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_token401.
  2. A signed user.consent.revoked webhook fires to your registered webhook URL — see Webhooks.
  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 errorWhen you’ll see it
invalid_requestMissing or malformed parameter (e.g. no code_challenge).
invalid_clientWrong client_id / client_secret pair.
invalid_grantExpired code, replayed code, wrong redirect_uri, mismatched PKCE verifier, or revoked refresh.
unsupported_grant_typeAnything other than authorization_code or refresh_token.
invalid_scopeRequested scope isn’t in your allowed set.
access_deniedThe user clicked Deny on the consent screen.
OAuth errors follow RFC 6749 §5.2 — shape is { "error": "...", "error_description": "..." }, not RFC 7807 Problem Details. Resource endpoint errors do use Problem Details — see Errors.