Skip to main content

Webhook Security

Every webhook delivery from Infodeck is signed with HMAC-SHA256. Verifying signatures ensures that the requests your endpoint receives genuinely came from Infodeck and have not been tampered with.

Signature Format

Every delivery includes an x-infodeck-signature header with the following format:

x-infodeck-signature: t=1771911526,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
ComponentDescription
tUnix timestamp (seconds) when the signature was generated
v1HMAC-SHA256 hex digest of the signed payload

How to Verify -- Step by Step

1. Extract timestamp and signature

Parse the x-infodeck-signature header to get the t and v1 values.

t=1771911526,v1=5257a869...
^ ^
timestamp signature

2. Construct the signed payload

Concatenate the timestamp, a literal period (.), and the raw request body:

{timestamp}.{raw_body}

For example:

1771911526.{"id":"evt_abc123","type":"asset.created",...}
danger

You must use the raw request body as a string, not a parsed-and-re-serialized JSON object. Re-serializing can change key ordering or whitespace, which breaks the signature.

3. Compute the expected signature

Using your webhook signing secret, compute the HMAC-SHA256 hex digest of the signed payload string.

4. Compare signatures (constant-time)

Compare your computed signature against the v1 value from the header. Always use a constant-time comparison function to prevent timing attacks.

To prevent replay attacks, reject events where the timestamp is more than 5 minutes old:

current_time - timestamp > 300 seconds  -->  reject

Code Examples

Node.js

const crypto = require('crypto');

function verifyWebhookSignature(payload, header, secret) {
const [tPart, v1Part] = header.split(',');
const timestamp = tPart.split('=')[1];
const signature = v1Part.split('=')[1];

// Step 2: Construct signed payload
const signedPayload = `${timestamp}.${payload}`;

// Step 3: Compute expected signature
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');

// Step 4: Constant-time comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);

// Step 5: Check timestamp freshness (5 min tolerance)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) {
throw new Error('Webhook timestamp too old — possible replay attack');
}

return isValid;
}

// Usage in Express
const express = require('express');
const app = express();

app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-infodeck-signature'];

try {
const isValid = verifyWebhookSignature(
req.body.toString(),
signature,
process.env.INFODECK_WEBHOOK_SECRET
);

if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
} catch (err) {
return res.status(401).json({ error: err.message });
}

const event = JSON.parse(req.body);
// Process the verified event...
res.status(200).json({ received: true });
});
tip

Use express.raw({ type: 'application/json' }) instead of express.json() to get the raw body for signature verification. If you use express.json(), the body is parsed and the original byte-for-byte content is lost.

Python

import hmac
import hashlib
import os
import time

def verify_webhook(payload: bytes, header: str, secret: str) -> bool:
"""
Verify an Infodeck webhook signature.

Args:
payload: Raw request body as bytes
header: Value of x-infodeck-signature header
secret: Your webhook signing secret

Returns:
True if the signature is valid

Raises:
ValueError: If the timestamp is too old
"""
parts = dict(p.split('=', 1) for p in header.split(','))
timestamp = parts['t']
signature = parts['v1']

# Construct signed payload
signed_payload = f"{timestamp}.{payload.decode()}"

# Compute expected signature
expected = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()

# Constant-time comparison
if not hmac.compare_digest(signature, expected):
return False

# Check freshness
age = int(time.time()) - int(timestamp)
if age > 300:
raise ValueError("Webhook timestamp too old — possible replay attack")

return True


# Usage in Flask
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhooks', methods=['POST'])
def handle_webhook():
signature = request.headers.get('x-infodeck-signature')

try:
is_valid = verify_webhook(
request.get_data(), # raw bytes
signature,
os.environ['INFODECK_WEBHOOK_SECRET']
)
if not is_valid:
return jsonify(error='Invalid signature'), 401
except ValueError as e:
return jsonify(error=str(e)), 401

event = request.get_json()
# Process the verified event...
return jsonify(received=True), 200

Go

package webhook

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)

// VerifyWebhook verifies an Infodeck webhook signature.
// Returns true if valid, or an error if verification fails.
func VerifyWebhook(payload []byte, header, secret string) (bool, error) {
parts := strings.SplitN(header, ",", 2)
if len(parts) != 2 {
return false, fmt.Errorf("invalid signature header format")
}

timestamp := strings.TrimPrefix(parts[0], "t=")
signature := strings.TrimPrefix(parts[1], "v1=")

// Construct signed payload
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))

// Compute expected signature
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expected := hex.EncodeToString(mac.Sum(nil))

// Constant-time comparison
if !hmac.Equal([]byte(signature), []byte(expected)) {
return false, nil
}

// Check freshness
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false, fmt.Errorf("invalid timestamp: %w", err)
}

age := time.Now().Unix() - ts
if age > 300 {
return false, fmt.Errorf("webhook timestamp too old (%ds) — possible replay attack", age)
}

return true, nil
}
// Usage in net/http handler
import (
"io"
"net/http"
"os"
)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}

signature := r.Header.Get("X-Infodeck-Signature")
valid, err := webhook.VerifyWebhook(payload, signature, os.Getenv("INFODECK_WEBHOOK_SECRET"))
if err != nil || !valid {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}

// Process the verified event...
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received": true}`))
}

Secret Rotation

You should rotate your signing secret periodically. Infodeck supports two rotation modes:

During graceful rotation, Infodeck signs deliveries with both the old and new secret for a 24-hour overlap period. Your endpoint should attempt verification with the new secret first, then fall back to the old one.

curl -X POST https://app.infodeck.io/api/v2/organizations/{orgId}/webhooks/{webhookId}/rotate-secret \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{ "mode": "graceful" }'

Response:

{
"data": {
"newSecret": "whsec_new_a1b2c3...",
"previousSecretExpiresAt": 1772000000,
"overlapHours": 24
}
}

During the overlap period:

  1. Try verifying with the new secret
  2. If that fails, try the old secret
  3. After the overlap period, reject anything signed with the old secret

Immediate Rotation

Immediately invalidates the old secret. Use only in emergencies (e.g., secret was leaked).

curl -X POST https://app.infodeck.io/api/v2/organizations/{orgId}/webhooks/{webhookId}/rotate-secret \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{ "mode": "immediate" }'
danger

Immediate rotation will cause signature verification failures for any in-flight deliveries signed with the old secret. These deliveries will be retried with the new secret.


IP Allowlisting

For defense in depth, you can restrict incoming webhook requests to Infodeck's delivery IP ranges.

Contact support@infodeck.io for the current list of delivery IP ranges.

info

IP allowlisting is a supplementary measure. Always verify the HMAC signature as your primary security check -- IP addresses can be spoofed, but signatures cannot be forged without the secret.


Security Checklist

  • Signature verification is enabled on your endpoint
  • Using constant-time comparison (not === or ==)
  • Timestamp freshness check is enabled (5-minute window)
  • Signing secret is stored in environment variables, not in source code
  • Using raw request body for verification, not re-serialized JSON
  • Secret rotation plan is in place (recommend every 90 days)
  • HTTPS enforced on your endpoint (Infodeck will not deliver to HTTP URLs)
Was this page helpful?