The investing flow
The 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.
Phase 1 — Authenticate
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.
Phase 2 — Browse the catalogue
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.
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:
"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
The four fields
{
"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:
{
"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
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.