Webhooks
Polling a share purchase until it settles works, but it wastes both our capacity and yours. Register an endpoint and we push the transition instead.
Deliveries are signed, retried with exponential backoff, and carry a stable event id so you can dedupe.
The machine-readable contract lives in the spec's webhooks block. These describe operations your endpoint implements and we call; they are not routes on this API, and no generated client will produce them for you.
Getting an endpoint registered
There is no self-service registration route yet. Send the HTTPS URL you want deliveries posted to, to partners@valueverde.de, and we will register it against your organisation and return the signing secret.
The signing secret is shown exactly once, at registration. It is derived, never stored, and cannot be read back — if you lose it, we rotate, and the old secret stops verifying. Put it in your secret manager before you close the ticket.
Your URL must be https and must resolve to a public address. We reject loopback, link-local, the cloud metadata address, and private ranges (RFC1918, unique-local, CGNAT) — both at registration and again at delivery time, so a hostname that later resolves to an internal address is still refused.
Event types
type | Fires when | Terminal |
|---|---|---|
share_purchase.submitted | An order you placed has been accepted into the queue. | no |
share_purchase.approved | The order passed review. Payment is being collected. | no |
share_purchase.settled | The order settled — the point the investor's holdings change. | yes |
share_purchase.rejected | The order was refused, with a reason. | yes |
share_purchase.cancelled | The order was withdrawn, including by your own call to the cancel endpoint. | yes |
Every transition a partner order can make now emits an event, which means the
absence of an event genuinely means "nothing has happened yet". Previously
only submitted and settled fired, so a rejected order — which is final —
looked exactly like one still in the queue, and every integrator had to run a
reconciliation poller anyway.
Branch on terminal, not on the type. Every data object carries a
terminal boolean saying whether anything further will arrive for that order.
An endpoint that switches on terminal keeps working when a new type is added;
one that hard-codes a list of final types does not.
Handle unknown types by ignoring them. New event types are additive and will start arriving without a major version bump. An endpoint that returns a non-2xx for a type it does not recognise will collect retries and then dead-letters for events it never needed.
The envelope
Every delivery has the same four top-level fields. Only data varies by type.
| Field | Type | Notes |
|---|---|---|
id | string (uuid) | Event id, stable per (share purchase, transition). Every retry and every redelivery of the same event carries the same value. Store it; treat a repeat as a no-op. |
type | string enum | One of the five in the table above. New values may be added. |
created_at | string (date-time) | When the transition occurred — ISO-8601 with offset — not when the delivery was attempted. A retried delivery keeps the original value. |
data | object | Type-specific body, described below. |
Field order in the raw body is fixed (id, type, created_at, data). That only matters if you are debugging a signature mismatch — verify against the raw bytes and the order is irrelevant.
Payloads
share_purchase.submitted
{
"id": "8f14e45f-ceea-4e0a-9d3f-6c9b0f2a71c4",
"type": "share_purchase.submitted",
"created_at": "2026-08-12T09:14:22.031+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"share_count": 2,
"status": "submitted",
"terminal": false,
"total": "500.00",
"total_currency": "EUR"
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
share_purchase_id | string (uuid) | yes | The order. Matches share_purchase_id on GET …/share-purchases/{purchaseId}. |
investor_id | string (uuid) | yes | The managed investor — the investorId path segment on the investing endpoints. |
cooperative_id | string (uuid) | yes | The cooperative subscribed to. |
share_count | integer | yes | Shares in the order, ≥ 1. |
status | string | yes | Always submitted for this type. |
terminal | boolean | yes | Always false for this type — more is coming. |
total | string | yes | Total consideration as a decimal string, the same as everywhere else in the contract. Parse it as a decimal type. Server-computed and frozen at submission. |
total_currency | string | yes | ISO-4217 code, e.g. EUR. |
share_purchase.settled
{
"id": "1a2fd0c7-6b91-4a0c-8ce7-3d2f16c8b2a1",
"type": "share_purchase.settled",
"created_at": "2026-08-14T11:02:07.884+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"share_count": 2,
"status": "settled",
"terminal": true
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
share_purchase_id | string (uuid) | yes | |
investor_id | string (uuid) | yes | |
cooperative_id | string (uuid) | yes | |
share_count | integer | yes | |
status | string | yes | Always settled for this type. |
terminal | boolean | yes | Always true — this order is finished. |
settled carries no totalThe settlement transition does not restate the amount, so total and total_currency are absent from this payload — they are not null, they are not there. Take the amount from the submitted event you already stored, or read the order. A handler that assumes a uniform data shape across both types will throw on the second one.
share_purchase.approved
{
"id": "5c9a1e30-77bd-4c02-9f13-0a4e2b7d55c8",
"type": "share_purchase.approved",
"created_at": "2026-08-13T08:41:19.220+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"status": "approved",
"terminal": false
}
}
Progress, not an ending — settlement still follows. Useful if you want to tell your customer their order passed review rather than leaving them on "pending" for days.
Carries no share_count or total: approval does not restate the order. Read
them from the submitted event you stored, or from the order.
share_purchase.rejected
{
"id": "d31b7c0e-9a2f-4b88-8c41-6f0d9e3a1b72",
"type": "share_purchase.rejected",
"created_at": "2026-08-13T15:22:04.117+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"status": "rejected",
"terminal": true,
"reason": "bank details could not be verified"
}
}
| Field | Type | Required | Notes |
|---|---|---|---|
reason | string | no | Why the order was refused, in our words. Present when one was recorded. Surface it or map it — do not branch on it. The wording is written for a human and is not a stable enum. |
This is the event that lets you stop polling and tell your customer something true. It is final.
share_purchase.cancelled
{
"id": "7e2c4a91-3f05-4d6b-b0a8-91c4e2d70f13",
"type": "share_purchase.cancelled",
"created_at": "2026-08-13T09:05:41.664+02:00",
"data": {
"share_purchase_id": "3a1c9d84-2f77-4b21-9f0e-77b2c1d54e90",
"investor_id": "b0d2f8e1-5c3a-4a77-9c11-2e6b8d4f0a35",
"cooperative_id": "11111111-1111-4111-8111-111111111111",
"status": "cancelled",
"terminal": true
}
}
Fires for your own call to POST …/cancel too,
so a system that reacts only to webhooks stays consistent without special-casing
its own writes.
Headers
| Header | Contents |
|---|---|
Content-Type | application/json |
X-VV-Webhook-Signature | t=<unix-seconds>,v1=<hex-hmac> |
X-VV-Webhook-Id | Delivery-attempt id — changes on every retry |
X-VV-Webhook-Event | Event type, mirroring type in the body. Lets you route before parsing. |
Dedupe on the body's id, not on X-VV-Webhook-Id: the latter identifies the attempt, so two retries of one event carry two different values.
Verifying a delivery
The scheme is Stripe-style. The signed payload is the timestamp, a literal ., and the raw request body:
signed_payload = "{t}.{raw_body}"
expected = HMAC-SHA256(signing_secret, signed_payload) // hex
Compare expected against the v1= value, and reject the delivery if t is outside your tolerance window — five minutes is a sensible default. That timestamp is what stops a captured delivery being replayed at you later.
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance:
return False # too old — possible replay
signed = t.encode() + b"." + raw_body # raw bytes, not re-serialised JSON
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Two things that will silently break verification:
- Sign the raw body. If your framework parses the JSON and you re-serialise it, key order or whitespace shifts and the MAC will not match. Capture the bytes before parsing.
- Compare in constant time. Use
hmac.compare_digestor your language's equivalent, not==.
The v1 prefix versions the scheme. If we ever add v2, deliveries will carry both for a transition period — match on the prefix you support rather than assuming a single value.
Delivery, retries and failure
| Property | Value |
|---|---|
| Method | POST, Content-Type: application/json |
| Success | Any 2xx |
| Connect / read timeout | 5s each |
| Attempts | 6 |
| Backoff | Exponential, from 30s |
Anything that is not a 2xx — including a timeout, a TLS failure or a DNS failure — counts as a failed attempt and is retried. After the final attempt the delivery is dead-lettered and we stop.
Answer fast and process later. Because the timeout is 5 seconds and a slow response is indistinguishable from a broken one, acknowledge with a 2xx as soon as you have persisted the event, then do the work asynchronously. An endpoint that does its processing inline will eventually time out under load and start collecting retries for events it actually handled.
Return a non-2xx only when you genuinely want the delivery again.
A minimal receiver
from flask import Flask, request
app = Flask(__name__)
seen = set() # use a durable store in production
@app.post("/valueverde/webhooks")
def receive():
raw = request.get_data() # bytes, before any parsing
if not verify(raw, request.headers["X-VV-Webhook-Signature"], SECRET):
return "", 400 # not us — do not retry
event = json.loads(raw)
if event["id"] in seen: # stable per (purchase, transition)
return "", 200 # already handled; acknowledge anyway
seen.add(event["id"])
if event["type"] == "share_purchase.submitted":
enqueue_submitted(event["data"]) # has total / total_currency
elif event["type"] == "share_purchase.settled":
enqueue_settled(event["data"]) # has neither
# any other type: ignore, and still return 2xx
return "", 200
What webhooks do not replace
Webhooks are an optimisation, not a source of truth. They can be delayed by retries, and a dead-lettered delivery is simply lost. Reconcile against GET /v1/investors/{investorId}/share-purchases on a schedule, and treat the API as authoritative when the two disagree.