Skip to main content

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="…"
tip

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, no bank_account.institution — derived from the IBAN.
  • No bank_account.account_holder — defaults to the profile name.
  • No email inside profile — the top-level one is the investor's address. Sending it in both places is a 422.
  • No given_at on 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.