Skip to main content

Authentication

The Partner API uses OAuth2 client_credentials exclusively. Each partner organisation holds a client_id and client_secret that mint short-lived RS256-signed access tokens.

Two environments, one contract

Examples use production (https://api.valueverde.de). Substitute https://api.staging.valueverde.de to build and test without moving money — the request is otherwise identical.

Credentials do not cross environments: a staging client_id will not authenticate against production.

Endpoints

EndpointPurpose
POST /oauth2/tokenExchange credentials for an access token.
GET /v1/meWhat this credential is and what it may do.
GET /.well-known/jwks.jsonPublic keys for verifying token signatures.
GET /.well-known/oauth-authorization-serverDiscovery document listing endpoints, supported algorithms, and grant types.

The discovery document advertises the JWKS as /oauth2/jwks. That and /.well-known/jwks.json are the same key set served at two paths — either is fine, and both stay in place.

Token request

Content-Type: application/x-www-form-urlencoded. Send credentials via HTTP Basic auth (recommended) or as form fields:

curl -sS -X POST "https://api.valueverde.de/oauth2/token" \
-u "$VV_CLIENT_ID:$VV_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&scope=cooperatives:read"

Response:

{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "cooperatives:read"
}

Send the token on every API call:

Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

Scopes

You request scopes at token-mint time, space-delimited, e.g. scope=cooperatives:read projects:read investors:write applications:write portfolio:read.

Omit scope and you receive every scope your client holds. That is the simplest correct request, and the one to reach for when you do not want to track the granted set in your own configuration:

curl -sS -X POST "https://api.valueverde.de/oauth2/token" \
-u "$VV_CLIENT_ID:$VV_CLIENT_SECRET" \
-d "grant_type=client_credentials"

The response's scope field then tells you what you got. Request a narrower set explicitly if you want least privilege per worker.

Requesting a scope you were not granted fails the whole token request

Extra scopes are not silently dropped. If the requested set is not a subset of what your client holds, the token endpoint returns 400 invalid_scope and you get no token at all — not a token with fewer scopes. The error names both the scope that was refused and the ones your client does hold:

{
"error": "invalid_scope",
"error_description": "This client was not granted investors:write. It holds applications:read, cooperatives:read. Request a subset of those, or omit scope entirely to receive all of them."
}
ScopeGrants access to
cooperatives:readGET /v1/cooperatives, and GET /v1/cooperatives/{id} together with projects:read
projects:readGET /v1/cooperatives/{id} — required alongside cooperatives:read, including for ?expand=projects
investors:readGET /v1/investors/{investorId}
investors:writePOST /v1/investors, PUT /v1/investors/{investorId}/profile
applications:readGET /v1/investors/{investorId}/share-purchases, GET …/share-purchases/{purchaseId}
applications:writePOST /v1/investors/{investorId}/share-purchases, POST …/{purchaseId}/cancel
portfolio:readGET /v1/investors/{investorId}/holdings

GET /v1/me needs no scope at all.

Endpoints declare their required scope in the OpenAPI spec under each operation's security block. If your token is missing one, you get a 403 that names it — code: INSUFFICIENT_SCOPE, a required_scopes array, and an RFC 6750 challenge header:

HTTP/2 403
content-type: application/problem+json
www-authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", scope="investors:write"
{
"type": "https://api.valueverde.de/errors/insufficient-scope",
"title": "Insufficient scope",
"status": 403,
"detail": "This token is missing the investors:write scope. Request it when minting the token; if the client was never granted it, contact partner support.",
"instance": "/v1/investors",
"code": "INSUFFICIENT_SCOPE",
"required_scopes": ["investors:write"],
"trace_id": "..."
}

A 403 carrying code: FORBIDDEN instead is a different failure — an ownership or activation problem — and no amount of scope will fix it.

Discovering what your credential can do

GET /v1/me answers it in one call. It requires no scope, so it works with the very first token you mint:

curl -sS -H "Authorization: Bearer $TOKEN" "https://api.valueverde.de/v1/me"
{
"client_id": "01a00233-0335-7ff4-b25f-8837fec2f7f6",
"organization_id": "01a00233-0330-74f3-9ad2-f56f49c2c940",
"token_scopes": ["cooperatives:read"],
"granted_scopes": ["applications:read", "applications:write", "cooperatives:read", "portfolio:read", "projects:read"]
}

granted_scopes is what you may ask for at the token endpoint; token_scopes is what the token in your hand actually carries. They differ only when you requested a narrower set.

If you need a scope your client doesn't hold, contact partner support — we widen the existing client in place, so your client_id and client_secret keep working and there is nothing for you to rotate.

Token lifecycle

PropertyValue
AlgorithmRS256
TTL15 minutes (expires_in: 900)
RefreshNot applicable — re-mint with credentials
Audience (aud)valueverde-clients
Issuer (iss)https://api.valueverde.de
kid rotationAnnounced via partner support, with overlap window. JWKS exposes both keys during rotation.

There is no refresh token in the client_credentials flow. When the access token expires, mint a new one with the same credentials.

Token caching

Cache the token in your process for slightly less than expires_in (we suggest re-minting at 60 seconds remaining to absorb clock skew). Hammering /oauth2/token for every API call wastes both your budget and ours.

Verifying tokens (optional)

Most partners pass the token through to us and let our resource server reject bad ones. If you choose to verify locally — recommended for high-throughput pipelines — use the JWKS:

  1. Fetch https://api.valueverde.de/.well-known/jwks.json (cache for an hour, refresh on kid mismatch).
  2. Pick the JWK matching the token's kid.
  3. Verify the RS256 signature.
  4. Check iss == https://api.valueverde.de, aud contains valueverde-clients, and exp is in the future.

The token also carries:

  • client_id — your client_id.
  • org_id — the organisation id you act on behalf of (UUID).
  • token_type: "client" — distinguishes client_credentials tokens from end-user tokens at the resource server.
  • scope — space-delimited scopes the token actually carries.

Rotating credentials

Adding or removing a scope is not a rotation — we edit the existing client, and your credentials keep working. Ask for a scope change and nothing in your deployment needs to move.

To rotate client_secret:

  1. Email partners@valueverde.de requesting a rotation.
  2. We provision a second active secret for the same client_id.
  3. Roll the new secret into your deployments.
  4. Confirm traffic on the new secret, then ask us to revoke the old one.

If a secret may be compromised, contact us immediately — revocation is minutes, not hours. Provide the first 8 characters only (so we can confirm we revoke the right one); never send the full secret.

Failure responses

All authentication failures use the Problem Details envelope.

StatusMeaning
400 from /oauth2/tokenMalformed token request, unsupported grant_type, or a scope your client was not granted (invalid_scope, with the offending scope named in error_description).
401 from /oauth2/tokenclient_id / client_secret rejected.
401 from a resource endpointMissing, expired, or signature-invalid token.
403 INSUFFICIENT_SCOPE from a resource endpointToken lacks a scope the endpoint requires; required_scopes names it.
403 FORBIDDEN from a resource endpointNot a scope problem — see the error reference.