Verify the signature
Every request from Kivoo is signed: check the Kivoo-Signature header before trusting its content, and handle each event once.
Anyone can send a request to your URL. Before acting on a webhook event, check that it really comes from Kivoo and was not altered: that is what the Kivoo-Signature header is for.
The Kivoo-Signature header
Kivoo-Signature: t=1790416800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis when the request was sent, in seconds since 1 January 1970 (UTC).v1is the signature: the HMAC-SHA256, in hexadecimal, of the string<t>.<raw body>(the timestamp, a dot, then the exact body of the request), computed with the webhook’s secret (the full string shown at creation,whsec_included).
To verify:
- Read the raw body of the request, before any JSON decoding: the signature covers these exact bytes, a re-serialised JSON would not match.
- Split the header:
tand thev1value or values. - Refuse a signature that is too old: Kivoo recommends a tolerance of 300 seconds (5 minutes) between
tand your clock. This prevents replaying an old intercepted request. - Compute the HMAC-SHA256 of
<t>.<raw body>with your secret and compare it with eachv1in constant time. One match is enough. - If nothing matches, answer
400and ignore the request.
Node.js
This module only uses node:crypto:
import { createHmac, timingSafeEqual } from 'node:crypto';
// Signatures are accepted up to five minutes after they were sent.
const TOLERANCE_SECONDS = 300;
// HMAC-SHA256 of "<t>.<raw body>" with the webhook's secret, in hexadecimal.
export function computeSignature(secret, timestamp, rawBody) {
return createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
}
// True when the Kivoo-Signature header signs this raw body and is recent enough.
export function verifyKivooSignature(secret, header, rawBody, now = Date.now()) {
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const [key, value = ''] = part.trim().split('=');
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
if (key === 'v1' && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Math.floor(now / 1000) - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(computeSignature(secret, timestamp, rawBody), 'hex');
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}With Express, keep the raw body on the webhook route:
import express from 'express';
import { verifyKivooSignature } from './verify-kivoo-signature.mjs';
const app = express();
app.post('/webhooks/kivoo', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const signature = req.get('Kivoo-Signature');
if (!verifyKivooSignature(process.env.KIVOO_WEBHOOK_SECRET, signature, rawBody)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(rawBody);
res.sendStatus(200); // answer first, within 10 seconds
// … then handle `event`, once per `event.id` (see below).
});
app.listen(3000);PHP
<?php
function verifyKivooSignature(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === [] || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_KIVOO_SIGNATURE'] ?? '';
if (!verifyKivooSignature(getenv('KIVOO_WEBHOOK_SECRET'), $header, $rawBody)) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(200);
// … handle $event, once per $event['id'].Handle each event once
The same event may reach you several times: after an attempt whose answer got lost, or when someone clicks Renvoyer (resend) in the delivery journal. The Kivoo-Event-Id header (equal to the envelope’s id) stays the same from one delivery to the next: it is your idempotency key.
- Record the
idof each handled event, with a uniqueness constraint, in the same transaction as your handling. - If the
idis already known, answer200without doing anything again. - Keep these ids for at least thirty days, the lifetime of the delivery journal.
Keep the secret secret
The secret signs requests on your behalf: it must only live on your server. If it leaked, regenerate it from the webhook’s page, then update your server: the old secret stops signing at once.