Webhook signature verification

Who can use this feature?

Plan: Call Center AI
User type: Admin

Krisp cryptographically signs every webhook it delivers to a SIEM endpoint. The signature lets your receiving service prove that each event genuinely came from Krisp and that nothing in it was altered in transit.

This article explains why the signature exists, how it is built, and how to verify it in your own code.

  Info

Signature verification applies to SIEM webhook endpoints only. Deliveries to Teams endpoints do not carry the X-Krisp-Signature header, so the steps in this article apply to your SIEM integration.

Why signature verification matters

Krisp webhooks carry security-relevant events, such as a detected deepfake, a compliance violation, or a deleted call, into your own systems: a SIEM or a Teams channel.

Those receiving URLs live on the public internet. Without a signature, anyone who discovers your webhook URL could inject fake "Krisp" events or quietly modify real ones, which would poison the exact security signals your team relies on. Unverifiable security alerts are worse than no alerts at all.

Verifying the signature on every request closes that gap.

How verification works

In plain terms, the process has four parts:

  1. A shared secret is established. When a webhook endpoint is created, Krisp generates a signing secret for that endpoint and displays it once. Store it in your secret manager, because it cannot be viewed again afterwards. 
  2. Krisp attaches a seal to every event. The event body and a timestamp are combined with the secret and passed through a one-way function. The result travels with the request in the X-Krisp-Signature header.
  3. Your service re-creates the seal. Using its own copy of the secret, your endpoint computes what the signature should be and compares it to the one received. A match means the request is trusted, a mismatch means it is rejected.
  4. The timestamp is checked. Rejecting old timestamps prevents replay attacks, where an attacker captures a legitimate request and re-sends it later.

A single check therefore confirms two things at once: authenticity, because only Krisp holds the secret that can produce a valid signature, and integrity, because any change to the payload breaks the seal.

Why this approach

Krisp uses HMAC-SHA256 signing with a timestamped header, the same method used by Stripe and GitHub. This is the de facto standard for webhook signing, which means:

  • Your security and engineering teams already know the pattern, and existing libraries and tooling work without modification.
  • Integration effort stays low, since verification is a few lines of code rather than a custom protocol.
  • Each webhook endpoint has its own unique signature, so a leaked secret in one environment or one customer's system never affects another.

Signature header format

Every request includes the following header:

X-Krisp-Signature: t=<unix_seconds>,v1=<hex>
Element Description
t The signing time, expressed as Unix epoch seconds (integer).
v1 The HMAC-SHA256 output, as 64 lowercase hexadecimal characters.

 

The signed payload

The exact string that Krisp signs is the timestamp, a period, and the request body:

signed_payload = t + "." + raw_request_body

Here, t is the ASCII decimal value taken from the header, and raw_request_body is the exact bytes received on the wire, never a re-serialized JSON object.

The signature itself is:

v1 = hex( HMAC_SHA256( key = secret_as_utf8_bytes, message = signed_payload_bytes ) )

Verification algorithm

Run these steps on every incoming request:

  1. Parse t and v1 from the X-Krisp-Signature header.
  2. Capture the raw body bytes.
  3. Compute the expected v1 using the formula above.
  4. Compare the expected value to the received v1 using a constant-time comparison. On mismatch, reject the request with a 401 response.
  5. Check that abs(now - t) is 300 seconds or less. Outside that window, reject the request as a replay.
  6. If both checks pass, treat the request as authentic and process the event.

  Hint

Keep your server clock in sync with NTP. A drifting clock is a common cause of otherwise valid requests failing the 300 second timestamp check.

Reference implementations

Use the sample below for your language as a starting point.

Python

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts["t"], parts["v1"]

    if abs(int(time.time()) - int(t)) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{t}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, v1)   # constant-time

Node.js

const crypto = require("crypto");

function verify(rawBody /* Buffer */, header, secret, tolerance = 300) {
  const { t, v1 } = Object.fromEntries(
    header.split(",").map((p) => p.split("="))
  );

  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > tolerance) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(t + ".")
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(expected),
    b = Buffer.from(v1);

  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Common integration mistakes

Almost every failed integration comes down to one of the following three issues.

1. Using a parsed body instead of the raw body

Reading the parsed request body and re-serializing it, for example with JSON.stringify(), changes the bytes through key ordering and whitespace, so the signature will never match. Capture the raw body before any JSON middleware runs, using express.raw() in Node.js or await request.body() in FastAPI.

2. Using a plain string comparison

Always compare with a constant-time function such as hmac.compare_digest or crypto.timingSafeEqual, never with ==. A plain comparison leaks how much of the signature is correct through response timing, which lets an attacker brute-force a valid signature one byte at a time.

3. Decoding the secret

The secret is used as its raw UTF-8 bytes, meaning the string exactly as displayed. It looks like hex, but it must not be hex-decoded. Likewise, the body is signed as raw bytes and the resulting hex output is lowercase.

  Important

Never log the signing secret, and never disable verification to work around a mismatch. If verification fails consistently, work through the three issues above before changing your endpoint configuration.

Contract summary

  • Header: X-Krisp-Signature: t=<unix>,v1=<hex>
  • Signature: v1 = HMAC_SHA256(endpoint_secret, "<t>." + raw_body)
  • Verification: constant-time comparison over the raw received bytes.
  • Replay defense: reject timestamps older than 300 seconds.

Have more questions? Submit a request

Was this article helpful?
0 out of 0 found this helpful