Skip to main content

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.

WhatURL
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 }
}
}
WasIs
street, house_number, postal_code, cityaddress.*
iban, bic, bank_institution, account_holderbank_account.iban, .bic, .institution, .account_holder
company_name, legal_form, company_tax_idcompany.name, company.legal_form, company.tax_id
terms_consent + terms_consent_given_atconsents.terms.{given, given_at}
privacy_consent + privacy_consent_given_atconsents.privacy.{given, given_at}
data_sharing_consent + data_sharing_consent_given_atconsents.data_sharing.{given, given_at}
representation_authorization + ..._given_atconsents.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

WasIs
sepa_debtor_iban, sepa_mandate_reference, sepa_mandate_signed_atsepa_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:

    - "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:

ConceptNameAppears in
The managed investorinvestor_idProvision response, profile response, purchase response, holdings, webhook payloads — and as the investorId path segment
The ordershare_purchase_idShare-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 eventsshare_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 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 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.
  • 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 and share_purchase.settled — envelope, per-event data schemas, and the three delivery headers. The webhooks page 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.
  • 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.
  • 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. 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:

FieldNow defaults to
bic, bank_institutionderived from the IBAN
account_holderthe profile name, or the company name
applicant_typethe profile's company affiliation
sepa_debtor_ibanthe profile IBAN
sepa_mandate_referenceissued 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

MethodPathTag
POST/v1/investorsManaged Investors
GET/v1/investors/{investorId}Managed Investors
PUT/v1/investors/{investorId}/profileManaged Investors
POST/v1/investors/{investorId}/share-purchasesInvestments
GET/v1/investors/{investorId}/share-purchasesInvestments
GET/v1/investors/{investorId}/share-purchases/{purchaseId}Investments
POST/v1/investors/{investorId}/share-purchases/{purchaseId}/cancelInvestments
GET/v1/investors/{investorId}/holdingsInvestments

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

MethodPathTag
GET/cooperativesCooperatives
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.