Guide
Verify OneLifeVibe webhooks in Node.js
Verify OneLifeVibe webhooks in Node.js with node:crypto and Express: check the HMAC against the raw body, then parse the event.
Updated November 6, 2026
- Stack
- Node.js (node:crypto, Express)
The code
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (a string or Buffer), before JSON.parse.
export function verifyOneLifeVibe(rawBody, headers, secret, toleranceSeconds = 300) {
const timestamp = Number(headers['webhook-timestamp']);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
throw new Error('stale or missing webhook-timestamp');
}
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const given = String(headers['webhook-signature'] || '');
const a = Buffer.from(expected);
const b = Buffer.from(given);
if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error('bad webhook-signature');
return JSON.parse(rawBody);
}
// Express: keep the raw body for this route only.
// app.post('/onelifevibe', express.raw({ type: 'application/json' }), (req, res) => {
// const event = verifyOneLifeVibe(req.body.toString('utf8'), req.headers, process.env.OLV_WEBHOOK_SECRET);
// res.sendStatus(204); // answer first, then process event (dedupe on event.id)
// });
The facts it relies on
- Each delivery is a POST with headers webhook-id (the event id; dedupe on it, delivery is at-least-once), webhook-timestamp (Unix seconds) and webhook-signature, plus x-olv-event and x-olv-world.
- webhook-signature is v1= followed by the lowercase hex HMAC-SHA256 of "{webhook-timestamp}.{raw body}", keyed with the endpoint secret as UTF-8 text. The secret is returned once, when the subscription is created.
- Verify against the raw body before parsing, compare in constant time, and reject a timestamp more than 300 seconds from your clock.
- Respond 2xx quickly; anything else is a failure. Retries follow after 30 s, 2 min, 10 min, 1 h and 6 h (6 attempts), then the delivery is dead. Redirects are never followed and requests time out after 10 s.
- Body: {"id":"evt_…","type":"world.event","world":"soclasica","subject":…,"origin":"ingest","created_at":"…","data":{…},"delivery":{"endpoint_id":"whe_…","attempts":1}}.
- Webhooks need a paid plan (Starter $19/month or $199/year); on the free Sandbox, poll GET /api/platform/v1/events.
Related guides
- Verify OneLifeVibe webhooks in Python
- Verify OneLifeVibe webhooks in PHP
- Verify OneLifeVibe webhooks in Ruby
- Verify OneLifeVibe webhooks in Cloudflare Workers, Deno or Bun
What you are building
OneLifeVibe sends each webhook as a POST carrying five headers: webhook-id (the event id), webhook-timestamp in Unix seconds, webhook-signature, x-olv-event and x-olv-world. Your route accepts that POST, proves the signature was made with your endpoint secret, and only then parses the JSON.
The body holds id, type, world, subject, origin, created_at, data, and a delivery object with endpoint_id and attempts. Delivery is at-least-once, so the same event can arrive twice; dedupe on webhook-id before doing anything with it.
The code on the page is two pieces: verifyOneLifeVibe, a pure function taking the raw body, the headers and the secret, plus a commented Express handler that mounts it at POST /onelifevibe.
Before you start
You need the endpoint secret. It is returned once, when the subscription is created, so copy it into an environment variable such as OLV_WEBHOOK_SECRET before you close that response.
Webhooks need a paid plan: Starter at $19/month or $199/year. On the free Sandbox, poll GET /api/platform/v1/events instead.
Install Express. The signing uses only node:crypto — createHmac and timingSafeEqual — so verification adds no dependency.
How the code works
verifyOneLifeVibe starts with the timestamp. It converts webhook-timestamp with Number, rejects anything that is not finite, then compares it to Date.now() / 1000 and throws if the gap exceeds the tolerance, 300 seconds by default. That window decides how long a captured request stays replayable.
It then builds the expected signature: the literal v1= followed by the lowercase hex HMAC-SHA256 of the timestamp, a dot, and the raw body, keyed with the endpoint secret as UTF-8 text. Nothing else enters the digest — no header names, no URL. The comparison turns both strings into buffers, checks that the lengths match, and calls timingSafeEqual; the length check exists because timingSafeEqual throws on mismatched buffer sizes. Equal lengths plus a constant-time compare is what keeps a partial guess from being measurable.
The handler mounts express.raw({ type: 'application/json' }) on that one route, so req.body is a Buffer of the bytes as sent. It converts the buffer to a UTF-8 string, verifies with process.env.OLV_WEBHOOK_SECRET, replies 204, and processes the event afterward. Answering first keeps you inside the 10-second request timeout.
Testing it
Run the server locally and call verifyOneLifeVibe directly with a body and headers you build yourself. Check the three rejection paths: a webhook-timestamp more than 300 seconds from now, a signature computed over different bytes, and a missing header. Each should throw, and the route should answer non-2xx.
Then trigger a real event on your site and read the delivery object in the body. attempts should be 1 on the first try, and endpoint_id should match the subscription you created.
Know what a failure costs. OneLifeVibe retries after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours — six attempts — then the delivery is dead. Redirects are never followed, so the route URL must be final, and requests time out after 10 seconds.
Going to production
Store webhook-id, or the body's id, under a unique constraint and skip anything already handled. Duplicates are normal under at-least-once delivery, not a bug. Write the event down and hand the slow work to a queue.
Leave the 300-second tolerance where it is. A delivery that fails the timestamp check deserves a look, not a wider window. Log webhook-id, x-olv-event and x-olv-world with every request so a failed attempt can be traced back to its event and world.
Return 2xx for anything you have accepted, including duplicates. Keep the handler clear of downstream calls that can hang: 10 seconds is the whole budget, and an outage elsewhere should not burn your six attempts.
Questions
Why does my signature check fail when I use express.json()?
The signature covers the exact bytes that arrived, and express.json() replaces them with a parsed object. Re-serializing changes whitespace and key order, so the HMAC no longer matches. Use express.raw({ type: 'application/json' }) on the route and parse only after verification passes.
How many times does OneLifeVibe retry a failed webhook?
Six attempts in total, after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. After the last one the delivery is dead.
Can I receive webhooks on the free Sandbox plan?
Webhooks need a paid plan: Starter at $19/month or $199/year. On the free Sandbox, poll GET /api/platform/v1/events for events instead of waiting for deliveries.
Try it against the real API. A free Sandbox key reads the catalogue and the event log at once, no card.
Written from the OneLifeVibe tool registry and platform configuration, and checked automatically before publishing. Something wrong? Tell us.