> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pictify.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Verification

> Verify webhook signatures to ensure authenticity

# Webhook Verification

Webhook signature verification ensures that incoming webhooks are genuinely from Pictify and haven't been tampered with.

<Warning>
  Always verify webhook signatures in production. Never skip verification, even for testing.
</Warning>

## 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...
```

| Component | Description                                      |
| --------- | ------------------------------------------------ |
| `t`       | Unix timestamp when the webhook was sent         |
| `v1`      | HMAC-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

```typescript theme={null}
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

```python theme={null}
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

```go theme={null}
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

```ruby theme={null}
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

```typescript theme={null}
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

```python theme={null}
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

```ruby theme={null}
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

```bash theme={null}
# 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:

```typescript theme={null}
// ✅ 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:

```typescript theme={null}
// 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:

```typescript theme={null}
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

```typescript theme={null}
// ❌ Wrong - JSON.stringify may change the payload
const payload = JSON.stringify(req.body);

// ✅ Correct - use raw body
const payload = req.body.toString();
```

```python theme={null}
# ❌ Wrong - using parsed JSON
payload = json.dumps(request.json)

# ✅ Correct - use raw body
payload = request.get_data()
```
