Configure outbound HTTP webhook notifications triggered by document events — from creating the webhook to verifying HMAC signatures in your handler.
Webhooks let Lifestream Vault push real-time notifications to your own HTTP endpoint whenever vault events occur — document created, updated, deleted, moved, or copied. Unlike polling, webhooks are push-based: your server receives the payload the moment the event fires.
By the end of this guide you will have:
Webhooks are delivered by a BullMQ worker (webhook-delivery.worker) with exponential backoff retry on failures.
Prerequisites
ngrok http 3000) for local testingCreate a webhook by specifying:
document.created, document.updated, document.deleted, document.moved)The webhook is immediately active after creation. Any matching events in the vault will trigger a delivery attempt. The server auto-generates an HMAC signing secret and returns it once in the creation response — store it securely for signature verification.
import { LifestreamVaultClient } from '@lifestreamdynamics/vault-sdk';
const { client } = await LifestreamVaultClient.login(
'https://vault.lifestreamdynamics.com',
'you@example.com',
'your-password',
);
const VAULT_ID = 'your-vault-id';
const webhook = await client.webhooks.create(VAULT_ID, {
url: 'https://example.com/webhook',
events: ['document.created', 'document.updated', 'document.deleted', 'document.moved', 'document.copied'],
});
console.log('Webhook created:', webhook.id);
console.log('Active:', webhook.isActive); // true
console.log('Secret (store this!):', webhook.secret); // auto-generated, shown only onceEvery webhook delivery includes an X-Webhook-Signature header in the form sha256=<hex digest>, where the digest is an HMAC-SHA256 of the raw request body computed using the secret returned at creation time.
Always verify this signature before processing the payload. Without verification, anyone who discovers your endpoint URL could send forged events.
The verification algorithm:
sha256= prefix from the header valueHMAC-SHA256(secret, rawBody) as a hex stringimport express from 'express';
import crypto from 'crypto';
const app = express();
// Use express.raw() to access the raw body before JSON parsing
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.headers['x-webhook-signature'] as string | undefined;
const secret = process.env.WEBHOOK_SECRET!;
if (!header) {
res.status(400).send('Missing X-Webhook-Signature header');
return;
}
// The header is "sha256=<hex digest>" — strip the prefix before comparing
const signature = header.startsWith('sha256=') ? header.slice('sha256='.length) : header;
// Compute expected signature
const expected = crypto
.createHmac('sha256', secret)
.update(req.body) // req.body is a Buffer when using express.raw()
.digest('hex');
// Constant-time comparison to prevent timing attacks
const sigBuffer = Buffer.from(signature, 'utf8');
const expBuffer = Buffer.from(expected, 'utf8');
if (
sigBuffer.length !== expBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expBuffer)
) {
res.status(401).send('Invalid signature');
return;
}
// Signature valid — parse and handle the payload
const event = JSON.parse(req.body.toString('utf8'));
console.log('Received event:', event.type, event.documentId);
res.status(200).send('OK');
});
app.listen(3000, () => console.log('Webhook server listening on :3000'));Always use constant-time comparison. Never compare signatures with === or == — standard string comparison short-circuits on the first differing byte, making it vulnerable to timing attacks that allow an attacker to forge a valid signature byte by byte. Use crypto.timingSafeEqual() (Node.js) or hmac.compare_digest() (Python).
After creating the webhook, trigger an event by creating or updating a document in the subscribed vault. Then check the delivery log to confirm the payload was dispatched and your handler returned a 2xx response.
Delivery log entries include:
| Field | Description |
|---|---|
id | Unique delivery ID — use as idempotency key in your handler |
statusCode | HTTP response code returned by your endpoint, or null if delivery failed |
attempt | Delivery attempt number (retried with exponential backoff on failure) |
error | Error message if the delivery failed, or null |
deliveredAt | ISO 8601 timestamp of successful delivery, or null |
const VAULT_ID = 'your-vault-id';
const WEBHOOK_ID = 'your-webhook-id';
const deliveries = await client.webhooks.listDeliveries(VAULT_ID, WEBHOOK_ID);
deliveries.forEach((d) => {
console.log(`HTTP ${d.statusCode ?? '—'} | attempt: ${d.attempt} | ${d.deliveredAt ?? 'not delivered'}`);
if (d.error) console.log(` Error: ${d.error}`);
console.log(` Delivery ID: ${d.id}`);
});Respond within 10 seconds. The delivery worker times out after 10 seconds and marks the delivery as failed, then retries with exponential backoff. Offload heavy processing to a background queue and return 200 OK immediately.
Use the payload id field for deduplication. Retried deliveries carry the same event id in the payload body. Store processed IDs in Redis or a database to make your handler idempotent.
Subscribe only to events you need. Subscribing to all events generates unnecessary traffic. Use the minimal set your integration requires — for example, a documentation deployment pipeline only needs document.created.
Your handler URL must be publicly reachable. The delivery worker runs server-side and cannot reach private/internal IP addresses. SSRF protection blocks requests to localhost, RFC 1918 ranges (e.g. 192.168.x.x, 10.x.x.x), and link-local addresses. Use ngrok or a similar tunnel for local development.