Webhooks
Subscribe to events instead of polling. When something changes — an order settles, a deposit arrives, a verification is approved — VirtuaBroker sends an HTTP POST with a signed JSON payload to an endpoint you control.
Webhooks are the recommended way to track long-running work. Combine them with order states so your system reacts the moment a payment reaches a terminal state.
Register an endpoint
Endpoints are managed with a small CRUD surface. When you create one you supply the secret (minimum 8 characters) — VirtuaBroker signs every delivery with it, and it is never returned by the API. Every endpoint receives all event types; there is no per-endpoint event filter.
- POST
/v1/webhooks— create an endpoint - GET
/v1/webhooks— list your endpoints - GET
/v1/webhooks/{id}— fetch one endpoint - PUT
/v1/webhooks/{id}— updatename,url,secret, orisActive - DELETE
/v1/webhooks/{id}— remove an endpoint - POST
/v1/webhooks/resend— resend the latest event for an order or deposit
curl https://api.virtuabroker.com/v1/webhooks \ -H "Authorization: Bearer $VB_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Production", "url": "https://your-app.example.com/webhooks/vb", "secret": "a-strong-random-secret" }'
{
"id": "abc123def456",
"name": "Production",
"url": "https://your-app.example.com/webhooks/vb",
"isActive": true,
"createdAt": 1704067200000,
"updatedAt": 1704067200000
}
The secret is never returned — not at creation, not on reads. Keep it in a secret store — never in client code or version control. If you lose it, rotate it with a PUT to the endpoint.
Event catalog
Every endpoint receives all of the events below — branch on the type field of each delivery to route the ones you care about.
| Event | When |
|---|---|
order_status_updated | An order transitioned to a new status. Fires on every change. |
order_completed | An order reached a terminal completed state. |
deposit_completed | A deposit was credited in full. |
deposit_partially_completed | A deposit was credited for part of the expected amount. |
deposit_method_created | A receiving method (bank account or wallet) was created for an account. It may still be provisioning. |
deposit_method_activated | A receiving method became active and its instructions (IBAN / wallet address) are available in data.depositInfo. |
balance_updated | An account balance changed. |
openbanking_status_updated | An open banking payment session changed status. |
verification_status_updated | An entity's KYC or KYB verification status changed. Inspect data.verificationType (KYC | KYB). |
pending_payment_created_or_detected | An expected inbound payment was created or first detected. |
pending_payment_updated | A pending payment changed status. |
support_ticket_created | A support ticket was opened. |
support_ticket_updated | A support ticket changed status. |
support_ticket_message_added | A new message was added to a support ticket. |
kyc_completed | Deprecated. Legacy verification event — use verification_status_updated instead. |
Prefer verification_status_updated for verification changes — it covers KYC and KYB across every state. The legacy kyc_completed event is deprecated and will be removed in a future version.
Payload shape
Every delivery shares the same envelope: type selects the event, api_version pins the payload contract (currently "1.0"), and data carries the affected resource. The id field means one of two things depending on the event — read it carefully before using it as a key:
- Entity events —
order_status_updated,order_completed,deposit_completed,deposit_partially_completed,deposit_method_created,deposit_method_activated,balance_updated. Hereidis the id of the affected entity (e.g. the order id), so it stays the same across every delivery for that entity. It is not a per-delivery identifier. - Verification & operational events —
verification_status_updated, thesupport_ticket_*andpending_payment_*events. Hereidis a random UUID unique to the delivery, anddatacarries its owneventIdandoccurredAt.
id is the order id
{
"id": "A1B2C3D4E5F6",
"type": "order_status_updated",
"api_version": "1.0",
"data": {
"orderId": "A1B2C3D4E5F6",
"status": "Processing"
}
}
For verification, the envelope id is a delivery UUID and the meaningful fields live in data. A KYC change looks like this; a KYB change carries emails (an array) instead of customerEmail. The status is one of the six external verification states (see Statuses).
verification_status_updated (KYC)
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"type": "verification_status_updated",
"api_version": "1.0",
"data": {
"eventId": "b2c3d4e5-6f70-4a1b-8c2d-3e4f5a6b7c8d",
"occurredAt": 1710000000000,
"verificationType": "KYC",
"customerEmail": "user@example.com",
"entityId": "entity-1",
"status": "APPROVED",
"previousStatus": "REVIEWING"
}
}
Verify the signature
Every delivery to an endpoint you registered with POST /v1/webhooks carries an X-VirtuaBroker-Signature-256 header: the string sha256= followed by a hex HMAC-SHA256 of the raw request body, keyed with the secret you supplied when creating the endpoint. Verify it before trusting the payload — recompute the value over the exact bytes you received and compare it to the header with a constant-time comparison.
import crypto from "node:crypto"; function verify(rawBody, header, secret) { const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(rawBody, "utf8") .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(header); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express — capture the RAW body, not a parsed object app.post("/webhooks/vb", express.raw({ type: "application/json" }), (req, res) => { const sig = req.header("X-VirtuaBroker-Signature-256"); if (!verify(req.body, sig, process.env.VB_WEBHOOK_SECRET)) { return res.status(400).send("invalid signature"); } const event = JSON.parse(req.body.toString("utf8")); res.sendStatus(200); });
import hashlib, hmac, os def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header) # Flask — request.get_data() returns the RAW bytes @app.post("/webhooks/vb") def webhook(): sig = request.headers.get("X-VirtuaBroker-Signature-256", "") if not verify(request.get_data(), sig, os.environ["VB_WEBHOOK_SECRET"]): return "invalid signature", 400 event = request.get_json() return "", 200
Shell check: printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$VB_WEBHOOK_SECRET" — the digest, prefixed with sha256=, must equal the X-VirtuaBroker-Signature-256 header.
Compute the HMAC over the raw request body, exactly as received. Re-serializing parsed JSON reorders keys and whitespace, which changes the bytes and breaks verification.
Signatures cover endpoints registered with POST /v1/webhooks. A legacy single-URL callback (the older configuration read and updated at /v1/webhook) is delivered unsigned and carries no X-VirtuaBroker-Signature-256 header. Register endpoints with /v1/webhooks to receive signed deliveries.
Respond & reconcile
Acknowledge fast, then do the work. Return a 2xx as soon as you've verified and persisted the delivery, and process it asynchronously.
Delivery is attempted once — there is no automatic retry or exponential backoff. If your endpoint is unreachable or returns a non-2xx, that event is not re-queued for you. To pull the most recent event for a completed order or deposit, call POST /v1/webhooks/resend with the entity's entityType and entityId. For anything you cannot afford to miss, treat a GET on the order or deposit as the source of truth and reconcile against it.
Handling duplicates & out-of-order deliveries
Make your handler idempotent: the same event can arrive more than once, and updates for one entity are not strictly ordered. How you deduplicate depends on the event family — the envelope id is only a safe key for some of them.
- Verification & operational events — deduplicate on
data.eventId(a per-event UUID). Process each uniqueeventIdonce and ignore repeats. - Entity events (
order_*,deposit_*,balance_updated,deposit_method_*) — the envelopeidis the entity id, so it repeats on every update for that entity and must not be used as a duplicate key. Instead compare the reporteddata.statusto what you have already recorded — and, when in doubt, a freshGETof the entity — and skip transitions you have already applied.
Do not deduplicate entity events on id + type. Because id is the stable entity id, that would discard every legitimate follow-up update for the same order or deposit.
Test locally
Your endpoint has to be reachable over the public internet. During development, expose your local server through an HTTP tunnel and register the resulting public URL as a webhook on stage (https://api-stage.virtuabroker.com). Trigger real events with a stage flow and watch them land.
See Testing & sandbox for end-to-end flows that emit the events above, plus tips for replaying deliveries.