Webhooks

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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is when the request was sent, in seconds since 1 January 1970 (UTC).
  • v1 is 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:

  1. Read the raw body of the request, before any JSON decoding: the signature covers these exact bytes, a re-serialised JSON would not match.
  2. Split the header: t and the v1 value or values.
  3. Refuse a signature that is too old: Kivoo recommends a tolerance of 300 seconds (5 minutes) between t and your clock. This prevents replaying an old intercepted request.
  4. Compute the HMAC-SHA256 of <t>.<raw body> with your secret and compare it with each v1 in constant time. One match is enough.
  5. If nothing matches, answer 400 and ignore the request.

Node.js

This module only uses node:crypto:

verify-kivoo-signature.mjs
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:

server.mjs
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

webhook.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 id of each handled event, with a uniqueness constraint, in the same transaction as your handling.
  • If the id is already known, answer 200 without 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.

On this page