Webhook Verification
Webhook signature verification ensures that incoming webhooks are genuinely from Pictify and haven’t been tampered with.
Always verify webhook signatures in production. Never skip verification, even for testing.
How It Works
- When you create a webhook subscription, Pictify generates a unique signing secret
- For each webhook delivery, Pictify creates a signature using HMAC-SHA256
- Your server verifies the signature before processing the webhook
The signature is sent in the X-Pictify-Signature header:
X-Pictify-Signature: t=1706515260,v1=abc123...
| Component | Description |
|---|
t | Unix timestamp when the webhook was sent |
v1 | HMAC-SHA256 signature of {timestamp}.{payload} |
Verification Algorithm
- Extract components - Parse the timestamp (
t) and signature (v1) from the header
- Check timestamp - Reject if older than 5 minutes (replay protection)
- Compute signature - Calculate
HMAC-SHA256(secret, "{timestamp}.{payload}")
- Compare - Use constant-time comparison to compare signatures
Implementation Examples
Node.js
import crypto from 'crypto';
interface VerificationResult {
valid: boolean;
error?: string;
}
export function verifyWebhookSignature(
payload: string,
signatureHeader: string,
secret: string
): VerificationResult {
// Parse the signature header
const parts: Record<string, string> = {};
for (const pair of signatureHeader.split(',')) {
const [key, value] = pair.split('=');
parts[key] = value;
}
const timestamp = parseInt(parts.t, 10);
const providedSignature = parts.v1;
// Check timestamp (5 minute tolerance)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - timestamp) > 300) {
return { valid: false, error: 'Timestamp too old' };
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
const valid = crypto.timingSafeEqual(
Buffer.from(providedSignature),
Buffer.from(expectedSignature)
);
return { valid };
}
Python
import hmac
import hashlib
import time
from typing import Tuple
def verify_webhook_signature(
payload: bytes,
signature_header: str,
secret: str
) -> Tuple[bool, str | None]:
"""
Verify a Pictify webhook signature.
Returns:
Tuple of (is_valid, error_message)
"""
# Parse the signature header
parts = dict(pair.split('=') for pair in signature_header.split(','))
timestamp = int(parts.get('t', 0))
provided_signature = parts.get('v1', '')
# Check timestamp (5 minute tolerance)
current_time = int(time.time())
if abs(current_time - timestamp) > 300:
return False, "Timestamp too old"
# Compute expected signature
signed_payload = f"{timestamp}.{payload.decode()}"
expected_signature = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
valid = hmac.compare_digest(provided_signature, expected_signature)
return valid, None if valid else "Invalid signature"
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
"time"
)
type VerificationError struct {
Message string
}
func (e *VerificationError) Error() string {
return e.Message
}
func VerifyWebhookSignature(payload, signatureHeader, secret string) error {
// Parse the signature header
parts := make(map[string]string)
for _, pair := range strings.Split(signatureHeader, ",") {
kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
timestamp, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil {
return &VerificationError{"Invalid timestamp"}
}
providedSignature := parts["v1"]
// Check timestamp (5 minute tolerance)
currentTime := time.Now().Unix()
if math.Abs(float64(currentTime-timestamp)) > 300 {
return &VerificationError{"Timestamp too old"}
}
// Compute expected signature
signedPayload := fmt.Sprintf("%d.%s", timestamp, payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
if !hmac.Equal([]byte(providedSignature), []byte(expectedSignature)) {
return &VerificationError{"Invalid signature"}
}
return nil
}
Ruby
require 'openssl'
require 'rack/utils'
module Pictify
class WebhookVerifier
TOLERANCE = 300 # 5 minutes
def self.verify(payload:, signature_header:, secret:)
parts = signature_header.split(',').map { |p| p.split('=') }.to_h
timestamp = parts['t'].to_i
provided_signature = parts['v1']
# Check timestamp
if (Time.now.to_i - timestamp).abs > TOLERANCE
return { valid: false, error: 'Timestamp too old' }
end
# Compute expected signature
signed_payload = "#{timestamp}.#{payload}"
expected_signature = OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
# Constant-time comparison
valid = Rack::Utils.secure_compare(provided_signature, expected_signature)
{ valid: valid, error: valid ? nil : 'Invalid signature' }
end
end
end
Framework Integration
Express.js
import express from 'express';
import { verifyWebhookSignature } from './webhook-utils';
const app = express();
// Important: Use raw body parser for webhook routes
app.post(
'/webhooks/pictify',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-pictify-signature'] as string;
const payload = req.body.toString();
const { valid, error } = verifyWebhookSignature(
payload,
signature,
process.env.PICTIFY_WEBHOOK_SECRET!
);
if (!valid) {
console.error('Webhook verification failed:', error);
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
// Process the webhook
switch (event.event) {
case 'render.completed':
handleRenderCompleted(event.data);
break;
case 'render.failed':
handleRenderFailed(event.data);
break;
}
res.status(200).send('OK');
}
);
FastAPI
from fastapi import FastAPI, Request, HTTPException, Header
app = FastAPI()
@app.post("/webhooks/pictify")
async def handle_webhook(
request: Request,
x_pictify_signature: str = Header(...)
):
payload = await request.body()
valid, error = verify_webhook_signature(
payload,
x_pictify_signature,
WEBHOOK_SECRET
)
if not valid:
raise HTTPException(status_code=401, detail=error)
event = await request.json()
# Process the webhook
if event['event'] == 'render.completed':
await handle_render_completed(event['data'])
elif event['event'] == 'render.failed':
await handle_render_failed(event['data'])
return {"status": "ok"}
Rails
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def pictify
result = Pictify::WebhookVerifier.verify(
payload: request.raw_post,
signature_header: request.headers['X-Pictify-Signature'],
secret: ENV['PICTIFY_WEBHOOK_SECRET']
)
unless result[:valid]
Rails.logger.error "Webhook verification failed: #{result[:error]}"
return head :unauthorized
end
event = JSON.parse(request.raw_post)
case event['event']
when 'render.completed'
HandleRenderCompletedJob.perform_later(event['data'])
when 'render.failed'
HandleRenderFailedJob.perform_later(event['data'])
end
head :ok
end
end
Security Best Practices
1. Store Secrets Securely
# Use environment variables
export PICTIFY_WEBHOOK_SECRET=whsec_abc123...
# Or use a secrets manager
aws secretsmanager get-secret-value --secret-id pictify/webhook-secret
2. Use Constant-Time Comparison
Always use constant-time comparison to prevent timing attacks:
// ✅ Good - constant time
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
// ❌ Bad - vulnerable to timing attacks
a === b
3. Enforce Timestamp Checks
Replay protection prevents attackers from resending old webhooks:
// Reject webhooks older than 5 minutes
const MAX_AGE = 300; // 5 minutes in seconds
if (Math.abs(Date.now() / 1000 - timestamp) > MAX_AGE) {
throw new Error('Webhook too old');
}
4. Use HTTPS Only
Always use HTTPS for your webhook endpoint:
✅ https://api.yoursite.com/webhooks/pictify
❌ http://api.yoursite.com/webhooks/pictify
5. Log Verification Failures
Monitor for potential attacks:
if (!valid) {
logger.warn('Webhook verification failed', {
ip: req.ip,
signature: signature.substring(0, 20) + '...',
timestamp,
error
});
}
Troubleshooting
”Invalid signature” Error
- Check secret - Ensure you’re using the correct webhook secret
- Raw body - Make sure you’re using the raw request body, not parsed JSON
- Encoding - The payload must be UTF-8 encoded
”Timestamp too old” Error
- Clock sync - Ensure your server’s clock is synchronized (NTP)
- Processing delay - If processing takes too long, the webhook may expire
- Retry queue - Pictify retries failed webhooks, which may be older
Common Mistakes
// ❌ Wrong - JSON.stringify may change the payload
const payload = JSON.stringify(req.body);
// ✅ Correct - use raw body
const payload = req.body.toString();
# ❌ Wrong - using parsed JSON
payload = json.dumps(request.json)
# ✅ Correct - use raw body
payload = request.get_data()