Skip to Content
Welcome to Novee Developer Portal 🎉

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:

HeaderDescription
X-TimestampUnix timestamp in milliseconds when the signature was generated.
X-SignatureHMAC-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).
Last updated on