univoozAPI
Webhooks

Check the signature

Three details decide if your check operates correctly.

Each message has a Univooz-Signature header:

Univooz-Signature: t=1785495120,v1=5257a869e7...

Calculate HMAC-SHA256 of the text "{t}.{raw_request_body}" with your endpoint secret. Compare the result with v1.

Reject the message if t is more than five minutes old. This prevents a repeat attack.

The three details

Each mistake below gives code that looks correct and passes a simple test.

  1. t is in seconds, not milliseconds. If you use milliseconds, every message looks old and you reject all of them.
  2. Use the raw body bytes, before JSON parsing. If you parse the body and make the text again, the bytes change and the signature does not agree. Most frameworks parse the body, so you must ask for the raw body.
  3. Compare in constant time. A normal comparison stops at the first different character. This tells an attacker how much of a false signature was correct.

Example

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));

  const timestamp = Number(parts.t);
  const ageSeconds = Math.floor(Date.now() / 1000) - timestamp;
  if (!Number.isFinite(timestamp) || ageSeconds > 300) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest();

  const received = Buffer.from(parts.v1, 'hex');
  if (received.length !== expected.length) return false;

  return timingSafeEqual(expected, received);
}

rawBody must be the exact bytes that arrived. In Express, use express.raw({ type: 'application/json' }) on this route, not express.json().

Change the secret

A new secret operates immediately. Univooz signs the next message with it.

There is no period when both secrets operate. So change the secret and install your code at the same time.

On this page