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.
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.
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:
{
"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 ofbirth_dateand 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
kycblock onPOST /v1/investorsandPUT /v1/investors/{investorId}/profile, thekyc_attestedfield on the investor response, and the409 KYC_ATTESTATION_REQUIREDerror 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
409discoverable 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 holdsinvestors:writeat all. -
Four dead timestamps left the share-purchase response:
acknowledged_at,payment_requested_at,payment_due_atandcertificate_sent_at. A partner order never travels the lane that populates them, so all four were permanentlynull— four keys inviting a progress UI built on steps that never happen. -
membership_numberleft 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_amountandshare_valuechange from JSON number to JSON string:- "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.00round-trips as100. 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_BODYinstead of being discarded. A typo'dsepa_debitor_ibanused to be dropped silently and the order booked against the profile IBAN with a201— 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 into422 {"code":"VALIDATION_ERROR","detail":"The request could not be processed."}with nothing identifying the field. All of them now carry anerrorsmap keyed by the wire field name, as the error reference always promised. -
A partially filled profile section is refused, not dropped. Sending
street,house_numberandpostal_codewithoutcityused to answer200, discard the address, and clear whatever address was already stored. It is now a422namingcity. Omitting a section entirely is still fine — that is what makesPUT …/profileusable as partial progress. -
401responses carry a body. Previously the status and theWWW-Authenticateheader arrived with an empty body, making the401the one error you could not branch on. It is now a full RFC 9457 document withcode: UNAUTHENTICATED. -
The purchase
409split. A precondition that the platform could not derive now returnsPURCHASE_DEFAULTS_UNAVAILABLEwith adetailnaming the exact missing default, instead of being folded into the genericINVESTOR_PROFILE_INCOMPLETE. -
Idempotency-Keyis 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 answered404and booked nothing. It is now an honest409. -
Incoherent catalogue queries are refused. A
near_lat/near_lngsearch sorts by distance and applies no filters, so combining it withcity,sortor a price range is a422naming the parameters that could not be honoured, rather than a200that quietly ignored half the query. A lonenear_latis likewise refused instead of discarded. -
/v1requires 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/investorsand reading the replay — a write used as a lookup, and a409if any detail had drifted. -
missing_profile_fieldson the investor and profile responses names exactly which fields stand between the profile and a placed order. It is empty precisely whenis_profile_completeistrue. Anis_profile_complete: falseno longer has to be diagnosed one409at a time. -
Three new webhook events —
share_purchase.approved,share_purchase.rejectedandshare_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. -
terminalon 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.rejectedadditionally carries a human-readablereason. -
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_countgained an upper bound. -
Sequence diagrams. The investing flow now shows each phase as a diagram, and the introduction 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
nullsilently. 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
scopenow 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, noscopefield — and the failure surfaced later as an unexplained403on a different endpoint. If you addedscope=…purely to work around this, you can drop it again. 400 invalid_scopenames 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
403says so. It now carriescode: INSUFFICIENT_SCOPE, arequired_scopesarray, andWWW-Authenticate: Bearer error="insufficient_scope", scope="…". A403withcode: FORBIDDENis a different failure — one that adding scopes will not fix — and the two are finally distinguishable. See the error reference. representation_authorizationis genuinely optional, defaulting tofalse. It was in the schema'srequiredlist alongside three consents that must betrue, which read as "set this to true" — andtrueis invalid for a private individual. Nothing changes for callers already sendingfalse.- 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
serversentry was production, where/v1is 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'sclient_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
webhookswith typed payloads forshare_purchase.submittedandshare_purchase.settled— envelope, per-eventdataschemas, and the three delivery headers. The webhooks page now carries the same field-by-field reference.
Corrected documentation
share_purchase.settledcarries nototalortotal_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 thesubmittedevent or read the order.REPRESENTATION_CONSENT_REQUIRES_COMPANYis documented, under 422, with its precondition spelled out. It was reachable on/v1and appeared nowhere in a page that presents itself as the complete list.- The
403section previously promised aWWW-Authenticateheader that was not being sent. It is sent now, so the page and the response agree. /.well-known/jwks.jsonand thejwks_urithe 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,applicantanduser_idwere published but could never reach a partner —/v1served internal types. It now has dedicated ones, so those fields no longer exist rather than arriving forever-null. publication_statusandis_publishedremoved. Only published cooperatives are ever returned on/v1, so both were constants.- Optional fields now arrive as explicit
nullinstead 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_typeinstead ofprojects. Pass?expand=projectsfor project-level records. Grouping and capacity rules are documented in Getting started. - The profile response returns
iban_masked, notiban. It also dropspromo_codeanddocument_url. - The purchase payload no longer accepts
terms_consent_given_atorprivacy_consent_given_at. Both carry over from the investor's profile; onlystatute_consent_given_atis collected per purchase. electronic_communication_consent_given_atremoved from request and response. Nothing ever read it.- Detail images renamed to
hero_image_urlandlogo_image_url, matching the card. The oldimage_url/logo_urlare still emitted for one release.
Added
energy_categoryon the energy mix — a controlledsolar/wind/heat/chp/hydro/biomass/othervalue beside the free-textenergy_type, so you no longer have to normalise German labels yourself.ETagandCache-Controlon both catalogue reads. SendIf-None-Matchfor a304. See Pagination & caching.POST /v1/investorsaccepts an optional inlineprofileandkycblock — provision and complete in one call. A replay carrying a different profile returns409rather than discarding it.include_coordinates=trueon 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. 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}requirescooperatives:readandprojects:read. The spec previously advertised only the first, so a correctly-scoped-looking token got a403.- The
share-purchasesandholdingslist 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-purchasesaccepts an optionalIdempotency-Keyheader; provisioning is idempotent onpartner_customer_ref(200replay vs201create). - 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-purchasesandholdingslist endpoints return the paged envelope, like every other list endpoint.
Paths
- The documented contract now uses the
/v1prefix 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}.pageis 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 on422 VALIDATION_ERRORonly).
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.