Errors
Every non-2xx response uses the RFC 9457 Problem Details envelope, served as Content-Type: application/problem+json.
The envelope
{
"type": "https://api.valueverde.de/errors/cooperative-not-found",
"title": "Cooperative not found",
"status": 404,
"detail": "cooperative 11111111-1111-4111-8111-111111111111 not found",
"instance": "/cooperatives/11111111-1111-4111-8111-111111111111",
"code": "COOPERATIVE_NOT_FOUND",
"trace_id": "6d3a9b1e-8c2f-4a7d-9e5b-1f3a2c4d5e6f"
}
| Field | Type | Notes |
|---|---|---|
type | URI | Stable identifier for the problem class. The path after /errors/ is the lowercase-kebab form of code. |
title | string | Short human-readable summary. May change wording across versions — do not branch on this. |
status | integer | Mirrors the HTTP status. |
detail | string | Occurrence-specific detail (offending id, the field that failed, etc.). |
instance | URI | The request path that produced the error. |
code | string | Stable machine-readable error code. Branch on this. |
trace_id | string | Mirrors the X-Request-Id response header. Quote it in support tickets. |
errors | object | Per-field validation detail — only present on 422 VALIDATION_ERROR. |
required_scopes | array | Scopes the endpoint required and your token lacked — only present on 403 INSUFFICIENT_SCOPE. |
Error codes by status
The codes below are the ones reachable on the public partner endpoints under /v1. The platform returns additional codes on internal endpoints — those are not part of the partner contract.
400 Bad request
code | When | Resolution |
|---|---|---|
VALIDATION_ERROR | Unknown sort, dividend_type, or affiliation token; non-numeric min_share_price / max_share_price. | Use the documented enum values; check the pagination page for sort tokens. |
The token endpoint is the one exception to this list: POST /oauth2/token answers with the standard OAuth2 error object (error, error_description), not Problem Details.
error | When | Resolution |
|---|---|---|
invalid_scope | You requested a scope your client was not granted. The whole request fails — you get no token, not a narrower one. | error_description names both the refused scope and the ones your client holds. Request a subset, or omit scope to receive all of them. |
invalid_client | client_id / client_secret rejected (HTTP 401). | Verify the credential pair. |
unsupported_grant_type | Anything other than client_credentials. | This API supports only client_credentials. |
401 Unauthorized
code | When | Resolution |
|---|---|---|
(no code) | Missing or invalid Authorization: Bearer <token> on a resource endpoint. Returned by the OAuth2 resource server filter; envelope follows the OAuth2 WWW-Authenticate convention. | Mint a fresh access token. |
403 Forbidden
Two different failures share this status, and the code is what separates them. Branch on it: one is fixable by minting a different token, the other is not.
code | When | Resolution |
|---|---|---|
INSUFFICIENT_SCOPE | The token is valid but lacks a scope the endpoint requires. required_scopes names exactly what is missing, and the response carries WWW-Authenticate: Bearer error="insufficient_scope", scope="…". | Re-mint with that scope. If your client was never granted it, ask partner support to widen the client — your credentials are not rotated by a scope change. |
FORBIDDEN | Not a scope problem: the caller is not permitted to perform this operation at all (e.g. a non-partner token on a partner endpoint). No required_scopes, no WWW-Authenticate. | Adding scopes will not help. Check you are calling with a partner client_credentials token. |
{
"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": "..."
}
Use GET /v1/me to see what your client is allowed to request before guessing.
404 Not found
The investing endpoints collapse "doesn't exist" and "isn't yours" into a single 404 — acting on an investor or order another partner owns is indistinguishable from one that never existed (no cross-tenant existence oracle).
code | When | Resolution |
|---|---|---|
COOPERATIVE_NOT_FOUND | The id is unknown, or the cooperative is unpublished. | Verify the id; unpublished cooperatives never surface to partners. |
MANAGED_INVESTOR_NOT_FOUND | The investor id is unknown, or belongs to a different partner. | Verify investor_id; provision the investor first if you haven't. |
SHARE_PURCHASE_NOT_FOUND | The purchase id is unknown, belongs to a different investor, or sits under an investor you don't own. | Verify the investorId / purchaseId pair. |
409 Conflict
Preconditions and state-machine violations on the investing write path. None are retryable as-is — fix the precondition first.
code | When | Resolution |
|---|---|---|
INVESTOR_PROFILE_INCOMPLETE | Placing a purchase before the investor's profile is complete. | Read missing_profile_fields from GET /v1/investors/{investorId} — it names exactly what is outstanding — then PUT …/profile and retry. |
PURCHASE_DEFAULTS_UNAVAILABLE | A value the platform derives could not be derived — no sepa_mandate.debtor_iban sent and no banking details on the profile, or the profile records no terms/privacy consent to carry over. | detail names exactly which default was missing. Fix the profile, or send the field explicitly. |
SHARE_PURCHASE_ILLEGAL_STATE | Cancelling an order that is already settled or decided. | The order is terminal; no action will change it. |
IDEMPOTENCY_KEY_CONFLICT | An Idempotency-Key was reused with a different request body — including for a different investor, or after the investor's company affiliation changed (which changes the derived applicant_type). | Use a fresh key for a new purchase, or resend the original body verbatim to replay it. |
MANAGED_INVESTOR_PROFILE_CONFLICT | POST /v1/investors re-used an existing partner_customer_ref with a different inline profile. | Use PUT /v1/investors/{investorId}/profile to change a profile; a create endpoint will not overwrite one. |
422 Unprocessable entity
Returned for request-body validation failures on the write endpoints (POST /v1/investors, PUT …/profile, POST …/share-purchases). Bean-level checks (max length, email shape, required consent timestamps) and domain value-object checks (IBAN checksum, BIC format, birthdate plausibility) both surface here.
code | When | Resolution |
|---|---|---|
VALIDATION_ERROR | A field failed a shape or domain check, or a query parameter was not a recognised value — an unrecognised expand token is rejected here rather than silently ignored. | Read errors when present; a parameter-level rejection carries no errors map, only the generic detail. |
REPRESENTATION_CONSENT_REQUIRES_COMPANY | consents.representation_authorization.given: true on a profile that carries no company block. | Send { "given": false }, or omit it, unless the company section is present. See below. |
representation_authorization is the odd one out
The profile write takes four consents under consents. Three of them — terms, privacy, data_sharing — are required and must each be { "given": true }.
representation_authorization is different. It records that someone is authorised to act for a company, so given: true is only valid when the profile carries a company block. For a private individual — the majority case — send { "given": false }, or omit the member entirely.
{
"code": "REPRESENTATION_CONSENT_REQUIRES_COMPANY",
"title": "Representation authorization can only be granted alongside a company affiliation.",
"status": 422,
"errors": {
"profile.consents.representation_authorization.given": "must not be true unless a company section is present"
},
"trace_id": "..."
}
Error keys are paths
Every key in errors is the path you sent, not a bare field name —
profile.address.city, profile.bank_account.iban,
profile.consents.terms.given. That holds whichever layer rejected the value:
a shape check, a section's all-or-nothing rule, and a domain check such as an
IBAN checksum all report against the same root, so you can key your error
handling off one convention.
On POST /v1/investors and PUT /v1/investors/{investorId}/profile the paths
are relative to the request body, which wraps the profile in profile. Elsewhere
they are relative to the body's own root — for example statute_consent_given_at
on a share purchase.
The body includes per-field detail:
{
"type": "https://api.valueverde.de/errors/validation-error",
"title": "Validation error",
"status": 422,
"detail": "Request body failed validation.",
"instance": "/...",
"code": "VALIDATION_ERROR",
"errors": {
"profile.bank_account.iban": "must be a valid IBAN",
"share_count": "must be at least 1"
},
"trace_id": "..."
}
429 Too many requests
The only 4xx on this page that is retryable as sent — nothing about the
request is wrong, there was just too much of it.
code | When | Resolution |
|---|---|---|
RATE_LIMITED | You exceeded the throttle on /v1 (600 requests a minute per credential) or on POST /oauth2/token (20 a minute). | Sleep for Retry-After seconds and send the same request again. If you are hitting it on the token endpoint, cache your access token instead of minting one per call. See Rate limits. |
Every /v1 response — not just this one — carries RateLimit-Limit,
RateLimit-Remaining and RateLimit-Reset, so you can slow down before you get
here. The refusal adds Retry-After.
5xx — server errors
code | When | Resolution |
|---|---|---|
| (none — generic 500) | Unhandled server error. | Retry with exponential backoff (1s, 2s, 4s, max 3 attempts). Include trace_id if you need to escalate. |
Retry strategy
| Status | Retry? |
|---|---|
4xx | ❌ Fix the request. (A 409 IDEMPOTENCY_KEY_CONFLICT specifically means you reused a key with a different body — don't retry that body under that key.) |
429 | ✅ The exception among 4xx. Sleep for Retry-After, then resend unchanged. Do not treat it as a 5xx and back off exponentially past the reset — you would be idle while your budget refills. |
5xx | ✅ Exponential backoff with jitter, capped at 3 attempts. |
| Network timeout | ✅ Same as 5xx. Reads are idempotent. For POST …/share-purchases, always send an Idempotency-Key so a timed-out retry replays the original order instead of placing a second one; provisioning is idempotent on partner_customer_ref. |
Branch on code, not on title
Stable contract:
if problem["code"] == "COOPERATIVE_NOT_FOUND":
handle_not_found(problem["detail"])
Fragile contract:
if problem["title"] == "Cooperative not found": # don't — wording may change
...
type and code are equivalently stable; pick one. The kebab-case slug after /errors/ in type corresponds to the lowercase form of code.