Skip to main content

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

  1. When you create a webhook subscription, Pictify generates a unique signing secret
  2. For each webhook delivery, Pictify creates a signature using HMAC-SHA256
  3. Your server verifies the signature before processing the webhook

Signature Format

The signature is sent in the X-Pictify-Signature header:
X-Pictify-Signature: t=1706515260,v1=abc123...
ComponentDescription
tUnix timestamp when the webhook was sent
v1HMAC-SHA256 signature of {timestamp}.{payload}

Verification Algorithm

  1. Extract components - Parse the timestamp (t) and signature (v1) from the header
  2. Check timestamp - Reject if older than 5 minutes (replay protection)
  3. Compute signature - Calculate HMAC-SHA256(secret, "{timestamp}.{payload}")
  4. 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"

Go

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

  1. Check secret - Ensure you’re using the correct webhook secret
  2. Raw body - Make sure you’re using the raw request body, not parsed JSON
  3. Encoding - The payload must be UTF-8 encoded

”Timestamp too old” Error

  1. Clock sync - Ensure your server’s clock is synchronized (NTP)
  2. Processing delay - If processing takes too long, the webhook may expire
  3. 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()