# valueverde Partner API — complete documentation
> Programmatic access to the valueverde platform for integration partners. Browse the published cooperative catalogue, provision the investors you act on behalf of, place share-purchase orders, and read the resulting portfolio. Authenticated via OAuth2 client_credentials; errors follow RFC 9457 Problem Details with stable `code` and `trace_id` fields.
Every guide from https://docs.valueverde.de, concatenated. The machine-readable contract is published separately at https://docs.valueverde.de/spec/partner-api.yaml.
---
# Introduction
Source: https://docs.valueverde.de/docs/intro
# The valueverde Partner API
You bring the customer. We hold the cooperative catalogue, the order book, and
the money movement. This API is the seam between the two.
A partner integration does three things:
1. **Shows** its users the cooperatives they can invest in.
2. **Registers** each of those users with us once, as a *managed investor*.
3. **Places** share-purchase orders on their behalf, and follows what happens next.
Everything else — pricing, settlement, share certificates, the cooperative
relationship — happens on our side. You never compute an amount, and there is no
field anywhere in this API that lets you set one.
## The whole flow, once
This is the entire integration. Every arrow is one HTTP call, and nothing else
is required to go from credentials to a settled holding.
```mermaid
sequenceDiagram
autonumber
participant You as Your backend
participant VV as valueverde API
participant Ops as valueverde operations
Note over You,VV: Once per 15 minutes
You->>VV: POST /oauth2/token (client_credentials)
VV-->>You: access_token
Note over You,VV: Browsing — as often as you like
You->>VV: GET /v1/cooperatives
VV-->>You: page of cooperatives
You->>VV: GET /v1/cooperatives/{id}
VV-->>You: detail + projects
Note over You,VV: Once per customer
You->>VV: POST /v1/investors (your ref + profile)
VV-->>You: investor_id, is_profile_complete
Note over You,VV: Once per order
You->>VV: POST /v1/investors/{id}/share-purchases
VV-->>You: 201 — status submitted, terms frozen
Note over VV,Ops: Days, not seconds
Ops->>VV: approve, then settle
VV--)You: webhook share_purchase.approved
VV--)You: webhook share_purchase.settled
You->>VV: GET /v1/investors/{id}/holdings
VV-->>You: the position, now real
```
Two things in that picture are worth pausing on.
**The order does not settle in the request.** `POST …/share-purchases` returns
`201` with the order `submitted`. Approval and settlement happen afterwards, on
our side, over days. Your UI has to be able to say "in progress", and your
backend has to have somewhere to put the news when it arrives — a webhook
endpoint, or a poller.
**The investor is created once and reused.** `POST /v1/investors` is keyed on
your own customer reference and is idempotent on it. You are not creating a
session or a cart; you are registering a person with us, permanently.
## Where to start
| If you want to… | Read |
|---|---|
| Have something working in 20 minutes | [Quickstart](./quickstart) — copy-pasteable, end to end |
| Understand the investing flow properly | [The investing flow](./investing-flow) — states, gates, and what each call needs |
| Know what a token is and how long it lasts | [Authentication](./authentication) |
| Receive order updates instead of polling | [Webhooks](./webhooks) |
| Handle failures well | [Errors](./error-reference) |
| Look up an exact field | [API reference](./api/valueverde-partner-api) |
## Before you begin
You need a `client_id` and `client_secret` issued by valueverde Partner Support.
Email [partners@valueverde.de](mailto:partners@valueverde.de) to request them,
and say which of the two integration shapes you need:
- **Catalogue only** — you display cooperatives and hand users off to us.
Scopes: `cooperatives:read`, `projects:read`.
- **Full investing** — you also register investors and place orders.
Adds: `investors:read`, `investors:write`, `applications:read`,
`applications:write`, `portfolio:read`.
The API speaks OAuth2 `client_credentials` over HTTPS. There is no API-key
fallback and no end-user login: your backend authenticates as itself and acts on
behalf of investors it has registered. Every partner endpoint lives under `/v1`.
## Base URLs
| Environment | Base URL | Use it for |
|---|---|---|
| **Production** | `https://api.valueverde.de` | Live traffic. Real orders, real money, real cooperatives. |
| **Staging** | `https://api.staging.valueverde.de` | Building and testing. Same contract, same shapes, no money moves. |
Both serve the full `/v1` contract. Build against staging, then change the host
— nothing else about a request differs between the two.
Credentials are per-environment: a staging `client_id` will not authenticate
against production, and vice versa.
## Conventions
These hold everywhere, so they are stated once here rather than repeated on
every endpoint.
- **JSON is `snake_case`**, in both directions.
- **Money is a decimal string**, never a JSON number — `"100.00"`, with a
separate `*_currency` field (ISO-4217). Parse it into a decimal type. A JSON
number would reach most clients as an IEEE-754 double, which is both inexact
and lossy about scale (`100.00` comes back as `100`). Percentages, distances
and tonnages are ordinary numbers; only amounts of money are strings.
- **Timestamps are ISO-8601 with an offset.** Dates that are calendar days
(`birth_date`) are plain `yyyy-MM-dd`.
- **IDs are UUIDs**, and the name of an id is the same everywhere it appears:
an investor is `investor_id`, an order is `share_purchase_id`.
- **Lists are paged envelopes**, never bare arrays: `items` plus `page`, `size`,
`total_items`, `total_pages`, `has_next`, `has_previous`. `page` is 1-based.
- **Errors are RFC 9457 problem documents** with a stable machine-readable
`code` and a `trace_id` to quote at us.
- **Unknown request fields are rejected**, not ignored. A typo is a `400`, not a
silent misbooking.
---
# Quickstart
Source: https://docs.valueverde.de/docs/quickstart
# Quickstart
Eight calls, in order, from a `client_id` to a submitted order. Every step
captures what the next one needs, so you can paste the whole page into a shell
and watch it run.
Run it against **staging** — the same contract as production, without moving
real money. When it works, change `VV_HOST` to `https://api.valueverde.de` and
swap in your production credentials; nothing else about these calls changes.
```bash
export VV_HOST="https://api.staging.valueverde.de"
export VV_CLIENT_ID="…" # your staging credentials, from partner support
export VV_CLIENT_SECRET="…"
```
:::tip
Every example uses `jq` to pull the one field the next step needs. If you do not
have it, read the value out of the response by hand — the field names are the
same.
:::
## 1. Get a token
```bash
export VV_TOKEN=$(curl -sS -X POST "$VV_HOST/oauth2/token" \
-u "$VV_CLIENT_ID:$VV_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" | jq -r .access_token)
echo "${VV_TOKEN:0:24}…"
```
The token lasts **15 minutes**. Cache it and re-mint on expiry; do not fetch one
per request. Details in [Authentication](./authentication).
## 2. Check what your credential can do
```bash
curl -sS "$VV_HOST/v1/me" -H "Authorization: Bearer $VV_TOKEN"
```
```json
{
"client_id": "01a00233-0335-7ff4-b25f-8837fec2f7f6",
"organization_id": "01a00233-0330-74f3-9ad2-f56f49c2c940",
"token_scopes": ["cooperatives:read", "projects:read", "investors:read",
"investors:write", "applications:read", "applications:write",
"portfolio:read"],
"granted_scopes": ["cooperatives:read", "projects:read", "investors:read",
"investors:write", "applications:read", "applications:write",
"portfolio:read"]
}
```
This endpoint needs no scope, so it works with your very first token. If
`granted_scopes` is missing the `investors:*` entries, stop here — steps 4
onwards will `403`, and partner support has to widen the grant.
## 3. Find a cooperative
```bash
export VV_COOP_ID=$(curl -sS "$VV_HOST/v1/cooperatives?page=1&size=1" \
-H "Authorization: Bearer $VV_TOKEN" | jq -r '.items[0].id')
curl -sS "$VV_HOST/v1/cooperatives/$VV_COOP_ID?expand=projects" \
-H "Authorization: Bearer $VV_TOKEN" | jq '{id, name, share_price, minimum_shares}'
```
```json
{
"id": "11111111-1111-4111-8111-111111111111",
"name": "Buergerenergie Musterhausen eG",
"share_price": 100,
"minimum_shares": 1
}
```
`share_price` and `minimum_shares` are what you show the user. You never send an
amount back — see [step 6](#6-place-the-order).
## 4. Register the investor
One call creates the person and fills in their profile. Everything the platform
can derive, it derives.
```bash
export VV_INVESTOR_ID=$(curl -sS -X POST "$VV_HOST/v1/investors" \
-H "Authorization: Bearer $VV_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"partner_customer_ref": "your-customer-4711",
"email": "maria@example.org",
"profile": {
"first_name": "Maria",
"last_name": "Santos",
"birth_date": "1985-03-15",
"address": {
"street": "Hauptstrasse",
"house_number": "1",
"postal_code": "10115",
"city": "Berlin"
},
"bank_account": { "iban": "DE89370400440532013000" },
"consents": {
"terms": { "given": true },
"privacy": { "given": true },
"data_sharing": { "given": true }
}
}
}' | jq -r .investor_id)
echo "$VV_INVESTOR_ID"
```
That is the whole minimum. Note what is **not** in it:
- No `bic`, no `bank_account.institution` — derived from the IBAN.
- No `bank_account.account_holder` — defaults to the profile name.
- No `email` inside `profile` — the top-level one is the investor's address.
Sending it in both places is a `422`.
- No `given_at` on any consent — the platform stamps them on receipt. If you
captured the consent earlier, in your own UI, send the instant you recorded:
`"terms": { "given": true, "given_at": "2026-08-14T09:12:04Z" }`. That is the
better call for a regulated record, and the reason each consent is an object
rather than a bare boolean.
`address`, `bank_account` and `company` are each **all-or-nothing**. Omit a
section entirely to leave it unset and the profile in `DRAFT`; send it and every
field it names is required, except inside `bank_account`, where only `iban` is.
`consents` is different — it is always required, and §7 of the
[investing flow](./investing-flow.md) explains why omitting it is destructive.
`partner_customer_ref` is **your** identifier for this person. Re-posting the
same ref with the same details replays the existing investor (`200`) instead of
creating a second one, so a retried call is safe.
## 5. Confirm the profile is complete
```bash
curl -sS "$VV_HOST/v1/investors/$VV_INVESTOR_ID" \
-H "Authorization: Bearer $VV_TOKEN" | jq '{is_profile_complete, missing_profile_fields}'
```
```json
{
"is_profile_complete": true,
"missing_profile_fields": []
}
```
If it is `false`, `missing_profile_fields` names exactly what is still needed —
send those through `PUT /v1/investors/{investorId}/profile` and check again. An
order placed against an incomplete profile is refused with `409
INVESTOR_PROFILE_INCOMPLETE`.
## 6. Place the order
```bash
export VV_PURCHASE_ID=$(curl -sS -X POST \
"$VV_HOST/v1/investors/$VV_INVESTOR_ID/share-purchases" \
-H "Authorization: Bearer $VV_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{
\"cooperative_id\": \"$VV_COOP_ID\",
\"share_count\": 2,
\"sepa_mandate\": { \"signed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" },
\"statute_consent_given_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}" | jq -r .share_purchase_id)
echo "$VV_PURCHASE_ID"
```
Four fields. **The cooperative, the share count, and the two moments you
captured that we cannot** — when the investor signed the SEPA mandate, and when
they consented to this cooperative's statute.
There is no price field, no total, no fee. Commercial terms are computed from
the cooperative's current pricing and frozen server-side at submission, so a
partner cannot dictate or drift what the investor pays. They come back on the
response.
Always send an `Idempotency-Key`. A retry with the same key and the same body
replays the original order instead of booking a second one.
## 7. Read the frozen terms
```bash
curl -sS "$VV_HOST/v1/investors/$VV_INVESTOR_ID/share-purchases/$VV_PURCHASE_ID" \
-H "Authorization: Bearer $VV_TOKEN" | jq '{status, share_count, price_per_share, entry_fee, total, total_currency}'
```
```json
{
"status": "submitted",
"share_count": 2,
"price_per_share": "100.00",
"entry_fee": "0.00",
"total": "200.00",
"total_currency": "EUR"
}
```
This is the number to show the investor. It will not change.
Amounts are decimal **strings**, here and in webhook payloads alike. Parse
them into a decimal type — never a float.
## 8. Wait for settlement
The order is now `submitted`. Approval and settlement happen on our side, over
days. You find out one of two ways:
**Webhooks (recommended).** Ask partner support to register your endpoint and
you receive `share_purchase.approved`, then `share_purchase.settled` — or
`share_purchase.rejected` if it does not go through. Every payload carries
`terminal`, so you know when to stop waiting. See [Webhooks](./webhooks).
**Polling.** Re-read the order. Poll no faster than once a minute; nothing moves
in seconds.
```bash
curl -sS "$VV_HOST/v1/investors/$VV_INVESTOR_ID/share-purchases/$VV_PURCHASE_ID" \
-H "Authorization: Bearer $VV_TOKEN" | jq -r .status
```
Once it is `settled`, the position is real:
```bash
curl -sS "$VV_HOST/v1/investors/$VV_INVESTOR_ID/holdings" \
-H "Authorization: Bearer $VV_TOKEN" | jq '.items'
```
```json
[
{
"id": "9c1f7b02-4a55-4c6e-9f8b-2d3a1e5c7b90",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"shares": 2,
"pending_shares": 0,
"first_acquired_at": "2026-08-14T11:02:07.884+02:00",
"created_at": "2026-08-14T11:02:07.884+02:00",
"updated_at": "2026-08-14T11:02:07.884+02:00"
}
]
```
`shares` is the settled position. `pending_shares` is what is still on its way —
orders placed but not yet settled. The holding carries no cooperative name; join
on `cooperative_id` against the catalogue you already fetched.
## What to do next
- Read [The investing flow](./investing-flow) for the states, the gates, and
what each call actually requires.
- Wire up [Webhooks](./webhooks) so you are not polling in production.
- Read [Errors](./error-reference) before you write your retry logic, not after.
---
# The investing flow
Source: https://docs.valueverde.de/docs/investing-flow
# The investing flow
The [Quickstart](./quickstart) shows the happy path as fast as possible. This
page explains it: what has to be true before each call succeeds, which values
flow between them, and what happens when things do not go to plan.
Four phases. Each depends only on the one before it.
```mermaid
flowchart LR
A["1 · Authenticate
once per 15 min"] --> B["2 · Browse
as often as you like"]
B --> C["3 · Register the investor
once per customer"]
C --> D["4 · Place and track
once per order"]
```
---
## Phase 1 — Authenticate
```mermaid
sequenceDiagram
autonumber
participant You as Your backend
participant VV as valueverde
You->>VV: POST /oauth2/token
Basic client_id:client_secret
grant_type=client_credentials
VV-->>You: access_token, expires_in 900, scope
Note over You: Cache until 60s before expiry
```
Your backend authenticates as itself. There is no end-user login and no
refresh token — when the access token expires you mint another with the same
credentials.
Omit `scope` on the token request and you get every scope your client holds;
the response's `scope` field tells you which those are. Ask for a scope you were
not granted and the whole request fails, so `GET /v1/me` is the safe way to
discover what you have.
Full detail, including rotation: [Authentication](./authentication).
---
## Phase 2 — Browse the catalogue
```mermaid
sequenceDiagram
autonumber
participant User
participant You as Your backend
participant VV as valueverde
User->>You: opens your listing page
You->>VV: GET /v1/cooperatives?page=1&size=20
VV-->>You: items[] + paging + ETag
You-->>User: the list
User->>You: taps one
You->>VV: GET /v1/cooperatives/{id}?expand=projects
VV-->>You: detail, projects_by_type, projects[]
You-->>User: the detail page
```
**Scopes.** The list needs `cooperatives:read`. The detail needs
`cooperatives:read` **and** `projects:read` — it embeds the cooperative's
projects, so it is gated on both. A client holding only the first gets a `403`
naming the scope it is missing.
**Caching.** Both endpoints return an `ETag`. Send it back as `If-None-Match`
and an unchanged catalogue answers `304` with no body. The catalogue changes
rarely; this is the cheapest thing you can do for your latency.
**Filtering.** The list takes `q`, `city`, `bafa_funded`, `dividend_type`,
`min_share_price`, `max_share_price`, `affiliation`, and a `sort` token — or
`near_lat`/`near_lng`/`radius_km` for a geo search. **The two are exclusive.**
A geo search sorts by distance and applies no filters, so combining them is a
`422` naming the parameters that could not be honoured rather than a `200` that
quietly ignored half your query.
`size` is capped at 100 and clamped rather than rejected.
**There is no `/v1/projects`.** Projects arrive embedded in the cooperative
detail; `projects:read` gates that embedding rather than a separate collection.
---
## Phase 3 — Register the investor
This is the phase that most often surprises people, so it is worth being precise
about what a *managed investor* is.
It is **a person we hold on your behalf**. It has no login, no password, and no
session — it cannot authenticate. It exists so that an order has an owner, a
SEPA mandate has a debtor, and a holding has a holder. You address it by an id
we issue, and you find it again by a reference you choose.
```mermaid
sequenceDiagram
autonumber
participant You as Your backend
participant VV as valueverde
alt First time for this customer
You->>VV: POST /v1/investors
{partner_customer_ref, email, profile}
VV-->>You: 201 investor_id, is_profile_complete
else Same ref, same details
You->>VV: POST /v1/investors (retry)
VV-->>You: 200 the same investor — replayed
else Same ref, different profile
You->>VV: POST /v1/investors
VV-->>You: 409 — use PUT …/profile to change it
end
opt Profile incomplete
You->>VV: PUT /v1/investors/{id}/profile
VV-->>You: completeness + missing_profile_fields
end
```
### One call, not two
`POST /v1/investors` accepts the profile inline. You almost certainly onboarded
this person yourself and already hold the whole record, so making you create an
empty shell and fill it in afterwards buys nothing. `PUT …/profile` remains how
you *change* a profile later.
### What "complete" means
A profile must be complete before an order will be accepted. Complete means:
| Section | Fields |
|---|---|
| Name | `first_name`, `last_name` |
| Birth date | `birth_date` |
| Contact | `email` (the top-level one on the provision call) |
| Address | `address.street`, `address.house_number`, `address.postal_code`, `address.city` |
| Banking | `bank_account.iban` |
| Consents | `consents.terms`, `consents.privacy`, `consents.data_sharing` — each `{ "given": true }` |
| Company (only if the investor represents one) | `company.name`, `company.legal_form`, `company.tax_id`, and `consents.representation_authorization` |
You do not have to memorise that table. **`missing_profile_fields` on the
response tells you exactly what is outstanding**, and is empty precisely when
`is_profile_complete` is `true`.
### Sections are all-or-nothing
An address is stored as one value, not four, and the payload says so: it is one
`address` object. Send three of its four parts and you get a `422` naming the
missing one — `address.city` — not a `200` that quietly drops the address and,
worse, clears whatever was stored before. The same holds for `company`, and for
the `first_name`/`last_name` pair, which stayed flat because those two keys are
recognised everywhere.
Omitting a section entirely is always fine: that is what makes `PUT …/profile`
usable as partial progress.
### `consents` is the one section you must always send
Every other section may be omitted. `consents` may not, and this is the single
most important sentence on this page:
> **An omitted or not-given consent is a revocation, and it erases the recorded
> capture instant.** There is no consent history to restore it from.
A `PUT` replaces the stored profile. Leaving `consents` out does not mean "leave
the consents alone" — it means "none of these are given", which clears the
timestamps you originally supplied and drops the profile back to `DRAFT`. The
request schema therefore marks `consents`, and each of `terms`, `privacy` and
`data_sharing` inside it, as required.
**To leave a consent untouched across a `PUT`, re-send it as given.**
Each consent is an object, not a boolean, so you can tell us *when* you captured
it:
```json
"consents": {
"terms": { "given": true, "given_at": "2026-08-14T09:12:04Z" },
"privacy": { "given": true },
"data_sharing": { "given": true },
"representation_authorization": { "given": false }
}
```
`given_at` is optional. Omit it and we stamp the consent on receipt; send it and
we record the moment you actually captured it. In a partner flow the consent was
taken in your UI — possibly hours earlier, possibly replayed from a queue — so
sending the instant is the more accurate record, and the reason the field
exists. It must not be in the future, and it must be omitted when `given` is
`false`.
### What you do not have to send
| Field | Why you can omit it |
|---|---|
| `bank_account.bic` | Derived from the IBAN |
| `bank_account.institution` | Derived from the IBAN |
| `bank_account.account_holder` | Defaults to the profile name, or the company name |
If our IBAN lookup is unavailable, the derivation is skipped rather than failing
your write — the profile simply stays incomplete, and `missing_profile_fields`
says so. Send `bank_account.bic` and `bank_account.institution` explicitly to
remove the dependency.
### Finding an investor again
You do not have to store our id, though it is cheaper if you do:
```
GET /v1/investors?partner_customer_ref=your-customer-4711
```
resolves your own reference back to the investor. `404` if none of your
investors carries it.
---
## Phase 4 — Place and track the order
```mermaid
sequenceDiagram
autonumber
participant User
participant You as Your backend
participant VV as valueverde
participant Ops as valueverde operations
User->>You: signs SEPA mandate + accepts the statute
Note over You: capture both instants
You->>VV: POST …/share-purchases
Idempotency-Key: uuid
{cooperative_id, share_count,
sepa_mandate.signed_at,
statute_consent_given_at}
VV-->>You: 201 status=submitted, terms frozen
You-->>User: "order placed — €200.00"
Ops->>VV: approve
VV--)You: share_purchase.approved (terminal: false)
alt Goes through
Ops->>VV: settle
VV--)You: share_purchase.settled (terminal: true)
You->>VV: GET …/holdings
VV-->>You: the position
else Refused
Ops->>VV: reject (with a reason)
VV--)You: share_purchase.rejected (terminal: true, reason)
You-->>User: "not accepted — "
end
```
### The four fields
```json
{
"cooperative_id": "…",
"share_count": 2,
"sepa_mandate": { "signed_at": "2026-08-17T10:00:00Z" },
"statute_consent_given_at": "2026-08-17T10:00:00Z"
}
```
Everything else is derived, because a field you can only fill in one correct way
is a field that only creates ways to get it wrong:
| Field | Default |
|---|---|
| `applicant_type` | The investor's company affiliation: affiliated ⇒ `company`, otherwise `private` |
| `sepa_mandate.debtor_iban` | The profile IBAN. Send it only if the debtor account genuinely differs |
| `sepa_mandate.reference` | Issued server-side. Send one only if you run your own mandate management |
Terms and privacy consent are **not** collected here — they carry over from the
investor's profile with their original capture instants. Statute consent stays
per-order because it is consent to *this* cooperative's Satzung, which is
presented per purchase and genuinely cannot come from a profile.
Both timestamps must be in the past. They are moments you captured in your UI,
so stamping `now()` on our side would record the wrong one on a regulated
record.
### Commercial terms are ours
There is no price field, no fee field, and no total field in the request. There
is nowhere to put one. Terms are computed from the cooperative's current pricing
and frozen at submission, and come back on the response:
```json
{
"share_purchase_id": "…",
"status": "submitted",
"share_count": 2,
"price_per_share": "100.00",
"entry_fee": "0.00",
"total": "200.00",
"total_currency": "EUR",
"sepa_mandate": {
"debtor_iban_masked": "****3000",
"reference": "VV-2026-000123",
"signed_at": "2026-08-17T10:00:00Z"
},
"consents": {
"terms": { "given": true, "given_at": "2026-08-14T09:12:04Z" },
"privacy": { "given": true, "given_at": "2026-08-14T09:12:05Z" },
"statute": { "given": true, "given_at": "2026-08-17T10:00:00Z" }
}
}
```
Show `total` to the investor. It will not change.
The debtor IBAN comes back **masked**. We never return the full account number,
on any endpoint, so a compromised partner token is not a banking-data
exfiltration route.
### Idempotency
Send an `Idempotency-Key` header on every create. It is scoped to you and to the
investor:
- **Same key, same body** → the original order is replayed. No second booking.
- **Same key, different body** → `409 IDEMPOTENCY_KEY_CONFLICT`. Something
changed; look before you retry.
- **Same key, different investor** → also `409`. Keys are per-order, not per-run.
Because `applicant_type` may be derived, editing the investor's company
affiliation between two otherwise identical retries makes them different orders,
and therefore a `409`.
### The order lifecycle
```mermaid
stateDiagram-v2
[*] --> submitted: POST …/share-purchases
submitted --> approved: valueverde reviews
submitted --> rejected: valueverde declines
submitted --> cancelled: you call …/cancel
approved --> settled: payment collected
approved --> rejected: declined later
approved --> cancelled: you call …/cancel
settled --> [*]
rejected --> [*]
cancelled --> [*]
```
Five statuses, and no others appear on a partner order:
| Status | Meaning | Terminal |
|---|---|---|
| `submitted` | Accepted into the queue. Terms frozen. | no |
| `approved` | Passed review. Payment is being collected. | no |
| `settled` | Done. The holding exists. | **yes** |
| `rejected` | Refused, with a `rejection_reason`. | **yes** |
| `cancelled` | Withdrawn — by you, before it settled. | **yes** |
You can cancel a `submitted` or `approved` order. A `settled` one is `409` —
there is nothing left to cancel.
### Settlement is not yours to trigger
There is no partner endpoint that settles an order, deliberately. Settlement
means money has actually been collected, and that is a back-office act.
On staging, ask partner support to advance a specific order — that is the
supported way to exercise the tail of the flow end to end.
### Knowing when it is over
Every webhook payload carries a `terminal` boolean. Branch on that rather than
hard-coding which event types are final: when `terminal` is `true`, nothing more
is coming for that order and you can stop tracking it.
If you poll instead, poll the order once a minute at most. Nothing here moves in
seconds.
---
## A note on who tells your customer
By default, valueverde emails the investor directly at their profile address on
order transitions — including payment instructions and a rejection notice.
If you are a white-label integration and want to own that conversation, ask
partner support to turn it off for your account. You then take on the duty to
tell your own customers what happened to their orders. Webhooks are how you find
out in time to do it.
---
# Authentication
Source: https://docs.valueverde.de/docs/authentication
# 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.
:::info 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
| 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:
```bash
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:
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "cooperatives:read"
}
```
Send the token on every API call:
```http
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:
```bash
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.
:::danger 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:
```json
{
"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
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"
```
```json
{
"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:
```bash
curl -sS -H "Authorization: Bearer $TOKEN" "https://api.valueverde.de/v1/me"
```
```json
{
"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:
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](mailto: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](./error-reference) 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](./error-reference). |
---
# Webhooks
Source: https://docs.valueverde.de/docs/webhooks
# Webhooks
Polling a share purchase until it settles works, but it wastes both our capacity and yours. Register an endpoint and we push the transition instead.
Deliveries are signed, retried with exponential backoff, and carry a stable event id so you can dedupe.
The machine-readable contract lives in the spec's `webhooks` block. These describe operations **your** endpoint implements and we call; they are not routes on this API, and no generated client will produce them for you.
## Getting an endpoint registered
:::info Registration is done by us, not by you
There is no self-service registration route yet. Send the HTTPS URL you want deliveries posted to, to [partners@valueverde.de](mailto:partners@valueverde.de), and we will register it against your organisation and return the signing secret.
**The signing secret is shown exactly once, at registration.** It is derived, never stored, and cannot be read back — if you lose it, we rotate, and the old secret stops verifying. Put it in your secret manager before you close the ticket.
:::
Your URL must be `https` and must resolve to a public address. We reject loopback, link-local, the cloud metadata address, and private ranges (RFC1918, unique-local, CGNAT) — both at registration and again at delivery time, so a hostname that later resolves to an internal address is still refused.
## Event types
| `type` | Fires when | Terminal |
|---|---|---|
| `share_purchase.submitted` | An order you placed has been accepted into the queue. | no |
| `share_purchase.approved` | The order passed review. Payment is being collected. | no |
| `share_purchase.settled` | The order settled — the point the investor's holdings change. | **yes** |
| `share_purchase.rejected` | The order was refused, with a `reason`. | **yes** |
| `share_purchase.cancelled` | The order was withdrawn, including by your own call to the cancel endpoint. | **yes** |
Every transition a partner order can make now emits an event, which means **the
absence of an event genuinely means "nothing has happened yet"**. Previously
only `submitted` and `settled` fired, so a rejected order — which is final —
looked exactly like one still in the queue, and every integrator had to run a
reconciliation poller anyway.
**Branch on `terminal`, not on the type.** Every `data` object carries a
`terminal` boolean saying whether anything further will arrive for that order.
An endpoint that switches on `terminal` keeps working when a new type is added;
one that hard-codes a list of final types does not.
**Handle unknown types by ignoring them.** New event types are additive and will start arriving without a major version bump. An endpoint that returns a non-2xx for a type it does not recognise will collect retries and then dead-letters for events it never needed.
## The envelope
Every delivery has the same four top-level fields. Only `data` varies by type.
| Field | Type | Notes |
|---|---|---|
| `id` | string (uuid) | Event id, **stable per (share purchase, transition)**. Every retry and every redelivery of the same event carries the same value. Store it; treat a repeat as a no-op. |
| `type` | string enum | One of the five in the table above. New values may be added. |
| `created_at` | string (date-time) | When the transition *occurred* — ISO-8601 with offset — not when the delivery was attempted. A retried delivery keeps the original value. |
| `data` | object | Type-specific body, described below. |
Field order in the raw body is fixed (`id`, `type`, `created_at`, `data`). That only matters if you are debugging a signature mismatch — verify against the raw bytes and the order is irrelevant.
## Payloads
### `share_purchase.submitted`
```json
{
"id": "8f14e45f-ceea-4e0a-9d3f-6c9b0f2a71c4",
"type": "share_purchase.submitted",
"created_at": "2026-08-12T09:14:22.031+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"share_count": 2,
"status": "submitted",
"terminal": false,
"total": "500.00",
"total_currency": "EUR"
}
}
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `share_purchase_id` | string (uuid) | yes | The order. Matches `share_purchase_id` on [`GET …/share-purchases/{purchaseId}`](./api/get-share-purchase). |
| `investor_id` | string (uuid) | yes | The managed investor — the `investorId` path segment on the investing endpoints. |
| `cooperative_id` | string (uuid) | yes | The cooperative subscribed to. |
| `share_count` | integer | yes | Shares in the order, ≥ 1. |
| `status` | string | yes | Always `submitted` for this type. |
| `terminal` | boolean | yes | Always `false` for this type — more is coming. |
| `total` | string | yes | Total consideration as a **decimal string**, the same as everywhere else in the contract. Parse it as a decimal type. Server-computed and frozen at submission. |
| `total_currency` | string | yes | ISO-4217 code, e.g. `EUR`. |
### `share_purchase.settled`
```json
{
"id": "1a2fd0c7-6b91-4a0c-8ce7-3d2f16c8b2a1",
"type": "share_purchase.settled",
"created_at": "2026-08-14T11:02:07.884+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"share_count": 2,
"status": "settled",
"terminal": true
}
}
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `share_purchase_id` | string (uuid) | yes | |
| `investor_id` | string (uuid) | yes | |
| `cooperative_id` | string (uuid) | yes | |
| `share_count` | integer | yes | |
| `status` | string | yes | Always `settled` for this type. |
| `terminal` | boolean | yes | Always `true` — this order is finished. |
:::caution `settled` carries no `total`
The settlement transition does not restate the amount, so `total` and `total_currency` are **absent** from this payload — they are not `null`, they are not there. Take the amount from the `submitted` event you already stored, or read the order. A handler that assumes a uniform `data` shape across both types will throw on the second one.
:::
### `share_purchase.approved`
```json
{
"id": "5c9a1e30-77bd-4c02-9f13-0a4e2b7d55c8",
"type": "share_purchase.approved",
"created_at": "2026-08-13T08:41:19.220+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"status": "approved",
"terminal": false
}
}
```
Progress, not an ending — settlement still follows. Useful if you want to tell
your customer their order passed review rather than leaving them on "pending"
for days.
Carries no `share_count` or `total`: approval does not restate the order. Read
them from the `submitted` event you stored, or from the order.
### `share_purchase.rejected`
```json
{
"id": "d31b7c0e-9a2f-4b88-8c41-6f0d9e3a1b72",
"type": "share_purchase.rejected",
"created_at": "2026-08-13T15:22:04.117+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"status": "rejected",
"terminal": true,
"reason": "bank details could not be verified"
}
}
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `reason` | string | no | Why the order was refused, in our words. Present when one was recorded. **Surface it or map it — do not branch on it.** The wording is written for a human and is not a stable enum. |
This is the event that lets you stop polling and tell your customer something
true. It is final.
### `share_purchase.cancelled`
```json
{
"id": "7e2c4a91-3f05-4d6b-b0a8-91c4e2d70f13",
"type": "share_purchase.cancelled",
"created_at": "2026-08-13T09:05:41.664+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"status": "cancelled",
"terminal": true
}
}
```
Fires for your own call to [`POST …/cancel`](./api/cancel-share-purchase) too,
so a system that reacts only to webhooks stays consistent without special-casing
its own writes.
## Headers
| Header | Contents |
|---|---|
| `Content-Type` | `application/json` |
| `X-VV-Webhook-Signature` | `t=,v1=` |
| `X-VV-Webhook-Id` | Delivery-**attempt** id — changes on every retry |
| `X-VV-Webhook-Event` | Event type, mirroring `type` in the body. Lets you route before parsing. |
Dedupe on the **body's `id`**, not on `X-VV-Webhook-Id`: the latter identifies the attempt, so two retries of one event carry two different values.
## Verifying a delivery
The scheme is Stripe-style. The signed payload is the timestamp, a literal `.`, and the **raw request body**:
```
signed_payload = "{t}.{raw_body}"
expected = HMAC-SHA256(signing_secret, signed_payload) // hex
```
Compare `expected` against the `v1=` value, and reject the delivery if `t` is outside your tolerance window — five minutes is a sensible default. That timestamp is what stops a captured delivery being replayed at you later.
```python
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance:
return False # too old — possible replay
signed = t.encode() + b"." + raw_body # raw bytes, not re-serialised JSON
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
```
Two things that will silently break verification:
- **Sign the raw body.** If your framework parses the JSON and you re-serialise it, key order or whitespace shifts and the MAC will not match. Capture the bytes before parsing.
- **Compare in constant time.** Use `hmac.compare_digest` or your language's equivalent, not `==`.
The `v1` prefix versions the scheme. If we ever add `v2`, deliveries will carry both for a transition period — match on the prefix you support rather than assuming a single value.
## Delivery, retries and failure
| Property | Value |
|---|---|
| Method | `POST`, `Content-Type: application/json` |
| Success | Any `2xx` |
| Connect / read timeout | 5s each |
| Attempts | 6 |
| Backoff | Exponential, from 30s |
Anything that is not a `2xx` — including a timeout, a TLS failure or a DNS failure — counts as a failed attempt and is retried. After the final attempt the delivery is dead-lettered and we stop.
**Answer fast and process later.** Because the timeout is 5 seconds and a slow response is indistinguishable from a broken one, acknowledge with a `2xx` as soon as you have persisted the event, then do the work asynchronously. An endpoint that does its processing inline will eventually time out under load and start collecting retries for events it actually handled.
Return a non-`2xx` only when you genuinely want the delivery again.
## A minimal receiver
```python
from flask import Flask, request
app = Flask(__name__)
seen = set() # use a durable store in production
@app.post("/valueverde/webhooks")
def receive():
raw = request.get_data() # bytes, before any parsing
if not verify(raw, request.headers["X-VV-Webhook-Signature"], SECRET):
return "", 400 # not us — do not retry
event = json.loads(raw)
if event["id"] in seen: # stable per (purchase, transition)
return "", 200 # already handled; acknowledge anyway
seen.add(event["id"])
if event["type"] == "share_purchase.submitted":
enqueue_submitted(event["data"]) # has total / total_currency
elif event["type"] == "share_purchase.settled":
enqueue_settled(event["data"]) # has neither
# any other type: ignore, and still return 2xx
return "", 200
```
## What webhooks do not replace
Webhooks are an optimisation, not a source of truth. They can be delayed by retries, and a dead-lettered delivery is simply lost. Reconcile against [`GET /v1/investors/{investorId}/share-purchases`](./api/list-share-purchases) on a schedule, and treat the API as authoritative when the two disagree.
---
# Pagination & sorting
Source: https://docs.valueverde.de/docs/pagination-and-caching
# Pagination & sorting
Every list endpoint is paginated and every one returns the same envelope — `GET /v1/cooperatives`, `GET /v1/investors/{id}/share-purchases` and `GET /v1/investors/{id}/holdings`. All use **one-based** page numbers, so `page=1` is the first page. `size` defaults to 20 and is capped at 100; an oversized `size` is clamped rather than rejected.
## Request parameters
| Parameter | Type | Default | Bounds | Description |
|---|---|---|---|---|
| `page` | integer | `1` | `>= 1` | One-based page number. Out-of-range values clamp to the first page. |
| `size` | integer | `20` | `1..100` | Items per page. Out-of-range values clamp into the bounds. |
| `sort` | string | `created_at_desc` | enum (below) | Sort token. |
Filter parameters (`q`, `city`, `bafa_funded`, `dividend_type`, `min_share_price`, `max_share_price`, `affiliation`) all combine via AND. Empty filters do not constrain the result set.
### Sort tokens
| Token | Effect |
|---|---|
| `created_at_desc` | Newest first. **Default.** |
| `created_at_asc` | Oldest first. |
| `name_asc` | A → Z by name. |
| `name_desc` | Z → A by name. |
| `share_price_asc` | Cheapest share first. |
| `share_price_desc` | Most expensive share first. |
An unknown sort token returns `400 VALIDATION_ERROR`.
## Response envelope
```json
{
"items": [ /* CooperativeSummaryResponse[] */ ],
"page": 1,
"size": 20,
"total_items": 47,
"total_pages": 3,
"has_next": true,
"has_previous": false,
"sort": "created_at_desc"
}
```
| Field | Meaning |
|---|---|
| `items` | The page's rows. |
| `page` | Echo of the requested page (one-based). |
| `size` | Echo of the requested size (after clamping). |
| `total_items` | Total rows matching the filters, ignoring pagination. |
| `total_pages` | `ceil(total_items / size)`. |
| `has_next` | True when `page < total_pages`. |
| `has_previous` | True when `page > 1`. |
| `sort` | Echo of the sort token used. |
Use `has_next` to terminate pagination loops; do not compute it client-side.
## Stability across pages
Sort orderings are deterministic but **not transactional** — concurrent inserts or status changes can shift items across pages while you paginate. For most catalogue use-cases this is fine. If you need a perfect snapshot, fetch all pages once and dedupe on `id` client-side.
## Caching
Both catalogue reads — `GET /v1/cooperatives` and `GET /v1/cooperatives/{id}` — return a strong `ETag` and a storable `Cache-Control: private, max-age=300`. Send the tag back as `If-None-Match` and an unchanged resource answers `304 Not Modified` with no body:
```bash
curl -sS -D- -o/dev/null "https://api.valueverde.de/v1/cooperatives/$COOP_ID" \
-H "Authorization: Bearer $VV_ACCESS_TOKEN" \
-H 'If-None-Match: "3f9a1c08b2d54e7a91c6f0b3d8e2a745"'
```
The tag is derived from the rendered payload, not from a timestamp column. That matters: a cooperative's `updated_at` does not move when a dividend year is added, so a timestamp-based tag would have served you a stale `304`. Comma-separated lists, the `W/` prefix and `*` are all honoured.
The other `/v1` endpoints carry no ETag. Investor profiles and share purchases change on your own writes, so you already know when they moved.
Image endpoints (cooperative hero, card, logo, project images) carry strong ETags and a 1-hour `Cache-Control: max-age=3600`.
---
# Security & data handling
Source: https://docs.valueverde.de/docs/security
# Security & data handling
The Partner API serves data drawn from the German cooperative-banking sector. The controls below apply to every request, every credential, every endpoint.
## Transport
- **TLS 1.2 minimum, TLS 1.3 preferred.** Older protocols are rejected at the edge.
- **HSTS** is enforced with a one-year `max-age`, `includeSubDomains`, and `preload`.
- **Frame embedding** is forbidden (`X-Frame-Options: DENY`).
- **Referrer policy** is `strict-origin-when-cross-origin`.
- HTTP traffic on port 80 is redirected to HTTPS — but treat HTTP URIs as unsupported, not "auto-upgraded". Don't ship `http://api.valueverde.de/...` in production code.
## Credential handling
- **OAuth2 client_credentials** is the only partner authentication mechanism. See [Authentication](./authentication).
- Tokens are RS256 JWTs, short-lived (15 min), and signed by the keys at [`/.well-known/jwks.json`](https://api.valueverde.de/.well-known/jwks.json).
- `client_secret` is stored hashed (Argon2id) — we cannot recover a lost secret; we can only rotate.
- `client_secret` rotation is operator-assisted via partner support; revocation is minutes.
### What you should do
- Store `client_secret` in a secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault) — never in source control, never in client-side code.
- Use a separate `client_id` per deployment (staging / production) so you can rotate independently.
- Verify TLS server certificates. Do not disable certificate verification, even for testing.
- If you cache access tokens in shared infrastructure, scope the cache to the (`client_id`) tuple and treat the cached token as sensitive.
## What we log
| Event | Retention | Purpose |
|---|---|---|
| Every request (method, path, status, client_id, request id, client IP) | 90 days hot, 13 months cold | Operations, abuse investigation |
| Every authentication outcome (success, failure, reason) | 13 months | Audit, fraud investigation |
Request bodies are not retained beyond the immediate request lifecycle.
## What you should log
- The `X-Request-Id` response header on every call. Quote it in support tickets — it correlates straight to our server logs.
- The full Problem Details body of any 4xx / 5xx response, including `code`, `trace_id`, and `instance`.
Do **not** log the full `Authorization` header or the full `client_secret`. Mask both to the first 8 characters before logging.
## Data residency
All data is stored in Frankfurt (`eu-central-1`) with cross-AZ replication. There is no US or APAC replica today. We will publish a regional residency option when partner demand warrants it.
## Vulnerability disclosure
Email [security@valueverde.de](mailto:security@valueverde.de) with a description, reproducer, and impact assessment. We aim to respond within two business days. Coordinated-disclosure window is 90 days from acknowledgement.
Do **not** post vulnerabilities to GitHub issues, social media, or third-party paste services before we acknowledge.
---
# Versioning & deprecation
Source: https://docs.valueverde.de/docs/versioning
# Versioning & deprecation
The Partner API is versioned in the URL — every partner endpoint lives under the `/v1` prefix (`/v1/cooperatives`, `/v1/investors/...`). This document is the canonical record of what's stable and what isn't.
When we introduce breaking changes, we move to a new URL prefix (`/v2/...`) and the `/v1` paths are supported per the deprecation lifecycle below.
## What we promise
While the contract stays at v1, we will not:
- Remove an endpoint.
- Remove a field from a successful response.
- Change the type of an existing response field.
- Add a required request parameter.
- Change a successful response status code.
- Change the meaning of an existing error `code`.
We may, without notice:
- **Add** a new endpoint, optional query parameter, or response field.
- **Add** a new error `code` to an existing status. Default-handle by status; branch on `code` only when you intend to.
- Change a `description` or `summary` in the OpenAPI spec.
- Change the wire format of a field whose schema declares `additionalProperties: true` (for example, `type_specific` extension fields on `CooperativeProjectResponse`).
Treat unknown fields as forward-compatible — your client must not reject responses that contain fields it does not yet know about.
## When we cut a new version
A breaking change moves the path to a new prefix (`/v2/...`). Both the old and the new contract run side-by-side for at least **12 months**:
| Phase | Timing | What happens |
|---|---|---|
| Announcement | T+0 | New version available. Old endpoints unchanged. |
| Soft sunset | T+9 months | `Sunset: ` and `Deprecation: true` headers added to old-version responses. Email to partner technical contacts. |
| Hard sunset | T+12 months | Old endpoints return `410 Gone` with a `Link: ; rel="successor-version"` header. |
## Deprecation signals within a version
For changes inside the current contract (e.g. an old query parameter superseded by a better one), we flag deprecations on the response:
```http
Deprecation: Sun, 03 May 2026 12:00:00 GMT
Sunset: Mon, 03 May 2027 12:00:00 GMT
Link: ; rel="successor-version"
```
The OpenAPI spec marks the same operations and parameters with `deprecated: true`. The [changelog](./changelog) lists every deprecation as it is introduced.
---
# Rate limits
Source: https://docs.valueverde.de/docs/rate-limits
# Rate limits
An undocumented limit is indistinguishable from an outage, so here are the
numbers.
| Surface | Limit | Counted per |
|---|---|---|
| `POST /oauth2/token` | **20 per minute** | credential, and separately per calling address |
| Everything under `/v1` | **600 per minute** | credential |
The `/v1` limit is per **credential**, not per IP address. Two partners sharing
a cloud egress address do not throttle each other, and running your integration
from a dozen containers behind one NAT gateway does not count as one caller.
There is also a coarser per-address ceiling underneath, sized well above the
per-credential limit so that a single partner spending its full 600 never
reaches it. If you are seeing `429` while `RateLimit-Remaining` looks healthy,
you are sharing an address with a lot of traffic — tell us and we will look.
## Reading your budget
Every `/v1` response carries three headers:
```http
RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 42
```
- **`RateLimit-Limit`** — requests per minute for your credential.
- **`RateLimit-Remaining`** — how many you have left right now.
- **`RateLimit-Reset`** — seconds until your budget is back to full.
The bucket refills continuously rather than resetting on a fixed boundary, so
you regain roughly ten requests a second as you go. You do not have to wait for
`Reset` to hit zero before the next request succeeds — that value is the wait
for the *whole* budget, not for the next single request.
:::note
Limits are enforced per API instance, and we run more than one. In practice this
means your effective ceiling is somewhat **higher** than the published number,
and `RateLimit-Remaining` can move non-monotonically as your requests land on
different instances. Treat it as a live signal to back off on, not as an exact
ledger.
:::
## When you exceed it
`429 Too Many Requests`, with a `Retry-After` in seconds and the same
[problem-details body](./error-reference) as every other error:
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 3
RateLimit-Limit: 600
RateLimit-Remaining: 0
RateLimit-Reset: 58
Content-Type: application/problem+json
```
```json
{
"type": "https://api.valueverde.de/errors/rate-limited",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded for this API client. Retry after the period given in Retry-After, and see https://docs.valueverde.de/docs/rate-limits.",
"instance": "/v1/cooperatives",
"code": "RATE_LIMITED",
"trace_id": "9f2c1b7e-4d3a-4f18-93ac-2b6e5d0c8a71"
}
```
Sleep for `Retry-After` and try again. A refused request costs you nothing from
the bucket, so retrying does not push your own recovery further away — but
retrying *without* sleeping will not succeed any sooner either.
Quote `trace_id` if you contact support about a specific call.
## Staying under it
**Cache your access token.** This is the single most common cause of a `429` on
the token endpoint. A token is valid for 15 minutes; mint one, hold it, and
re-mint when it expires. An integration that fetches a token before every call
will exhaust twenty a minute almost immediately — and it is doing an RSA
signature per call for no reason.
**Use conditional requests on the catalogue.** `GET /v1/cooperatives` and
`GET /v1/cooperatives/{id}` return `ETag`, and a `304 Not Modified` still costs
a request but nothing else. See [Pagination & caching](./pagination-and-caching).
**Back off on the header, not on the failure.** If you are checking
`RateLimit-Remaining` and slowing down as it approaches zero, you will rarely
see a `429` at all.
**Ask before you engineer around it.** If a bulk reconciliation genuinely needs
more than 600 a minute, that is a reasonable thing to want — email
[partners@valueverde.de](mailto:partners@valueverde.de) rather than sharding
across credentials, which we would read as abuse.
## Other surfaces
The public unauthenticated catalogue (`/public/**`) and the browser
authentication endpoints have their own, tighter, per-address limits. Neither is
part of the partner contract; if you are calling them from an integration, you
are on the wrong endpoint.
---
# OpenAPI document
Source: https://docs.valueverde.de/docs/openapi-spec
# OpenAPI document
Everything in this reference is generated from one file. That file is published,
so you do not have to read the HTML — point your own tooling at it.
| What | URL |
|---|---|
| Latest, YAML | [`https://docs.valueverde.de/spec/partner-api.yaml`](https://docs.valueverde.de/spec/partner-api.yaml) |
| Latest, JSON | [`https://docs.valueverde.de/spec/partner-api.json`](https://docs.valueverde.de/spec/partner-api.json) |
| A pinned version | `https://docs.valueverde.de/spec//partner-api.yaml` (and `.json`) |
| Which versions exist | [`https://docs.valueverde.de/spec/index.json`](https://docs.valueverde.de/spec/index.json) |
| Postman collection | [`.../spec/valueverde-partner-api.postman_collection.json`](https://docs.valueverde.de/spec/valueverde-partner-api.postman_collection.json) |
It is OpenAPI 3.1, and it is the same document the reference pages on this site
are built from — there is no second, more accurate copy that we keep to
ourselves.
## Generate a client
```bash
curl -sS -o partner-api.yaml https://docs.valueverde.de/spec/partner-api.yaml
npx @openapitools/openapi-generator-cli generate \
-i partner-api.yaml \
-g typescript-fetch \
-o ./valueverde-client
```
Swap `-g` for any generator you prefer — `java`, `python`, `go`, `csharp`. We do
not yet ship a maintained SDK in any language, so the generated client *is* the
client; treat it as yours and check it into your repository.
## Import it into Postman
```
https://docs.valueverde.de/spec/valueverde-partner-api.postman_collection.json
```
Paste that into Postman's **Import → Link**. The collection is generated from
the same document on every docs build, so it cannot drift from the contract.
Set `clientId` and `clientSecret` in the collection variables, send **0. Get an
access token**, and work down the folders — the token, the cooperative id, the
investor id and the purchase id are all captured into variables as you go, so a
top-to-bottom run needs no copy-paste. It points at staging by default.
## Run it as a mock
The spec carries examples, so a local mock server needs nothing from us:
```bash
npx @stoplight/prism-cli mock partner-api.yaml
```
That gets you shapes and status codes without credentials. It will not model
state — a purchase you place against the mock does not appear in a later
holdings call — so use it to build against the contract, then move to
[staging](./quickstart) for behaviour.
## Contract-test against it
Because the spec is fetchable, "does the API still match its documentation" is a
test you can run in your own CI rather than a question you have to ask us:
```bash
npx @redocly/cli lint https://docs.valueverde.de/spec/partner-api.yaml
```
## Pin the version you generated against
`info.version` in the document tracks the *document*, not the URL prefix — the
API itself is `/v1` and stays `/v1` until a breaking change, per
[Versioning](./versioning). A generated client, though, is built from one exact
snapshot, and regenerating from `latest` can surface additive changes you did not
ask for on the day you least want them.
So generate from a pinned URL in CI:
```bash
curl -sS -o partner-api.yaml https://docs.valueverde.de/spec/1.6.0/partner-api.yaml
```
Every version we publish stays at its own URL permanently. `/spec/index.json`
lists them, newest first, so a scheduled job can notice a new one and open a pull
request rather than surprising a deploy:
```json
{
"name": "valueverde Partner API",
"latest": "1.6.0",
"versions": [
{
"version": "1.6.0",
"yaml": "/spec/1.6.0/partner-api.yaml",
"json": "/spec/1.6.0/partner-api.json",
"aliases": ["/spec/partner-api.yaml", "/spec/partner-api.json"]
},
{
"version": "1.5.0",
"yaml": "/spec/1.5.0/partner-api.yaml",
"json": "/spec/1.5.0/partner-api.json"
}
]
}
```
## What the document does not tell you
Two things are deliberately outside the schema, because they are runtime
behaviour rather than shape:
- **Rate limits.** The headers are on every response; the numbers are in
[Rate limits](./rate-limits).
- **Idempotency.** `Idempotency-Key` is declared on the endpoints that accept it,
but the replay window and the conflict rule are in
[Investing flow](./investing-flow).
Everything else — auth, scopes, every request and response shape, every error
body, and the outbound [webhook](./webhooks) payloads under OpenAPI 3.1's
top-level `webhooks` — is in the document.
## If your reader is not a person
Increasingly the first thing to read an API's documentation is an assistant, and
handing it a React shell to reverse-engineer helps nobody. The prose is published
as Markdown too:
| What | URL |
|---|---|
| Index of everything, [llms.txt](https://llmstxt.org) style | [`https://docs.valueverde.de/llms.txt`](https://docs.valueverde.de/llms.txt) |
| Every guide in one file | [`https://docs.valueverde.de/llms-full.txt`](https://docs.valueverde.de/llms-full.txt) |
| Any single page | append `.md` — e.g. [`/docs/quickstart.md`](https://docs.valueverde.de/docs/quickstart.md) |
The endpoint reference is deliberately absent from those: the machine-readable
form of an endpoint is the OpenAPI document above, and `llms.txt` points at it
rather than paraphrasing it less precisely.
---
# Errors
Source: https://docs.valueverde.de/docs/error-reference
# Errors
Every non-2xx response uses the [RFC 9457 Problem Details](https://datatracker.ietf.org/doc/html/rfc9457) envelope, served as `Content-Type: application/problem+json`.
## The envelope
```json
{
"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](./pagination-and-caching) 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 ` 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. |
```json
{
"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`](./authentication#discovering-what-your-credential-can-do) 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.
```json
{
"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:
```json
{
"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](./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:
```python
if problem["code"] == "COOPERATIVE_NOT_FOUND":
handle_not_found(problem["detail"])
```
Fragile contract:
```python
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`.
---
# Changelog
Source: https://docs.valueverde.de/docs/changelog
# Changelog
All notable changes to the public Partner API. Dates are in ISO 8601, newest first.
## v1.6.0 — 2026-08-18
The contract becomes fetchable, and the throttle behind it becomes visible.
### Added — the OpenAPI document is published
The machine-readable contract now has a URL. It was previously a build input
consumed by this site's generator and then discarded, so you could read our
endpoints as HTML but had nothing to hand `openapi-generator`, Prism, Insomnia
or your own contract tests.
| What | URL |
|---|---|
| Latest | `/spec/partner-api.yaml`, `/spec/partner-api.json` |
| Pinned | `/spec/1.6.0/partner-api.yaml` — every released version keeps its URL permanently |
| Manifest | `/spec/index.json` |
| Postman | `/spec/valueverde-partner-api.postman_collection.json`, generated from the spec |
Generate from a **pinned** URL in CI rather than from `latest`. See
[OpenAPI document](./openapi-spec).
### Added — rate limits, and the headers to see them
`/v1` is throttled at **600 requests a minute per API client**, and
`POST /oauth2/token` at **20 a minute**. Both were previously unlimited.
Every `/v1` response now carries `RateLimit-Limit`, `RateLimit-Remaining` and
`RateLimit-Reset`. A refusal is `429 RATE_LIMITED` with `Retry-After` and the
same RFC 9457 body as every other error — it is the one `4xx` you should retry
unchanged. Full detail in [Rate limits](./rate-limits).
If your integration mints a token per call, change it to cache the token before
upgrading: 20 a minute will not cover it.
### Added — machine-readable documentation
`llms.txt`, `llms-full.txt`, and every guide available as Markdown by appending
`.md` to its URL.
### Changed — `applicant_type` is lowercase, as documented
`applicant_type` on `POST /v1/investors/{investorId}/share-purchases` accepted
any capitalisation while its published schema said `enum: [private, company]`.
It now accepts exactly what the schema says: `private`, `company`, or omitted.
If you send `"PRIVATE"` or `"Private"`, switch to `"private"` — those now return
`422 VALIDATION_ERROR`. The published regex also dropped a Java-only inline flag
(`(?i)`) that no JavaScript validator could compile, which broke generated
clients and our own spec lint.
### Changed — idempotency keys expire after 24 hours
An `Idempotency-Key` on the API surface is now honoured for **24 hours** rather
than indefinitely, and the ledger is swept nightly.
Reusing a key after that window places a **new** order rather than replaying the
original — the row it would have matched is gone. This was always the intent
(the retention window was documented in the schema from the start) but no purge
existed, so keys were honoured forever. Nothing changes for a retry loop that
completes in minutes.
## v1.5.0 — 2026-08-18
Related fields on the investor and purchase payloads are now **sections** —
nested objects — instead of a flat list of keys. One breaking revision, no
compatibility aliases: unknown properties are rejected, so a retired flat key
returns `400` rather than being silently ignored.
The profile write went from 28 declarable keys to 9.
### Changed — the investor profile
`POST /v1/investors` (its `profile` block) and
`PUT /v1/investors/{investorId}/profile` now take four sections:
```json
{
"first_name": "Maria",
"last_name": "Santos",
"birth_date": "1985-03-15",
"tax_id": "DE12345678901",
"email": "maria@example.org",
"phone": "+49 30 1234567",
"address": { "street": "Hauptstrasse", "house_number": "1",
"postal_code": "10115", "city": "Berlin" },
"bank_account": { "iban": "DE89370400440532013000" },
"company": null,
"consents": {
"terms": { "given": true, "given_at": "2026-05-20T10:00:00Z" },
"privacy": { "given": true },
"data_sharing": { "given": true },
"representation_authorization": { "given": false }
}
}
```
| Was | Is |
|---|---|
| `street`, `house_number`, `postal_code`, `city` | `address.*` |
| `iban`, `bic`, `bank_institution`, `account_holder` | `bank_account.iban`, `.bic`, **`.institution`**, `.account_holder` |
| `company_name`, `legal_form`, `company_tax_id` | `company.name`, `company.legal_form`, `company.tax_id` |
| `terms_consent` + `terms_consent_given_at` | `consents.terms.{given, given_at}` |
| `privacy_consent` + `privacy_consent_given_at` | `consents.privacy.{given, given_at}` |
| `data_sharing_consent` + `data_sharing_consent_given_at` | `consents.data_sharing.{given, given_at}` |
| `representation_authorization` + `..._given_at` | `consents.representation_authorization.{given, given_at}` |
`first_name`, `last_name`, `birth_date`, `tax_id`, `email` and `phone` are
unchanged and stay at the top level.
The response mirrors the request section for section, with the account returned
as `bank_account.iban_masked`. An unset section is an explicit `null`, never an
object of nulls.
`address`, `bank_account` and `company` are each all-or-nothing: omit one to
leave it unset, or send it whole. Inside `bank_account` only `iban` is required.
### Changed — share purchases
| Was | Is |
|---|---|
| `sepa_debtor_iban`, `sepa_mandate_reference`, `sepa_mandate_signed_at` | `sepa_mandate.{debtor_iban, reference, signed_at}` |
| `sepa_debtor_iban_masked` (response) | `sepa_mandate.debtor_iban_masked` |
| `terms_consent_given_at`, `privacy_consent_given_at`, `statute_consent_given_at` (response) | `consents.{terms, privacy, statute}.{given, given_at}` |
`sepa_mandate` is required, but only its `signed_at` is — the debtor account
defaults to the profile IBAN and the reference is issued server-side.
`cooperative_id`, `share_count`, `applicant_type` and
`statute_consent_given_at` are unchanged, as are all the money fields.
### Changed — `consents` is required, and omitting it is destructive
**A `PUT` that omits `consents`, or sends a consent as not-given, revokes that
consent and erases its recorded capture instant.** There is no consent history
to restore from. To leave a consent alone across a write, re-send it as given.
This was always the storage behaviour; what changed is that the schema now
states it. `consents`, and `terms` / `privacy` / `data_sharing` within it, are
marked required, so the omission is a `422` rather than a silent revocation.
Each consent is `{ given, given_at }` rather than a bare timestamp because
`given_at` is optional: if your system records only a flag, send
`{ "given": true }` and we stamp it on receipt. Collapsing the pair would have
forced you to invent a timestamp we would then have recorded as fact.
### Changed — error keys are paths
Keys in the `errors` map are now the path you sent —
`profile.address.city`, `profile.bank_account.iban`,
`profile.consents.terms.given` — from every layer that can reject a value,
including domain checks such as the IBAN checksum. `missing_profile_fields`
uses the same paths, without the `profile` prefix, since it describes the
profile rather than a request body.
### Removed
- **`birth_day`, `birth_month`, `birth_year`.** Deprecated in favour of
`birth_date` and now gone. They allowed 31 February to be expressed and only
rejected deep in the platform.
## v1.4.0 — 2026-08-17
A single contract revision: several response shapes changed, and they changed
outright rather than carrying compatibility aliases alongside them.
Read the *Removed* and *Renamed* sections before upgrading a client generated
from an earlier version.
### Removed
- **The KYC attestation is gone from the contract.** The `kyc` block on
`POST /v1/investors` and `PUT /v1/investors/{investorId}/profile`, the
`kyc_attested` field on the investor response, and the
`409 KYC_ATTESTATION_REQUIRED` error no longer exist.
We never verified the assertion — a method string and a timestamp were enough
to open the purchase path — so the gate established only that something had
been typed, while costing an integrator a `409` discoverable only by hitting
it. Removing it states the true position plainly: **valueverde does not
perform or verify KYC on partner-managed investors, and does not claim to.**
Where reliance is genuinely required it belongs in the partner agreement,
which is what gates who holds `investors:write` at all.
- **Four dead timestamps left the share-purchase response**:
`acknowledged_at`, `payment_requested_at`, `payment_due_at` and
`certificate_sent_at`. A partner order never travels the lane that populates
them, so all four were permanently `null` — four keys inviting a progress UI
built on steps that never happen.
- **`membership_number` left the holdings response.** The partner read has no
cooperative-register lookup behind it, so the field was null on every response
— a key that documented something the endpoint could never return.
### Changed — response types
- **Money is now a decimal string on every partner response**, matching what the
webhook payloads already emitted. `price_per_share`, `entry_fee`, `total`,
`share_price`, `min_investment_amount` and `share_value` change from JSON
number to JSON string:
```diff
- "total": 200.00,
+ "total": "200.00",
```
A JSON number carries no precision guarantee, so most clients parse it into an
IEEE-754 double: the arithmetic is inexact, and the scale is lost — `100.00`
round-trips as `100`. The value was always exact on our side; the wire type was
throwing that away. Percentages, distances and tonnages are unaffected and
remain numbers.
The OpenAPI schema declares `type: string, format: decimal`, so a regenerated
client picks this up.
### Renamed
One value used to carry several names across the contract, which is how a
generated SDK ends up with distinct types for one entity and the mismatch
surfaces as an ownership `404` in production. Each concept now has exactly one
name, everywhere:
| Concept | Name | Appears in |
|---|---|---|
| The managed investor | `investor_id` | Provision response, profile response, purchase response, holdings, webhook payloads — and as the `investorId` path segment |
| The order | `share_purchase_id` | Share-purchase responses and webhook payloads — and as the `purchaseId` path segment |
The former `investor_account_id` and bare `id` keys are gone rather than
deprecated. Holdings moved to a partner-specific shape to make that possible
without changing the internal contract.
### Changed
- **Unknown request fields are rejected with `400 MALFORMED_REQUEST_BODY`**
instead of being discarded. A typo'd `sepa_debitor_iban` used to be dropped
silently and the order booked against the profile IBAN with a `201` — no
signal at all that the account you named was not the one debited. The response
names the field it could not place.
- **Value-level rejections name the field.** A bad IBAN checksum, an unknown
`applicant_type`, an impossible birth date — each used to collapse into
`422 {"code":"VALIDATION_ERROR","detail":"The request could not be
processed."}` with nothing identifying the field. All of them now carry an
`errors` map keyed by the wire field name, as the error reference always
promised.
- **A partially filled profile section is refused, not dropped.** Sending
`street`, `house_number` and `postal_code` without `city` used to answer `200`,
discard the address, and clear whatever address was already stored. It is now
a `422` naming `city`. Omitting a section entirely is still fine — that is what
makes `PUT …/profile` usable as partial progress.
- **`401` responses carry a body.** Previously the status and the
`WWW-Authenticate` header arrived with an empty body, making the `401` the one
error you could not branch on. It is now a full RFC 9457 document with
`code: UNAUTHENTICATED`.
- **The purchase `409` split.** A precondition that the platform could not derive
now returns `PURCHASE_DEFAULTS_UNAVAILABLE` with a `detail` naming the exact
missing default, instead of being folded into the generic
`INVESTOR_PROFILE_INCOMPLETE`.
- **`Idempotency-Key` is scoped per investor.** Reusing one key across two of
your customers used to match on commercial terms alone, replay the *first*
customer's order, and read it back through the second customer's account —
a create that answered `404` and booked nothing. It is now an honest `409`.
- **Incoherent catalogue queries are refused.** A `near_lat`/`near_lng` search
sorts by distance and applies no filters, so combining it with `city`, `sort`
or a price range is a `422` naming the parameters that could not be honoured,
rather than a `200` that quietly ignored half the query. A lone `near_lat` is
likewise refused instead of discarded.
- **`/v1` requires partner credentials.** The catalogue endpoints previously also
accepted any authenticated portal user. Nothing that should have been reaching
them is affected — partner clients are unchanged.
### Added
- **`GET /v1/investors?partner_customer_ref=…`** resolves your own customer
reference back to an investor. Recovering that mapping previously meant
re-POSTing to `/v1/investors` and reading the replay — a write used as a
lookup, and a `409` if any detail had drifted.
- **`missing_profile_fields`** on the investor and profile responses names
exactly which fields stand between the profile and a placed order. It is empty
precisely when `is_profile_complete` is `true`. An `is_profile_complete: false`
no longer has to be diagnosed one `409` at a time.
- **Three new webhook events** — `share_purchase.approved`,
`share_purchase.rejected` and `share_purchase.cancelled`. Every transition a
partner order can make now emits an event, so the absence of one genuinely
means "nothing has happened yet". Previously a rejected order — which is final
— was indistinguishable from one still in the queue, which forced every
integrator to run a reconciliation poller anyway.
- **`terminal` on every webhook payload**, saying whether anything further will
arrive for that order. Branch on it rather than hard-coding which types are
final. `share_purchase.rejected` additionally carries a human-readable
`reason`.
- **Request schemas state what they require.** Every field on the partner request
bodies now carries a description, an example, and its real bounds, so
required-vs-optional is visible in the reference without reading between the
lines. `share_count` gained an upper bound.
- **Sequence diagrams.** [The investing flow](./investing-flow) now shows each
phase as a diagram, and the [introduction](./intro) shows the whole
integration in one.
- **Partners may take over investor email.** valueverde emails managed investors
directly on order transitions by default, as it always has. A white-label
partner can now ask partner support to turn that off and notify its own
customers instead.
### Fixed
- **The published cooperative-detail schema was the wrong one.** A schema-name
collision meant the spec described an internal CO₂ shape in place of the
partner one — six keys the endpoint never emits, and none of the three it
does. Generated SDKs deserialised those to `null` silently. Five partner
schemas were affected; all now have distinct names, and an architecture test
fails the build if another collision is introduced.
## v1.3.0 — 2026-08-15
Everything here comes from one source: a partner built a reference integration against these docs with no help from us, and wrote down every place the API knew the answer and did not say it.
### Fixed
- **Omitting `scope` now returns every scope your client holds**, as the documentation always said it did. It previously minted a token with *no* scopes — `200`, a valid token, no `scope` field — and the failure surfaced later as an unexplained `403` on a different endpoint. If you added `scope=…` purely to work around this, you can drop it again.
- **`400 invalid_scope` names the scope it refused**, and lists the ones your client does hold, instead of failing opaquely. Discovering a credential's reach no longer requires one token request per documented scope.
- **A scope-related `403` says so.** It now carries `code: INSUFFICIENT_SCOPE`, a `required_scopes` array, and `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. A `403` with `code: FORBIDDEN` is a different failure — one that adding scopes will not fix — and the two are finally distinguishable. See the [error reference](./error-reference).
- **`representation_authorization` is genuinely optional**, defaulting to `false`. It was in the schema's `required` list alongside three consents that must be `true`, which read as "set this to true" — and `true` is invalid for a private individual. Nothing changes for callers already sending `false`.
- **Scope changes no longer rotate your credentials.** Granting a client another scope used to mean issuing a new `client_id` / `client_secret`. We now widen the existing client in place; ask partner support and nothing in your deployment has to move.
- **The spec lists staging first.** Its only `servers` entry was production, where `/v1` is not live — so every example and every "Try it" pointed at a host the integration could not be built against.
### Added
- **`GET /v1/me`** — the calling client's `client_id`, `organization_id`, the scopes on the presented token, and the full granted set. Requires no scope, deliberately: a client that needed one could not use it to find out which ones it holds.
- **Webhooks are in the spec**, as OpenAPI 3.1 `webhooks` with typed payloads for [`share_purchase.submitted`](./api/on-share-purchase-submitted) and [`share_purchase.settled`](./api/on-share-purchase-settled) — envelope, per-event `data` schemas, and the three delivery headers. The [webhooks page](./webhooks) now carries the same field-by-field reference.
### Corrected documentation
- **`share_purchase.settled` carries no `total` or `total_currency`.** The example on the webhooks page showed them; the delivery has never included them, because the settlement transition does not restate the amount. Take it from the `submitted` event or read the order.
- `REPRESENTATION_CONSENT_REQUIRES_COMPANY` is documented, under 422, with its precondition spelled out. It was reachable on `/v1` and appeared nowhere in a page that presents itself as the complete list.
- The `403` section previously promised a `WWW-Authenticate` header that was not being sent. It is sent now, so the page and the response agree.
- `/.well-known/jwks.json` and the `jwks_uri` the discovery document advertises (`/oauth2/jwks`) are the same key set at two paths. Both are supported; neither is more canonical.
## v1.2.0 — 2026-08-12
`/v1` gets its own response types, and stops asking you for things we already know. Breaking changes are grouped first because they are the ones that need action.
### Breaking
- **Fields that never carried a value are gone.** `risk_band`, `suitability_warning`, `applicant` and `user_id` were published but could never reach a partner — `/v1` served internal types. It now has dedicated ones, so those fields no longer exist rather than arriving forever-null.
- **`publication_status` and `is_published` removed.** Only published cooperatives are ever returned on `/v1`, so both were constants.
- **Optional fields now arrive as explicit `null`** instead of being omitted. A missing key now unambiguously means "your API version does not have this field"; previously you could not tell that from "this record has no value".
- **The cooperative detail returns `projects_by_type` instead of `projects`.** Pass `?expand=projects` for project-level records. Grouping and capacity rules are documented in [Getting started](./intro).
- **The profile response returns `iban_masked`**, not `iban`. It also drops `promo_code` and `document_url`.
- **The purchase payload no longer accepts `terms_consent_given_at` or `privacy_consent_given_at`.** Both carry over from the investor's profile; only `statute_consent_given_at` is collected per purchase.
- **`electronic_communication_consent_given_at` removed** from request and response. Nothing ever read it.
- **Detail images renamed** to `hero_image_url` and `logo_image_url`, matching the card. The old `image_url` / `logo_url` are still emitted for one release.
### Added
- `energy_category` on the energy mix — a controlled `solar` / `wind` / `heat` / `chp` / `hydro` / `biomass` / `other` value beside the free-text `energy_type`, so you no longer have to normalise German labels yourself.
- **`ETag` and `Cache-Control` on both catalogue reads.** Send `If-None-Match` for a `304`. See [Pagination & caching](./pagination-and-caching).
- `POST /v1/investors` accepts an optional inline `profile` and `kyc` block — provision and complete in one call. A replay carrying a *different* profile returns `409` rather than discarding it.
- `include_coordinates=true` on the catalogue list, for partners rendering a map.
- Optional per-consent capture timestamps on the profile write, so consent taken in your UI is recorded at the moment you captured it rather than the moment we received the call.
- **[Webhooks are documented](./webhooks).** The subsystem already existed and was already signing deliveries — it was simply never written up, so no partner could discover it. Covers `share_purchase.submitted` / `share_purchase.settled`, the HMAC verification recipe, and the retry schedule. Registration is still done by us on request.
### No longer required
Required fields across the three calls before a purchase drop from about 31 to 19. Each of these stays accepted as an override:
| Field | Now defaults to |
|---|---|
| `bic`, `bank_institution` | derived from the IBAN |
| `account_holder` | the profile name, or the company name |
| `applicant_type` | the profile's company affiliation |
| `sepa_debtor_iban` | the profile IBAN |
| `sepa_mandate_reference` | issued server-side, e.g. `VV-4RT9-K2WM-7BXP` |
`birth_date` replaces the `birth_day` / `birth_month` / `birth_year` triple; the integers are accepted for one release.
### Corrected documentation
These were wrong before, not changed now:
- Requesting a scope your client was not granted **fails the token request** with `invalid_scope`. It does not silently drop the extra scope.
- `GET /v1/cooperatives/{id}` requires `cooperatives:read` **and** `projects:read`. The spec previously advertised only the first, so a correctly-scoped-looking token got a `403`.
- The `share-purchases` and `holdings` list endpoints return the paged envelope. They never returned bare arrays.
## v1.1.0 — 2026-05-26
Adds the partner-mediated investing flow and standardises the documented contract on the `/v1` path prefix.
### Endpoints
| Method | Path | Tag |
|---|---|---|
| `POST` | `/v1/investors` | Managed Investors |
| `GET` | `/v1/investors/{investorId}` | Managed Investors |
| `PUT` | `/v1/investors/{investorId}/profile` | Managed Investors |
| `POST` | `/v1/investors/{investorId}/share-purchases` | Investments |
| `GET` | `/v1/investors/{investorId}/share-purchases` | Investments |
| `GET` | `/v1/investors/{investorId}/share-purchases/{purchaseId}` | Investments |
| `POST` | `/v1/investors/{investorId}/share-purchases/{purchaseId}/cancel` | Investments |
| `GET` | `/v1/investors/{investorId}/holdings` | Investments |
### Authentication
- New scopes: `investors:read`, `investors:write`, `applications:read`, `applications:write`, `portfolio:read`.
### Behaviour
- **Write endpoints** are now part of the contract. `POST /v1/investors/{investorId}/share-purchases` accepts an optional `Idempotency-Key` header; provisioning is idempotent on `partner_customer_ref` (`200` replay vs `201` create).
- Commercial terms on a share purchase are frozen server-side — the request carries no price fields. Debtor IBANs are returned masked.
- New error codes: `MANAGED_INVESTOR_NOT_FOUND`, `SHARE_PURCHASE_NOT_FOUND` (404); `INVESTOR_PROFILE_INCOMPLETE`, `KYC_ATTESTATION_REQUIRED`, `SHARE_PURCHASE_ILLEGAL_STATE`, `IDEMPOTENCY_KEY_CONFLICT` (409).
- `share-purchases` and `holdings` list endpoints return the paged envelope, like every other list endpoint.
### Paths
- The documented contract now uses the `/v1` prefix throughout (`/v1/cooperatives`, `/v1/cooperatives/{id}`).
## v1.0.0 — 2026-05-04
Initial public Partner API.
### Endpoints
| Method | Path | Tag |
|---|---|---|
| `GET` | `/cooperatives` | Cooperatives |
| `GET` | `/cooperatives/{id}` | Cooperatives |
### Authentication
- **OAuth2 client_credentials** at `POST /oauth2/token`. RS256-signed access tokens, 15-minute TTL.
- JWKS published at `/.well-known/jwks.json`. Discovery document at `/.well-known/oauth-authorization-server`.
- Scopes: `cooperatives:read`.
### Response shape
- Paginated lists use `{items, page, size, total_items, total_pages, has_next, has_previous, sort}`. `page` is one-based.
- All field names use `snake_case`.
### Error envelope
- RFC 9457 Problem Details, served as `application/problem+json`.
- Standard fields: `type`, `title`, `status`, `detail`, `instance`.
- Custom fields: `code` (machine-readable, stable), `trace_id` (correlation id), `errors` (per-field detail on `422 VALIDATION_ERROR` only).
### Headers
- Every response carries `X-Request-Id` (echoes a client-supplied id when present, otherwise generated).
- HSTS, frame-deny, and strict referrer policy are set on every response.