Guide
Verify OneLifeVibe webhooks in PHP
Verify OneLifeVibe webhooks in PHP by rebuilding the v1 HMAC-SHA256 signature from the timestamp and raw body, then comparing it in constant time.
Updated November 6, 2026
- Stack
- PHP (hash_hmac, hash_equals)
The code
<?php
function verify_onelifevibe(string $rawBody, array $headers, string $secret, int $tolerance = 300): array {
$timestamp = (int) ($headers['webhook-timestamp'] ?? 0);
if (abs(time() - $timestamp) > $tolerance) {
throw new RuntimeException('stale or missing webhook-timestamp');
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, (string) ($headers['webhook-signature'] ?? ''))) {
throw new RuntimeException('bad webhook-signature');
}
return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}
// In a plain PHP endpoint:
// $raw = file_get_contents('php://input');
// $headers = array_change_key_case(getallheaders(), CASE_LOWER);
// $event = verify_onelifevibe($raw, $headers, getenv('OLV_WEBHOOK_SECRET'));
// http_response_code(204);
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 Node.js
- Verify OneLifeVibe webhooks in Python
- Verify OneLifeVibe webhooks in Ruby
- Verify OneLifeVibe webhooks in Cloudflare Workers, Deno or Bun
What you are building
Each OneLifeVibe delivery is a POST carrying webhook-id, webhook-timestamp and webhook-signature headers, plus x-olv-event and x-olv-world. The body is an envelope with id, type, world, subject, origin, created_at, data and a delivery object holding endpoint_id and attempts. Your PHP endpoint takes that POST, verifies the signature against the raw body, and only then parses anything.
The function verify_onelifevibe does the checking: it rejects stale timestamps, rebuilds the expected signature, and returns the decoded event as an array when the signature matches. Around it sits three lines of plumbing that read the raw request body, lowercase the header keys, and answer with 204.
Delivery is at-least-once, so the same webhook-id can arrive more than once. Store the ids you have processed and skip repeats before running any side effects.
Before you start
You need the endpoint secret for the subscription. It is returned once, when the subscription is created, so capture it then and load it at runtime, for example from an environment variable such as OLV_WEBHOOK_SECRET rather than from a file that ships with your code. The secret is used as UTF-8 text, so no extra decoding step is needed.
Webhooks need a paid plan: Starter is $19/month or $199/year. On the free Sandbox, poll GET /api/platform/v1/events instead and keep the same processing code behind it.
Nothing else to install. hash_hmac, hash_equals and json_decode ship with standard PHP.
How the code works
The first check is time. The function casts webhook-timestamp to an integer and throws if it is missing or more than the tolerance away from time(), 300 seconds by default. A missing header casts to 0 and fails at once. Keeping the tolerance as a parameter lets you loosen it briefly while debugging a server clock, but 300 seconds is the value to ship.
The signature check rebuilds exactly what the platform signed: the timestamp as decimal digits, a dot, then the raw body. hash_hmac with sha256 and your secret produces lowercase hex, which the code prefixes with v1= so it matches the header format. hash_equals then compares the two strings in constant time, so a wrong signature does not leak through timing how much of it was correct.
Only after both checks pass does json_decode run, with JSON_THROW_ON_ERROR so a malformed body raises an exception instead of returning null. In the endpoint itself, file_get_contents on php://input gives the untouched body, array_change_key_case lowercases the header keys so lookups are predictable, and http_response_code(204) closes the request.
Testing it
Send a request with curl to your local endpoint first. Build the signature by hand: pick a fixed timestamp, concatenate it with a dot and your test body, run hash_hmac with sha256 and the secret, prefix v1=, and pass the result as webhook-signature. If the handler answers 204, the happy path works.
Then break it on purpose. Change one character of the body and confirm the endpoint rejects the delivery. Send a timestamp more than 300 seconds behind your clock and confirm the staleness check fires before the signature comparison. Use a different secret and confirm the comparison fails.
Test the duplicate path too. Post the same webhook-id twice and confirm your dedupe store stops the second one from repeating the work.
Going to production
Verify before you parse. Read the body once into a string and pass that same string to the verification and to json_decode; re-encoding a decoded array changes whitespace and key order, and the signature will not match.
Answer fast. Anything other than 2xx counts as a failure, and the request times out after 10 s, so a 204 with the heavy work pushed to a queue is safer than a long synchronous handler. Redirects are never followed, so point the subscription at the final URL.
Failures are retried after 30 s, 2 min, 10 min, 1 h and 6 h, six attempts in total, after which the delivery is dead. That is why dedupe on webhook-id matters: a retry can be an event you already handled. Log delivery.attempts, which starts at 1, to see how often your receiver is failing.
Questions
What should my PHP endpoint return to OneLifeVibe?
Any 2xx status; the code on the page sends 204. Anything else counts as a failure and the delivery is retried after 30 s, 2 min, 10 min, 1 h and 6 h, six attempts in total.
Why use hash_equals instead of comparing the signature with ==?
hash_equals compares in constant time, so the response time does not reveal how many leading characters of a guessed signature were correct. The expected value is v1= followed by lowercase hex, so an ordinary comparison is the wrong tool.
Can I test webhooks on the free Sandbox?
Webhooks require a paid plan: Starter is $19/month or $199/year. On the free Sandbox, poll GET /api/platform/v1/events and keep the same handler behind it.
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.