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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt— 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 TrueIn 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 "", 200Make 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
idbefore doing any work — see Idempotency. - Return a 2xx quickly, then process asynchronously.
- If verification fails consistently, check Troubleshooting.
Event types & payloads
The 8 Hoursmith webhook event types and the signed JSON envelope they arrive in, including the data snapshot and payment object.
Retries & replay
How Hoursmith delivers webhooks — timeouts, the exponential backoff retry schedule, idempotency, replaying past deliveries, and test events.