Security & Signature Verification
Novee signs every webhook payload with HMAC-SHA256 so you can verify that incoming requests are authentic and have not been tampered with.
How Signing Works
When Novee delivers a webhook event to your endpoint, the request includes two security headers:
| Header | Description |
|---|---|
X-Timestamp | Unix timestamp in milliseconds when the signature was generated. |
X-Signature | HMAC-SHA256 hex digest of the signing string. |
Signing String
The signature is computed over a concatenation of the timestamp and the raw JSON payload, separated by a period:
{timestamp}.{JSON payload}For example, if the timestamp is 1708185600000 and the payload is {"test":"test"}, the signing string is:
1708185600000.{"test":"test"}The HMAC-SHA256 is computed using your webhook secret (the whsec_... value returned at registration) as the key.
Verification Steps
Follow these steps to verify every incoming webhook request:
1. Extract Headers
Read the X-Signature and X-Timestamp headers from the incoming request.
2. Validate Timestamp (Replay Protection)
Reject requests where the timestamp is more than 5 minutes from the current time. This prevents replay attacks.
|current_time_ms - timestamp| ≤ 300000 (5 minutes)3. Compute Expected Signature
Construct the signing string and compute the HMAC-SHA256:
signing_string = "{timestamp}.{raw_json_body}"
expected_signature = HMAC-SHA256(secret, signing_string)4. Compare Signatures (Timing-Safe)
Use a constant-time comparison function to compare the expected signature with the X-Signature header. This prevents timing attacks.
Implementation Examples
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, timestamp, signature, secret) {
// Step 1: Replay protection
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (Math.abs(now - Number(timestamp)) > fiveMinutes) {
return false;
}
// Step 2: Compute expected signature
const signingString = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signingString)
.digest('hex');
// Step 3: Timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
);
} catch {
return false;
}
}Python
import hmac
import hashlib
import time
def verify_webhook_signature(raw_body: str, timestamp: str, signature: str, secret: str) -> bool:
# Step 1: Replay protection
now_ms = int(time.time() * 1000)
if abs(now_ms - int(timestamp)) > 5 * 60 * 1000:
return False
# Step 2: Compute expected signature
signing_string = f"{timestamp}.{raw_body}"
expected = hmac.new(
secret.encode("utf-8"),
signing_string.encode("utf-8"),
hashlib.sha256
).hexdigest()
# Step 3: Timing-safe comparison
return hmac.compare_digest(expected, signature)Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"time"
)
func verifyWebhookSignature(rawBody, timestamp, signature, secret string) bool {
// Step 1: Replay protection
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
nowMs := time.Now().UnixMilli()
if math.Abs(float64(nowMs-ts)) > 5*60*1000 {
return false
}
// Step 2: Compute expected signature
signingString := fmt.Sprintf("%s.%s", timestamp, rawBody)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
expected := hex.EncodeToString(mac.Sum(nil))
// Step 3: Timing-safe comparison
return hmac.Equal([]byte(expected), []byte(signature))
}C# / .NET
using System;
using System.Security.Cryptography;
using System.Text;
public static bool VerifyWebhookSignature(
string rawBody, string timestamp, string signature, string secret)
{
// Step 1: Replay protection
var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (Math.Abs(nowMs - long.Parse(timestamp)) > 5 * 60 * 1000)
return false;
// Step 2: Compute expected signature
var signingString = $"{timestamp}.{rawBody}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signingString));
var expected = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
// Step 3: Timing-safe comparison
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signature));
}Important Notes
- Secret Storage: Your webhook secret (
whsec_...) should be stored securely (e.g., environment variables, a secrets manager). Never hardcode it in client-side code or commit it to source control. - Secret Encryption: Webhook secrets are encrypted at rest in the Novee database using AES encryption.
- Raw Body: Always use the raw JSON string of the request body for signature verification, not a re-serialized version. JSON key order and whitespace must match exactly.
- Timing-Safe Comparison: Always use constant-time comparison functions to prevent timing attacks. Standard string equality (
===,==) is vulnerable. - Replay Protection: The 5-minute timestamp window protects against replay attacks. Ensure your server clock is synchronized (NTP).