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.
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
| Endpoint | Purpose |
|---|---|
POST /oauth2/token | Exchange credentials for an access token. |
GET /v1/me | What this credential is and what it may do. |
GET /.well-known/jwks.json | Public keys for verifying token signatures. |
GET /.well-known/oauth-authorization-server | Discovery 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.
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."
}
| Scope | Grants access to |
|---|---|
cooperatives:read | GET /v1/cooperatives, and GET /v1/cooperatives/{id} together with projects:read |
projects:read | GET /v1/cooperatives/{id} — required alongside cooperatives:read, including for ?expand=projects |
investors:read | GET /v1/investors/{investorId} |
investors:write | POST /v1/investors, PUT /v1/investors/{investorId}/profile |
applications:read | GET /v1/investors/{investorId}/share-purchases, GET …/share-purchases/{purchaseId} |
applications:write | POST /v1/investors/{investorId}/share-purchases, POST …/{purchaseId}/cancel |
portfolio:read | GET /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
| Property | Value |
|---|---|
| Algorithm | RS256 |
| TTL | 15 minutes (expires_in: 900) |
| Refresh | Not applicable — re-mint with credentials |
Audience (aud) | valueverde-clients |
Issuer (iss) | https://api.valueverde.de |
kid rotation | Announced 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:
- Fetch
https://api.valueverde.de/.well-known/jwks.json(cache for an hour, refresh onkidmismatch). - Pick the JWK matching the token's
kid. - Verify the RS256 signature.
- Check
iss == https://api.valueverde.de,audcontainsvalueverde-clients, andexpis in the future.
The token also carries:
client_id— yourclient_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:
- Email partners@valueverde.de requesting a rotation.
- We provision a second active secret for the same
client_id. - Roll the new secret into your deployments.
- 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.
| Status | Meaning |
|---|---|
400 from /oauth2/token | Malformed 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/token | client_id / client_secret rejected. |
401 from a resource endpoint | Missing, expired, or signature-invalid token. |
403 INSUFFICIENT_SCOPE from a resource endpoint | Token lacks a scope the endpoint requires; required_scopes names it. |
403 FORBIDDEN from a resource endpoint | Not a scope problem — see the error reference. |