Skip to content
Documentation
English
Open the app

Verify signatures

Verify the HMAC-SHA256 signature of the X-Novafisko-Signature header in PHP, Node.js, Python and C#, with clock tolerance and secret rotation.

Your receiving URL is public: anyone can send a request to it. The signature proves that a call really comes from NovaFisko and that its content was not altered in transit. Never process a webhook without verifying it.

The principle

Every request carries a header of the form:

X-Novafisko-Signature: t=1791191400,v1=5f2b8c1e0a7d4f6b9c3e2a1d8f7b6c5a4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b
Item Meaning
t Unix timestamp of the send, in seconds
v1 HMAC-SHA256 signature in lowercase hexadecimal, 64 characters

The signature is calculated as follows:

v1 = HMAC_SHA256(secret, t + "." + raw_body)

where secret is the whsec_... value received when the endpoint was created and raw_body is the raw body of the request, byte for byte.

The four steps

  1. Read the raw body. Get the bytes received before any JSON parsing.
  2. Extract t and v1 from the header.
  3. Check the timestamp. Refuse if t differs from your clock by more than 5 minutes.
  4. Recompute and compare. Compute the HMAC and compare it with v1 using a constant-time function.
Warning

Sign the body as received. If you parse the JSON and then serialise it again, the key order, the whitespace or the encoding of accented characters will change and the signature will no longer match. This is the most common mistake.

Why a clock tolerance

The timestamp is part of the signed data. An attacker who intercepted a valid request could therefore not replay it later: after 5 minutes, you refuse it. Keep your server's clock synchronised through NTP.

Each new delivery attempt is signed with a fresh timestamp. A delivery retried one hour later therefore remains valid.

Why a constant-time comparison

An ordinary comparison stops at the first differing character. By measuring the response time, an attacker can guess the signature character by character. The hash_equals, timingSafeEqual, compare_digest and FixedTimeEquals functions always take the same time.

PHP

<?php

