Guide to VGS (Very Good Security) Webhooks: Features and Best Practices
VGS webhook notifications tell you when something changes in your VGS (Very Good Security) organisation: a vault route is created, changed or deleted, a user logs in or changes their MFA, or the proxy starts hitting upstream errors. If you use VGS to tokenise and proxy sensitive data, these notifications give your security and platform teams an audit trail and early warning, without polling the dashboard.
This guide covers how VGS notifications are set up and signed, the events available, what VGS does and doesn't document about the payload, and the practices that keep a VGS receiver reliable. VGS's payment products have their own notifications, documented separately, and are not covered here.
What are VGS webhooks?
VGS sends webhook notifications for events in your organisation. You add an HTTPS endpoint in the VGS Dashboard, choose events, and choose the sources they apply to: tenants, users or organisations, each marked as sandbox or live. Notifications are available to activated organisations.
Each request carries a vgs-signature header of the form t=<timestamp>,v0=<signature>. The signature is a hex HMAC-SHA256 of the timestamp, a period and the raw body, keyed with a secret VGS generates for each webhook integration. VGS's prose calls it "a sha256 hash of a raw request body", but its code sample shows the full construction, with the timestamp included.
Some frequent events are grouped. For proxy.upstream_error and alias.reveal_failed, VGS sends only the 1st, 10th, 100th, 1000th and so on within a 5-minute window, with an occurrence count of how many were folded together.
VGS webhook features
| Feature | Details |
|---|---|
| Availability | Activated organisations |
| Configuration | VGS Dashboard, Organization Settings > Notifications |
| Subscription | Events plus sources (tenants, users, organisations; sandbox or live) |
| Signature header | vgs-signature: t=<unix seconds>,v0=<hex> |
| Scheme | HMAC-SHA256, hex |
| Signed content | <t>.<raw body> |
| Secret | One per webhook integration, generated by VGS |
| Suggested window | 60 seconds in VGS's sample, which VGS calls "very strict" |
| Attempts | 8: immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours |
| After the last attempt | The delivery is cancelled |
| Disabled endpoints | Notifications generated while disabled are not retried |
| Grouping | proxy.upstream_error and alias.reveal_failed, per 5-minute window |
| Replay | On request through VGS support |
| Limit | 20 integrations per organisation by default |
| Source IPs | 44.228.126.217, 50.112.21.217, 52.24.126.164, 54.148.139.208, 54.164.207.221, 54.90.7.123, 2600:1f24:64:8000::/52, 2600:1f28:37:4000::/52 |
Common events
VGS lists 12 events. For each one it names the details the notification carries.
| Event | Scope | Details |
|---|---|---|
route.created | Vault | route_id, route_name, environment |
route.updated | Vault | route_id, route_name, environment |
route.delete | Vault | route_id, route_name, environment |
vault.created | Organisation | vault_id, vault_name, environment |
user.permissions_updated | User | user_email, org_id, list of permissions |
user.permissions_deleted | User | user_email, org_id, list of permissions |
user.logged_in | User | user_ip, user_email |
user.password_updated | User | user_ip, user_email |
user.mfa_created | User | user_ip, user_email |
user.mfa_deleted | User | user_ip, user_email |
proxy.upstream_error | Vault | upstream_host, upstream_error, environment (grouped) |
alias.reveal_failed | Vault | environment, storage (grouped) |
Note that the route deletion event is route.delete, not route.deleted. VGS's grouping paragraph also mentions record.reveal_failed, which is not in the table, so it is unclear whether that is an older name for alias.reveal_failed.
VGS does not publish an example payload, so the field that carries the event name, the body's structure and even the content type are not documented. Capture a few real notifications, for example on a Hookdeck source, before you write parsing code, and code defensively around missing fields.
Setting up VGS webhooks
- In the VGS Dashboard, go to Organization Settings > Notifications. If your organisation is not enabled for notifications, the page tells you to contact support.
- Click Create new and enter your HTTPS endpoint URL.
- Choose the events, and for each the sources it applies to.
- Save, and store the integration's secret server-side, for example as
VGS_WEBHOOK_SECRET.
From the Notifications page you can open a notification to edit it, delete it, or switch it between active and inactive. Anything generated while it is inactive is not delivered later.
Securing VGS webhooks
Split vgs-signature on commas and read t and v0. Build <t>.<raw body>, compute an HMAC-SHA256 with your secret, hex-encode it, and compare it with v0 in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyVgsSignature(rawBody, header, secret) {
if (!header || !secret) return false;
const parts = {};
for (const item of header.split(',')) {
const i = item.indexOf('=');
if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
}
if (!/^\d+$/.test(parts.t ?? '') || !parts.v0) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${parts.t}.`).update(rawBody).digest('hex'),
'utf8'
);
const received = Buffer.from(parts.v0.toLowerCase(), 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post('/webhooks/vgs', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.VGS_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyVgsSignature(req.body, req.get('vgs-signature'), secret)) {
return res.sendStatus(401);
}
// Optional: compare t with the current time. Retries keep the original t,
// so a 60-second window rejects every retry after the first minute.
// Store the raw body, then process asynchronously.
res.sendStatus(200);
});
The same check in Python, based on VGS's own sample but with a constant-time comparison and the timestamp check left optional:
import hashlib
import hmac
import re
import time
def verify_vgs_signature(raw_body: bytes, header, secret: str, tolerance_seconds: int = 0) -> bool:
if not header or not secret:
return False
parts = dict(item.strip().split("=", 1) for item in header.split(",") if "=" in item)
timestamp, signature = parts.get("t"), parts.get("v0")
if not timestamp or not re.fullmatch(r"[0-9]+", timestamp) or not signature:
return False
if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode("utf-8"), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))
VGS's sample compares with !=, which is not constant-time, and applies a 60-second window before checking the signature. VGS itself points out that retries are sent with the same timestamp, so "a retry that happens after 30 minutes will be rejected". If you keep a window, widen it to cover the retry schedule, or rely on the signature alone.
Make VGS webhooks production-ready. Hookdeck Event Gateway verifies the
vgs-signatureheader against the timestamp and raw body without a timestamp window, answers VGS with an immediate200 OK, and durably queues every route, user and proxy notification before your service sees it.
VGS webhook limitations and pain points
The suggested window rejects retries
The Problem: Retried notifications fail verification, so an outage turns into lost events.
Why It Happens: VGS's sample rejects timestamps more than 60 seconds old, and every retry carries the original timestamp. The retry schedule runs for more than a day, so almost every retry falls outside the window.
Workarounds:
- Verify the signature without a timestamp check, or use a window that covers the full schedule.
- Track which notifications you have processed to protect against replays instead.
How Hookdeck Can Help: Hookdeck's VGS source type checks the signature without enforcing a timestamp window, so retries and replays verify.
No documented payload
The Problem: You don't know the exact shape of the body until you receive one.
Why It Happens: VGS lists the details each event carries and mentions an occurrence field for grouped events, but publishes no example, envelope, HTTP method or content type.
Workarounds:
- Capture real notifications for each event you subscribe to before writing parsers.
- Store the raw body so you can reprocess it once your parser is right.
How Hookdeck Can Help: Hookdeck stores every notification with its headers and body, so you can inspect real payloads and replay them through your parser after you fix it.
Grouped events undercount
The Problem: Your error counts don't match what happened at the proxy.
Why It Happens: For proxy.upstream_error and alias.reveal_failed, VGS sends only the 1st, 10th, 100th, 1000th notification in each 5-minute window.
Workarounds:
- Read the
occurrencecount rather than counting deliveries. - Treat these events as alerts that something is wrong, and go to VGS logs for detail.
How Hookdeck Can Help: Hookdeck filters can route grouped error events to an alerting destination and audit events to a separate store.
Nothing is retried for a disabled endpoint
The Problem: Events from a maintenance window never arrive.
Why It Happens: If you disable a notification endpoint, VGS does not retry anything generated while it was disabled. Recovering those events means asking VGS support for a replay.
Workarounds:
- Keep endpoints enabled during maintenance and let your own receiver hold events.
- If you do disable one, request a replay with the time range and event types.
How Hookdeck Can Help: With a Hookdeck source as the endpoint, you can pause delivery to your own service in Hookdeck while VGS keeps sending, then resume and receive everything that arrived.
Best practices
Verify against the raw body
The signature covers the exact bytes after the timestamp. Use express.raw() in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI.
Allowlist the published IPs
VGS lists the addresses notifications come from, including two IPv6 ranges. Allowlisting them adds a layer, but keep verifying the signature.
Return exactly 200
VGS treats "non-200 responses" as failures. Return 200 once the notification is verified and stored.
Watch login and permission events closely
user.logged_in, user.mfa_deleted and the permission events are the ones a security team cares about. Route them to alerting, not only to a log.
Process asynchronously
Verify, store, return 200, and do the work afterwards. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
VGS signs each notification with vgs-signature: t=...,v0=..., a hex HMAC-SHA256 of the timestamp and raw body under the integration's secret. Verify against the raw bytes, don't apply the sample's 60-second window to retries that keep their original timestamp, read occurrence on grouped events, and capture real payloads before you parse them, because VGS doesn't publish one.
Hookdeck Event Gateway sits in front of your receiver, checks every VGS signature, and holds each notification until your service has processed it.
Get started with Hookdeck for free and handle VGS webhooks reliably in minutes.