Skip to main content

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

Registration is done by us, not by you

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

typeFires whenTerminal
share_purchase.submittedAn order you placed has been accepted into the queue.no
share_purchase.approvedThe order passed review. Payment is being collected.no
share_purchase.settledThe order settled — the point the investor's holdings change.yes
share_purchase.rejectedThe order was refused, with a reason.yes
share_purchase.cancelledThe 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.

FieldTypeNotes
idstring (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.
typestring enumOne of the five in the table above. New values may be added.
created_atstring (date-time)When the transition occurred — ISO-8601 with offset — not when the delivery was attempted. A retried delivery keeps the original value.
dataobjectType-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"
}
}
FieldTypeRequiredNotes
share_purchase_idstring (uuid)yesThe order. Matches share_purchase_id on GET …/share-purchases/{purchaseId}.
investor_idstring (uuid)yesThe managed investor — the investorId path segment on the investing endpoints.
cooperative_idstring (uuid)yesThe cooperative subscribed to.
share_countintegeryesShares in the order, ≥ 1.
statusstringyesAlways submitted for this type.
terminalbooleanyesAlways false for this type — more is coming.
totalstringyesTotal 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_currencystringyesISO-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
}
}
FieldTypeRequiredNotes
share_purchase_idstring (uuid)yes
investor_idstring (uuid)yes
cooperative_idstring (uuid)yes
share_countintegeryes
statusstringyesAlways settled for this type.
terminalbooleanyesAlways true — this order is finished.
settled carries no total

The 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"
}
}
FieldTypeRequiredNotes
reasonstringnoWhy 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

HeaderContents
Content-Typeapplication/json
X-VV-Webhook-Signaturet=<unix-seconds>,v1=<hex-hmac>
X-VV-Webhook-IdDelivery-attempt id — changes on every retry
X-VV-Webhook-EventEvent 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_digest or 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

PropertyValue
MethodPOST, Content-Type: application/json
SuccessAny 2xx
Connect / read timeout5s each
Attempts6
BackoffExponential, 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.