Your billing system already knows the moment a trial converts, a subscription lapses, or an invoice gets paid. The problem is that this knowledge lives inside your platform, and the tools your team actually works in — the CRM, the spreadsheet, the Slack channel, the onboarding sequence — do not. Someone ends up copying data by hand, or writing a nightly sync job that runs six hours behind reality.
Outbound webhooks close that gap. VBWD emits a domain event for nearly everything that happens in the system, and the outbound webhooks feature turns those events into HTTP POSTs that land wherever you point them: a Zapier catch hook, an n8n webhook node, a CRM endpoint, or a small script you host yourself. This article walks through how it works, how to verify deliveries, what fires, and where the honest limits are.
VBWD is built around an internal event bus. When a subscription renews, a payment is captured, or a user registers, the responsible service publishes an event onto that bus. Plugins and core subscribers listen and react — that is how the platform stays modular. The webhook relay is one more subscriber, except it subscribes to all events rather than a single named one.
Concretely: the relay does a subscribe-all against the bus. Every event that flows through — subscription lifecycle transitions, invoice state changes, payment captures, user events, and events published by plugins you have installed — is a candidate for delivery. You do not wire up each event type individually in code. You register a webhook endpoint through the admin API, and the relay fans matching events out to it.
This matters for integrators because it means the surface area is broad by default. If a plugin you enabled publishes a new event type, your existing webhook can receive it without a platform change. VBWD stays source-available and self-hosted — Python/Flask, PostgreSQL, Vue 3, Docker — so the event bus and the relay run inside your own infrastructure, not a third party's. The architecture overview covers how the agnostic core and its plugin layer keep these seams clean.
Webhook endpoints are managed through admin CRUD at /admin/webhooks. You create a webhook by giving it a target URL; you can list, update, and delete them the same way you would manage any other admin resource. Each webhook is a stored record — target URL, its signing secret, and its delivery configuration — persisted in Postgres like the rest of the platform state.
Every delivery is HMAC-signed. When the relay POSTs a payload, it computes an HMAC over the request body using the webhook's shared secret and attaches it as a header. This is the part that integrators most often get wrong: the signature is not decoration, it is the whole security model. Your endpoint URL will, over time, end up in logs, proxy configs, and browser histories. Anyone who learns the URL can POST fake events to it. The HMAC is how your receiver tells a real VBWD delivery from a forged one. Verify it before you trust a single field in the body.
A delivered payload looks roughly like this — a domain event wrapped in a small envelope:
{
"event": "subscription.renewed",
"id": "evt_9f2c1a7b",
"created_at": "2026-07-15T09:14:03Z",
"data": {
"subscription_id": 4821,
"user_id": 1337,
"tarif_plan_slug": "pro-monthly",
"status": "active",
"period_end": "2026-08-15T09:14:03Z"
}
}The exact fields depend on the event type and the plugins that produced it, but the shape is consistent: an event name you can branch on, a stable event id you can deduplicate against, a timestamp, and a data object.
Do this first, before any parsing or business logic. Read the raw request body as bytes, recompute the HMAC with your shared secret, and compare it to the header the relay sent — using a constant-time comparison so you are not leaking timing information. Here is a generic Python receiver:
import hmac, hashlib
from flask import Flask, request, abort
WEBHOOK_SECRET = b"your-shared-secret"
app = Flask(__name__)
@app.post("/vbwd-hook")
def receive():
raw_body = request.get_data() # exact bytes, not re-serialized
sent_sig = request.headers.get("X-VBWD-Signature", "")
expected = hmac.new(
WEBHOOK_SECRET, raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, sent_sig):
abort(401) # forged or corrupted; drop it
event = request.get_json()
handle_event(event) # only now do you trust it
return "", 200
Two details that bite people. First, sign over the raw bytes — if you parse the JSON and re-serialize it before computing the HMAC, key ordering or whitespace differences will make a valid signature fail. Second, keep the secret out of source control; it belongs in an environment variable or a secrets manager, the same place your database credentials live. Check the header name your build actually sends against the integration docs — the exact header key is part of the delivery contract.
Because the relay subscribes to everything, the catalogue is effectively the union of all events the core and your enabled plugins publish. The categories you will reach for most:
The practical consequence: branch on the event field in your receiver and ignore what you do not need. Do not assume the set is fixed — treat unknown event names as a no-op rather than an error, so enabling a new plugin never breaks your integration. The plugin catalogue shows which plugins add which event streams.
Say you want every subscription renewal to update a deal record in your CRM and drop a note in a team channel. With n8n (the same shape applies to a Zapier catch hook):
/admin/webhooks, create a webhook with that URL as the target. Store the signing secret n8n will need.event. Only subscription.renewed continues; everything else exits quietly.id. n8n's static data or a small lookup table works. This step is not optional — see the limits below.user_id; another posts a message to Slack. Both read from data in the verified payload.The whole thing is roughly fifteen minutes of node wiring and no VBWD code changes. Because the relay is already publishing every renewal, you are subscribing to a stream that exists, not building a new one. VBWD's feature set lists the domain areas that emit events you can pipe this way.
Webhooks are simple to start and easy to get subtly wrong. The things you must plan for:
id and make repeat handling a no-op. Never treat "received a webhook" as "this happened exactly once."Outbound webhooks turn VBWD's internal event bus into an integration surface you control. Every subscription lifecycle event and other domain event can fire an HMAC-signed POST to a URL you register through admin CRUD; a subscribe-all relay handles delivery with retries and keeps delivery logs. On your side the job is small but non-negotiable: verify the signature, deduplicate on the event id, and keep a reconciliation fallback for outages that outlast the retry window. Do those three things and you can wire renewals into a CRM, payments into accounting, and signups into onboarding without a nightly sync job or a single line of copy-paste.
And because VBWD is self-hosted and source-available — BSL 1.1, free for commercial use while annual VBWD-attributable sales stay under the value of 6.7 BTC — the relay, the event bus, and every delivery log run inside your own stack, not a vendor's.
Ready to route your first event? Start in the admin webhooks panel at the integration docs, register an endpoint, and fire a test delivery into a Zapier or n8n catch hook.