Guides

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
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"
  }'
200 · response
{
  "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.

EventWhen
order_status_updatedAn order transitioned to a new status. Fires on every change.
order_completedAn order reached a terminal completed state.
deposit_completedA deposit was credited in full.
deposit_partially_completedA deposit was credited for part of the expected amount.
deposit_method_createdA receiving method (bank account or wallet) was created for an account. It may still be provisioning.
deposit_method_activatedA receiving method became active and its instructions (IBAN / wallet address) are available in data.depositInfo.
balance_updatedAn account balance changed.
openbanking_status_updatedAn open banking payment session changed status.
verification_status_updatedAn entity's KYC or KYB verification status changed. Inspect data.verificationType (KYC | KYB).
pending_payment_created_or_detectedAn expected inbound payment was created or first detected.
pending_payment_updatedA pending payment changed status.
support_ticket_createdA support ticket was opened.
support_ticket_updatedA support ticket changed status.
support_ticket_message_addedA new message was added to a support ticket.
kyc_completedDeprecated. 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 event — 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.

verify X-VirtuaBroker-Signature-256
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);
});

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.

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.

← Prev
Payment lifecycle