Skip to content
Hoursmith Docs
Webhooks

Verify signatures

Validate the Hoursmith-Signature header with an HMAC-SHA256 check over the raw request body, with Node.js and Python examples.

Every webhook delivery is signed with your endpoint's signing secret. Verify the signature before trusting a payload so you know it came from Hoursmith and wasn't tampered with.

The signature header

Each delivery includes a Hoursmith-Signature header:

Hoursmith-Signature: t=1717603200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t — the Unix timestamp (in seconds) when the signature was generated.
  • v1 — the signature: an HMAC-SHA256, hex-encoded, computed over the string `<t>.<raw_request_body>` using your signing secret.

How to verify

Parse t and v1 from the header

Split the Hoursmith-Signature value on commas, then split each part on = to read the t and v1 values.

Recompute the HMAC

Compute hmac_sha256(secret, t + "." + rawBody) and hex-encode it, where rawBody is the raw request body bytes.

Compare in constant time

Compare your computed value to v1 using a constant-time comparison to avoid timing attacks.

Reject stale timestamps

Reject the delivery if t is more than 300 seconds (5 minutes) old. This is your replay protection.

Verify against the raw request body bytes, exactly as received — before any JSON parsing or re-serialization. Parsing and re-stringifying the JSON can change whitespace or key order, which breaks the HMAC. Capture the raw body in your framework before it's deserialized.

Examples

import crypto from 'node:crypto';

const SIGNING_SECRET = process.env.HOURSMITH_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // 5 minutes

/**
 * Verify a Hoursmith webhook.
 * @param {Buffer|string} rawBody  The RAW request body, before JSON parsing.
 * @param {string} signatureHeader The `Hoursmith-Signature` header value.
 */
function verifyWebhook(rawBody, signatureHeader) {
  // 1. Parse `t` and `v1` from the header.
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((kv) => kv.split('=')),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) throw new Error('Malformed signature header');

  // 4. Reject timestamps older than the tolerance.
  const age = Math.floor(Date.now() / 1000) - Number(t);
  if (Number.isNaN(age) || age > TOLERANCE_SECONDS) {
    throw new Error('Timestamp outside tolerance');
  }

  // 2. Recompute the HMAC over `<t>.<rawBody>`.
  const signedPayload = `${t}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', SIGNING_SECRET)
    .update(signedPayload)
    .digest('hex');

  // 3. Constant-time compare.
  const a = Buffer.from(expected);
  const b = Buffer.from(v1);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('Signature mismatch');
  }

  return true;
}

In Express, capture the raw body so it isn't lost to the JSON parser:

import express from 'express';

const app = express();

app.post(
  '/hooks/hoursmith',
  express.raw({ type: 'application/json' }), // req.body is a Buffer
  (req, res) => {
    try {
      verifyWebhook(req.body, req.get('Hoursmith-Signature'));
    } catch {
      return res.sendStatus(400);
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // Return 2xx fast, then process asynchronously.
    res.sendStatus(200);
    void handleEvent(event);
  },
);
import hashlib
import hmac
import os
import time

SIGNING_SECRET = os.environ["HOURSMITH_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300  # 5 minutes


def verify_webhook(raw_body: bytes, signature_header: str) -> bool:
    """Verify a Hoursmith webhook.

    raw_body: the RAW request body bytes, before JSON parsing.
    signature_header: the `Hoursmith-Signature` header value.
    """
    # 1. Parse `t` and `v1` from the header.
    parts = dict(kv.split("=", 1) for kv in signature_header.split(","))
    t = parts.get("t")
    v1 = parts.get("v1")
    if not t or not v1:
        raise ValueError("Malformed signature header")

    # 4. Reject timestamps older than the tolerance.
    if int(time.time()) - int(t) > TOLERANCE_SECONDS:
        raise ValueError("Timestamp outside tolerance")

    # 2. Recompute the HMAC over `<t>.<raw_body>`.
    signed_payload = f"{t}.".encode("utf-8") + raw_body
    expected = hmac.new(
        SIGNING_SECRET.encode("utf-8"),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    # 3. Constant-time compare.
    if not hmac.compare_digest(expected, v1):
        raise ValueError("Signature mismatch")

    return True

In Flask, read the raw body with request.get_data() before parsing:

import json

from flask import Flask, request

app = Flask(__name__)


@app.post("/hooks/hoursmith")
def hoursmith_webhook():
    raw = request.get_data()  # raw bytes, not yet parsed
    try:
        verify_webhook(raw, request.headers["Hoursmith-Signature"])
    except (ValueError, KeyError):
        return "", 400

    event = json.loads(raw)
    # Return 2xx fast, then process asynchronously.
    enqueue(event)
    return "", 200

Make sure your server's clock is accurate (use NTP). If it drifts by more than 5 minutes, valid deliveries can fall outside the tolerance window and be rejected.

After verifying

A verified payload is safe to process. From here:

  • Dedupe on the event id before doing any work — see Idempotency.
  • Return a 2xx quickly, then process asynchronously.
  • If verification fails consistently, check Troubleshooting.
Was this page helpful?

On this page