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.
export VV_HOST="https://api.staging.valueverde.de"
export VV_CLIENT_ID="…" # your staging credentials, from partner support
export VV_CLIENT_SECRET="…"
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
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.
2. Check what your credential can do
curl -sS "$VV_HOST/v1/me" -H "Authorization: Bearer $VV_TOKEN"
{
"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
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}'
{
"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.
4. Register the investor
One call creates the person and fills in their profile. Everything the platform can derive, it derives.
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, nobank_account.institution— derived from the IBAN. - No
bank_account.account_holder— defaults to the profile name. - No
emailinsideprofile— the top-level one is the investor's address. Sending it in both places is a422. - No
given_aton 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 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
curl -sS "$VV_HOST/v1/investors/$VV_INVESTOR_ID" \
-H "Authorization: Bearer $VV_TOKEN" | jq '{is_profile_complete, missing_profile_fields}'
{
"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
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
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}'
{
"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.
Polling. Re-read the order. Poll no faster than once a minute; nothing moves in seconds.
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:
curl -sS "$VV_HOST/v1/investors/$VV_INVESTOR_ID/holdings" \
-H "Authorization: Bearer $VV_TOKEN" | jq '.items'
[
{
"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 for the states, the gates, and what each call actually requires.
- Wire up Webhooks so you are not polling in production.
- Read Errors before you write your retry logic, not after.