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
| 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
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
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
| 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.
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:
- Every access and refresh token issued to your client for that user is
revoked immediately. Subsequent calls return
invalid_token → 401.
- A signed
user.consent.revoked webhook fires to your registered webhook
URL — see Webhooks.
- 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
— shape is { "error": "...", "error_description": "..." }, not RFC 7807
Problem Details. Resource endpoint errors do use Problem Details — see
Errors.