Skip to main content

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:

SectionFields
Namefirst_name, last_name
Birth datebirth_date
Contactemail (the top-level one on the provision call)
Addressaddress.street, address.house_number, address.postal_code, address.city
Bankingbank_account.iban
Consentsconsents.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

FieldWhy you can omit it
bank_account.bicDerived from the IBAN
bank_account.institutionDerived from the IBAN
bank_account.account_holderDefaults 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:

FieldDefault
applicant_typeThe investor's company affiliation: affiliated ⇒ company, otherwise private
sepa_mandate.debtor_ibanThe profile IBAN. Send it only if the debtor account genuinely differs
sepa_mandate.referenceIssued 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 body409 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:

StatusMeaningTerminal
submittedAccepted into the queue. Terms frozen.no
approvedPassed review. Payment is being collected.no
settledDone. The holding exists.yes
rejectedRefused, with a rejection_reason.yes
cancelledWithdrawn — 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.