function verifyNovafiskoSignature(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
    if ($header === null) {
        return false;
    }
    // Header format: t=<unix ts>,v1=<hex hmac>
    $parts = [];
    foreach (explode(',', $header) as $pair) {
        [$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
        $parts[$key] = $value;
    }
    $timestamp = $parts['t'] ?? '';
    $signature = $parts['v1'] ?? '';
    if (! ctype_digit($timestamp) || $signature === '') {
        return false;
    }
    // Reject stale or future-dated requests (replay protection)
    if (abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

    return hash_equals($expected, $signature); // constant-time comparison
}

$rawBody = file_get_contents('php://input'); // raw bytes, before any json_decode
$header = $_SERVER['HTTP_X_NOVAFISKO_SIGNATURE'] ?? null;

if (! verifyNovafiskoSignature($rawBody, $header, getenv('NOVAFISKO_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
// Acknowledge first, process asynchronously
http_response_code(200);
echo 'ok';

With Laravel, use $request->getContent() for the raw body and $request->header('X-Novafisko-Signature') for the header.

Node.js

import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.NOVAFISKO_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

function verifyNovafiskoSignature(rawBody, header, secret) {
  if (!header) return false;
  // Header format: t=<unix ts>,v1=<hex hmac>
  const parts = Object.fromEntries(
    header.split(",").map((pair) => {
      const index = pair.indexOf("=");
      return [pair.slice(0, index).trim(), pair.slice(index + 1).trim()];
    })
  );
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || !parts.v1) return false;
  // Reject stale or future-dated requests (replay protection)
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();
  const received = Buffer.from(parts.v1, "hex");
  // timingSafeEqual throws when lengths differ: check first
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

// express.raw keeps the body as a Buffer: never use express.json() on this route
app.post("/hooks/novafisko", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyNovafiskoSignature(req.body, req.get("X-Novafisko-Signature"), SECRET)) {
    return res.status(400).send("Invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.status(200).send("ok"); // acknowledge first
  queue.push(event); // then process asynchronously
});

app.listen(3000);

Python

import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["NOVAFISKO_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300


def verify_novafisko_signature(raw_body: bytes, header: str | None, secret: bytes) -> bool:
    if not header:
        return False
    # Header format: t=<unix ts>,v1=<hex hmac>
    parts = dict(pair.strip().split("=", 1) for pair in header.split(",") if "=" in pair)
    timestamp, signature = parts.get("t", ""), parts.get("v1", "")
    if not timestamp.isdigit() or not signature:
        return False
    # Reject stale or future-dated requests (replay protection)
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)  # constant-time comparison


@app.post("/hooks/novafisko")
def novafisko_webhook():
    raw_body = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_novafisko_signature(raw_body, request.headers.get("X-Novafisko-Signature"), SECRET):
        abort(400, "Invalid signature")
    event = json.loads(raw_body)
    enqueue(event)  # process asynchronously
    return "ok", 200

With FastAPI, read the body with await request.body(). With Django, use request.body.

C#

using System.Security.Cryptography;
using System.Text;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var secret = Environment.GetEnvironmentVariable("NOVAFISKO_WEBHOOK_SECRET")!;
const int ToleranceSeconds = 300;

static bool VerifyNovafiskoSignature(byte[] rawBody, string? header, string secret, int tolerance)
{
    if (string.IsNullOrEmpty(header)) return false;

    // Header format: t=<unix ts>,v1=<hex hmac>
    string? timestamp = null, signature = null;
    foreach (var pair in header.Split(','))
    {
        var index = pair.IndexOf('=');
        if (index < 0) continue;
        var key = pair[..index].Trim();
        var value = pair[(index + 1)..].Trim();
        if (key == "t") timestamp = value;
        if (key == "v1") signature = value;
    }
    if (!long.TryParse(timestamp, out var unix) || string.IsNullOrEmpty(signature)) return false;

    // Reject stale or future-dated requests (replay protection)
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - unix) > tolerance) return false;

    var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
    var signed = new byte[prefix.Length + rawBody.Length];
    Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
    Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);

    var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
    byte[] received;
    try { received = Convert.FromHexString(signature); }
    catch (FormatException) { return false; }

    return CryptographicOperations.FixedTimeEquals(expected, received); // constant-time comparison
}

app.MapPost("/hooks/novafisko", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer); // raw bytes, before any JSON parsing
    var rawBody = buffer.ToArray();

    if (!VerifyNovafiskoSignature(rawBody, request.Headers["X-Novafisko-Signature"], secret, ToleranceSeconds))
    {
        return Results.BadRequest("Invalid signature");
    }
    // Acknowledge first, process asynchronously
    return Results.Ok("ok");
});

app.Run();

Check your implementation

Use this test vector to validate your code without waiting for a real event. Remember to disable the timestamp check during this test.

Item Value
Secret whsec_test_secret
Timestamp 1791191400
Body {"id":"evt_test","event":"webhook.test"}
Signed string 1791191400.{"id":"evt_test","event":"webhook.test"}

Compute the expected signature on the command line, then compare it with the result of your function:

printf '%s' '1791191400.{"id":"evt_test","event":"webhook.test"}' \
  | openssl dgst -sha256 -hmac 'whsec_test_secret' -hex

For an end-to-end trial, trigger a real send with POST /v1/firms/{firm}/webhooks/{id}/test.

Secret rotation

A secret cannot be read again or modified. To renew it without losing any event:

  1. Create a second endpoint with the same URL and the same events. You receive a new secret.
  2. Deploy your receiver so that it accepts both secrets: the request is valid if either of them produces the right signature.
  3. Delete the old endpoint.
  4. Remove the old secret from your configuration.

During the short period when both coexist, each event reaches you twice, with the same id. Your deduplication on id absorbs it.

def verify_any(raw_body, header, secrets):
    # Accept the current and the previous secret during a rotation
    return any(verify_novafisko_signature(raw_body, header, secret) for secret in secrets)
Tip

Renew the secret without delay if you suspect a leak, for example if it appeared in an application log or in a code repository.

Troubleshooting

Symptom Likely cause
The signature never matches The body was parsed and then serialised again. Sign the raw bytes
The signature fails on accented text The body was transcoded. It is in UTF-8, with accented characters unescaped
Everything is refused because of the timestamp The server clock is drifting. Synchronise it through NTP
Failure after a proxy change An intermediary modifies the body (compression, rewriting). Pass it through intact
Failure on one endpoint only Wrong secret. Compare its last four characters with secret_hint

See also