Webhook Signature Verification

Paypercut signs every webhook it sends so you can verify that the event was genuinely created by Paypercut and that the payload was not modified in transit.

This document explains how webhook signatures work, how to verify them, and best practices for secure and reliable webhook handling.


Overview

Each webhook request sent by Paypercut includes a cryptographic signature in the HTTP headers. The signature is generated using HMAC-SHA256 and a shared secret that belongs to your webhook endpoint.

You must verify this signature on every webhook request before processing the payload.

Why signature verification is critical:

  • Authentication: Confirms the webhook was sent by Paypercut, not a malicious third party
  • Integrity: Ensures the payload was not tampered with during transmission
  • Replay Protection: Prevents attackers from reusing captured webhook requests
  • Compliance: Meets security requirements for handling sensitive payment data

Headers Sent with Each Webhook

Every webhook request includes the following headers:

Header Description
Paypercut-Signature Signature used to verify authenticity
Paypercut-Delivery-Id Unique ID of the delivery attempt
Paypercut-Event-Id Unique ID of the webhook event
Content-Type Always application/json

Signature Header Format

The signature header has the following format:

t=,v1=<hex_signature>

Example:

t=1768991448,v1=51844041050a81c3a22a15e3e6cb29167d4265f7068764671a488027931f3630

Where:

  • t is the Unix timestamp (in seconds) when the signature was generated
  • v1 is the HMAC-SHA256 signature encoded as hexadecimal

How the Signature Is Generated

Paypercut computes the signature using the following algorithm:

  1. Take the Unix timestamp (t)
  2. Concatenate it with the raw request body using a dot (.) timestamp.raw_body
  3. Compute the HMAC-SHA256 of this string using your webhook endpoint secret
  4. Encode the result as hexadecimal

Formula:

HMAC_SHA256(
    secret,
    timestamp + “.” + raw_body
)

Important: Use the Raw Request Body

Signature verification must be performed against the exact raw bytes of the HTTP request body.

Do not:

  • Parse and re-encode JSON
  • Pretty-print JSON
  • Reorder fields
  • Change whitespace
  • Convert numbers or booleans

Any modification to the payload bytes will result in a different signature and cause verification to fail.

Verifying a Webhook (Step-by-Step)

Follow these steps to verify every incoming webhook:

Read the Raw Body

Read the HTTP request body as bytes and store it exactly as received.

Critical: Do not parse, modify, or re-encode the body before verification.

Extract the Signature Header

Read the Paypercut-Signature header and extract:

  • Timestamp (t)
  • Signature (v1)

Example parsing:

const signatureHeader = request.headers['Paypercut-Signature'];
const parts = signatureHeader.split(',');
const timestamp = parts[0].split('=')[1];
const signature = parts[1].split('=')[1];
Check Timestamp Tolerance

To protect against replay attacks, ensure the timestamp is recent.

Recommended tolerance: ±5 minutes (300 seconds)

Requests outside this window should be rejected with a 401 Unauthorized response.

const currentTime = Math.floor(Date.now() / 1000);
const timeDifference = Math.abs(currentTime - timestamp);

if (timeDifference > 300) {
  throw new Error('Webhook timestamp is too old');
}

Why this matters: Without timestamp validation, attackers could capture and replay valid webhook requests to trigger duplicate actions in your system.

Recompute the Signature

Recompute the HMAC using:

timestamp + “.” + raw_body

and your webhook secret.

const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
  .createHmac('sha256', webhookSecret)
  .update(signedPayload)
  .digest('hex');
Compare Signatures

Compare the computed signature with the one from the header using a constant-time comparison.

Never use == or === operators for signature comparison. These are vulnerable to timing attacks.

// CORRECT - Constant-time comparison
const isValid = crypto.timingSafeEqual(
  Buffer.from(expectedSignature),
  Buffer.from(signature)
);

// WRONG - Vulnerable to timing attacks
const isValid = (expectedSignature === signature);

If the signatures match, the webhook is authentic and safe to process.


Security Best Practices

1. Always Use HTTPS

Never accept webhook requests over unencrypted HTTP connections.

  • Configure your webhook endpoint to use HTTPS with valid SSL/TLS certificates
  • Implement HTTP Strict Transport Security (HSTS) headers
  • Use certificates from trusted certificate authorities

HTTPS prevents man-in-the-middle attacks and protects authentication credentials in transit.

2. Store Secrets Securely

Never hardcode webhook secrets in your application code or commit them to version control.

Recommended approaches:

  • Use environment variables
  • Use encrypted configuration management systems
  • Use dedicated secrets management services (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault)

Secret rotation: Rotate your webhook secrets quarterly at minimum. Implement automated rotation processes where possible.

3. Implement Defense-in-Depth

Layer multiple security mechanisms:

  1. Authentication: Verify webhook signature (HMAC-SHA256)
  2. Validation: Check payload structure, timestamps, and content
  3. Rate Limiting: Prevent brute force and denial-of-service attacks
  4. Monitoring: Log all webhook activity for security analysis
  5. Isolation: Run webhook handlers with minimal privileges

4. Validate Payload Structure

After verifying the signature, validate the payload structure:

function validatePayload(payload) {
  // Check required fields exist
  if (!payload.event_type || !payload.data) {
    throw new Error('Invalid payload structure');
  }

  // Validate data types
  if (typeof payload.event_type !== 'string') {
    throw new Error('Invalid event_type format');
  }

  // Validate against expected schema
  // Use JSON Schema validation libraries
}

5. Implement Idempotency

Design webhook handlers to be idempotent - processing the same webhook multiple times should produce the same result.

Best practices:

  • Use the Paypercut-Delivery-Id header to track processed webhooks
  • Store processed delivery IDs in your database or cache
  • Check for duplicate deliveries before processing
async function handleWebhook(deliveryId, payload) {
  // Check if already processed
  const alreadyProcessed = await db.checkDeliveryId(deliveryId);

  if (alreadyProcessed) {
    console.log(`Webhook ${deliveryId} already processed`);
    return { status: 'duplicate' };
  }

  // Process webhook
  await processWebhookEvent(payload);

  // Mark as processed
  await db.saveDeliveryId(deliveryId);

  return { status: 'success' };
}

6. Handle Errors Gracefully

Return appropriate HTTP status codes:

  • 200 OK: Webhook processed successfully
  • 401 Unauthorized: Signature verification failed
  • 400 Bad Request: Invalid payload structure
  • 500 Internal Server Error: Processing error (Paypercut will retry)

Important: Only return 2xx status codes after successfully processing the webhook. Returning 200 before processing can result in lost events.


Implementation Examples

Below are complete implementation examples in popular programming languages.

const express = require('express');
const crypto = require('crypto');
const bodyParser = require('body-parser');

const app = express();
const WEBHOOK_SECRET = process.env.PAYPERCUT_WEBHOOK_SECRET;
const TIMESTAMP_TOLERANCE = 300; // 5 minutes in seconds

// Preserve raw body for signature verification
app.use(bodyParser.json({
  verify: (req, res, buf, encoding) => {
    if (buf && buf.length) {
      req.rawBody = buf.toString(encoding || 'utf8');
    }
  }
}));

function verifyWebhookSignature(req) {
  const signatureHeader = req.get('Paypercut-Signature');

  if (!signatureHeader) {
    throw new Error('Missing Paypercut-Signature header');
  }

  // Parse signature header
  const parts = signatureHeader.split(',');
  const timestamp = parts[0].split('=')[1];
  const receivedSignature = parts[1].split('=')[1];

  // Check timestamp tolerance
  const currentTime = Math.floor(Date.now() / 1000);
  const timeDifference = Math.abs(currentTime - parseInt(timestamp));

  if (timeDifference > TIMESTAMP_TOLERANCE) {
    throw new Error('Webhook timestamp is too old or too far in the future');
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${req.rawBody}`;
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedPayload)
    .digest('hex');

  // Constant-time comparison
  const isValid = crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(receivedSignature)
  );

  if (!isValid) {
    throw new Error('Invalid webhook signature');
  }

  return true;
}

app.post('/webhooks/paypercut', async (req, res) => {
  try {
    // Verify signature
    verifyWebhookSignature(req);

    const deliveryId = req.get('Paypercut-Delivery-Id');
    const eventId = req.get('Paypercut-Event-Id');

    // Check for duplicate delivery
    const isDuplicate = await checkIfProcessed(deliveryId);
    if (isDuplicate) {
      console.log(`Duplicate webhook delivery: ${deliveryId}`);
      return res.status(200).json({ received: true, duplicate: true });
    }

    // Process webhook event
    await processWebhookEvent(req.body);

    // Mark as processed
    await markAsProcessed(deliveryId);

    res.status(200).json({ received: true });

  } catch (error) {
    console.error('Webhook verification failed:', error.message);

    if (error.message.includes('signature') || error.message.includes('timestamp')) {
      return res.status(401).json({ error: 'Unauthorized' });
    }

    res.status(400).json({ error: error.message });
  }
});

async function processWebhookEvent(payload) {
  console.log('Processing event:', payload.event_type);

  switch (payload.event_type) {
    case 'payment.succeeded':
      await handlePaymentSucceeded(payload.data);
      break;
    case 'payment.failed':
      await handlePaymentFailed(payload.data);
      break;
    case 'refund.processed':
      await handleRefundProcessed(payload.data);
      break;
    default:
      console.log('Unhandled event type:', payload.event_type);
  }
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook server listening on port ${PORT}`);
});
import hmac
import hashlib
import time
from flask import Flask, request, jsonify

app = Flask(__name__)

WEBHOOK_SECRET = 'your_webhook_secret_here'
TIMESTAMP_TOLERANCE = 300  # 5 minutes in seconds

def verify_webhook_signature(request):
    """
    Verify the webhook signature from Paypercut.

    Args:
        request: Flask request object

    Returns:
        bool: True if signature is valid

    Raises:
        ValueError: If signature verification fails
    """
    signature_header = request.headers.get('Paypercut-Signature')

    if not signature_header:
        raise ValueError('Missing Paypercut-Signature header')

    # Parse signature header
    parts = signature_header.split(',')
    timestamp = parts[0].split('=')[1]
    received_signature = parts[1].split('=')[1]

    # Check timestamp tolerance
    current_time = int(time.time())
    time_difference = abs(current_time - int(timestamp))

    if time_difference > TIMESTAMP_TOLERANCE:
        raise ValueError('Webhook timestamp is too old or too far in the future')

    # Get raw body
    raw_body = request.get_data()

    # Compute expected signature
    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}"
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    if not hmac.compare_digest(expected_signature, received_signature):
        raise ValueError('Invalid webhook signature')

    return True

@app.route('/webhooks/paypercut', methods=['POST'])
def handle_webhook():
    try:
        # Verify signature
        verify_webhook_signature(request)

        delivery_id = request.headers.get('Paypercut-Delivery-Id')
        event_id = request.headers.get('Paypercut-Event-Id')

        # Check for duplicate delivery
        if check_if_processed(delivery_id):
            app.logger.info(f'Duplicate webhook delivery: {delivery_id}')
            return jsonify({'received': True, 'duplicate': True}), 200

        # Process webhook event
        payload = request.get_json()
        process_webhook_event(payload)

        # Mark as processed
        mark_as_processed(delivery_id)

        return jsonify({'received': True}), 200

    except ValueError as e:
        app.logger.error(f'Webhook verification failed: {str(e)}')

        if 'signature' in str(e).lower() or 'timestamp' in str(e).lower():
            return jsonify({'error': 'Unauthorized'}), 401

        return jsonify({'error': str(e)}), 400

    except Exception as e:
        app.logger.error(f'Webhook processing error: {str(e)}')
        return jsonify({'error': 'Internal server error'}), 500

def process_webhook_event(payload):
    """Process the webhook event based on event type."""
    event_type = payload.get('event_type')

    app.logger.info(f'Processing event: {event_type}')

    if event_type == 'payment.succeeded':
        handle_payment_succeeded(payload.get('data'))
    elif event_type == 'payment.failed':
        handle_payment_failed(payload.get('data'))
    elif event_type == 'refund.processed':
        handle_refund_processed(payload.get('data'))
    else:
        app.logger.warning(f'Unhandled event type: {event_type}')

if __name__ == '__main__':
    app.run(port=3000)
<?php

define('WEBHOOK_SECRET', getenv('PAYPERCUT_WEBHOOK_SECRET'));
define('TIMESTAMP_TOLERANCE', 300); // 5 minutes in seconds

function verifyWebhookSignature($rawBody, $signatureHeader) {
    if (empty($signatureHeader)) {
        throw new Exception('Missing Paypercut-Signature header');
    }

    // Parse signature header
    $parts = explode(',', $signatureHeader);
    $timestampPart = explode('=', $parts[0]);
    $signaturePart = explode('=', $parts[1]);

    $timestamp = $timestampPart[1];
    $receivedSignature = $signaturePart[1];

    // Check timestamp tolerance
    $currentTime = time();
    $timeDifference = abs($currentTime - intval($timestamp));

    if ($timeDifference > TIMESTAMP_TOLERANCE) {
        throw new Exception('Webhook timestamp is too old or too far in the future');
    }

    // Compute expected signature
    $signedPayload = $timestamp . '.' . $rawBody;
    $expectedSignature = hash_hmac('sha256', $signedPayload, WEBHOOK_SECRET);

    // Constant-time comparison
    if (!hash_equals($expectedSignature, $receivedSignature)) {
        throw new Exception('Invalid webhook signature');
    }

    return true;
}

// Get raw body
$rawBody = file_get_contents('php://input');

// Get headers
$signatureHeader = $_SERVER['HTTP_PAYPERCUT_SIGNATURE'] ?? '';
$deliveryId = $_SERVER['HTTP_PAYPERCUT_DELIVERY_ID'] ?? '';
$eventId = $_SERVER['HTTP_PAYPERCUT_EVENT_ID'] ?? '';

try {
    // Verify signature
    verifyWebhookSignature($rawBody, $signatureHeader);

    // Check for duplicate delivery
    if (checkIfProcessed($deliveryId)) {
        error_log("Duplicate webhook delivery: $deliveryId");
        http_response_code(200);
        echo json_encode(['received' => true, 'duplicate' => true]);
        exit;
    }

    // Parse and process webhook
    $payload = json_decode($rawBody, true);
    processWebhookEvent($payload);

    // Mark as processed
    markAsProcessed($deliveryId);

    http_response_code(200);
    echo json_encode(['received' => true]);

} catch (Exception $e) {
    error_log('Webhook verification failed: ' . $e->getMessage());

    if (strpos(strtolower($e->getMessage()), 'signature') !== false ||
        strpos(strtolower($e->getMessage()), 'timestamp') !== false) {
        http_response_code(401);
        echo json_encode(['error' => 'Unauthorized']);
    } else {
        http_response_code(400);
        echo json_encode(['error' => $e->getMessage()]);
    }
}

function processWebhookEvent($payload) {
    $eventType = $payload['event_type'] ?? '';

    error_log("Processing event: $eventType");

    switch ($eventType) {
        case 'payment.succeeded':
            handlePaymentSucceeded($payload['data']);
            break;
        case 'payment.failed':
            handlePaymentFailed($payload['data']);
            break;
        case 'refund.processed':
            handleRefundProcessed($payload['data']);
            break;
        default:
            error_log("Unhandled event type: $eventType");
    }
}
?>
require 'sinatra'
require 'json'
require 'openssl'

WEBHOOK_SECRET = ENV['PAYPERCUT_WEBHOOK_SECRET']
TIMESTAMP_TOLERANCE = 300 # 5 minutes in seconds

def verify_webhook_signature(request)
  signature_header = request.env['HTTP_PAYPERCUT_SIGNATURE']

  raise 'Missing Paypercut-Signature header' if signature_header.nil? || signature_header.empty?

  # Parse signature header
  parts = signature_header.split(',')
  timestamp = parts[0].split('=')[1]
  received_signature = parts[1].split('=')[1]

  # Check timestamp tolerance
  current_time = Time.now.to_i
  time_difference = (current_time - timestamp.to_i).abs

  if time_difference > TIMESTAMP_TOLERANCE
    raise 'Webhook timestamp is too old or too far in the future'
  end

  # Get raw body
  request.body.rewind
  raw_body = request.body.read

  # Compute expected signature
  signed_payload = "#{timestamp}.#{raw_body}"
  expected_signature = OpenSSL::HMAC.hexdigest('sha256', WEBHOOK_SECRET, signed_payload)

  # Constant-time comparison
  unless Rack::Utils.secure_compare(expected_signature, received_signature)
    raise 'Invalid webhook signature'
  end

  true
end

post '/webhooks/paypercut' do
  begin
    # Verify signature
    verify_webhook_signature(request)

    delivery_id = request.env['HTTP_PAYPERCUT_DELIVERY_ID']
    event_id = request.env['HTTP_PAYPERCUT_EVENT_ID']

    # Check for duplicate delivery
    if check_if_processed?(delivery_id)
      logger.info "Duplicate webhook delivery: #{delivery_id}"
      status 200
      return { received: true, duplicate: true }.to_json
    end

    # Parse and process webhook
    request.body.rewind
    payload = JSON.parse(request.body.read)
    process_webhook_event(payload)

    # Mark as processed
    mark_as_processed(delivery_id)

    status 200
    { received: true }.to_json

  rescue => e
    logger.error "Webhook verification failed: #{e.message}"

    if e.message.downcase.include?('signature') || e.message.downcase.include?('timestamp')
      status 401
      { error: 'Unauthorized' }.to_json
    else
      status 400
      { error: e.message }.to_json
    end
  end
end

def process_webhook_event(payload)
  event_type = payload['event_type']

  logger.info "Processing event: #{event_type}"

  case event_type
  when 'payment.succeeded'
    handle_payment_succeeded(payload['data'])
  when 'payment.failed'
    handle_payment_failed(payload['data'])
  when 'refund.processed'
    handle_refund_processed(payload['data'])
  else
    logger.warn "Unhandled event type: #{event_type}"
  end
end
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "math"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const timestampTolerance = 300 // 5 minutes in seconds

type WebhookPayload struct {
    EventType string                 `json:"event_type"`
    Data      map[string]interface{} `json:"data"`
}

func verifyWebhookSignature(rawBody []byte, signatureHeader string, webhookSecret string) error {
    if signatureHeader == "" {
        return fmt.Errorf("missing Paypercut-Signature header")
    }

    // Parse signature header
    parts := strings.Split(signatureHeader, ",")
    if len(parts) != 2 {
        return fmt.Errorf("invalid signature header format")
    }

    timestampPart := strings.Split(parts[0], "=")
    signaturePart := strings.Split(parts[1], "=")

    if len(timestampPart) != 2 || len(signaturePart) != 2 {
        return fmt.Errorf("invalid signature header format")
    }

    timestamp := timestampPart[1]
    receivedSignature := signaturePart[1]

    // Check timestamp tolerance
    webhookTime, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return fmt.Errorf("invalid timestamp format")
    }

    currentTime := time.Now().Unix()
    timeDifference := math.Abs(float64(currentTime - webhookTime))

    if timeDifference > timestampTolerance {
        return fmt.Errorf("webhook timestamp is too old or too far in the future")
    }

    // Compute expected signature
    signedPayload := fmt.Sprintf("%s.%s", timestamp, string(rawBody))
    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write([]byte(signedPayload))
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    // Constant-time comparison
    if !hmac.Equal([]byte(expectedSignature), []byte(receivedSignature)) {
        return fmt.Errorf("invalid webhook signature")
    }

    return nil
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    webhookSecret := os.Getenv("PAYPERCUT_WEBHOOK_SECRET")

    // Read raw body
    rawBody, err := io.ReadAll(r.Body)
    if err != nil {
        log.Printf("Error reading request body: %v", err)
        http.Error(w, "Bad request", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    // Get headers
    signatureHeader := r.Header.Get("Paypercut-Signature")
    deliveryID := r.Header.Get("Paypercut-Delivery-Id")
    eventID := r.Header.Get("Paypercut-Event-Id")

    // Verify signature
    if err := verifyWebhookSignature(rawBody, signatureHeader, webhookSecret); err != nil {
        log.Printf("Webhook verification failed: %v", err)

        if strings.Contains(strings.ToLower(err.Error()), "signature") ||
           strings.Contains(strings.ToLower(err.Error()), "timestamp") {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
        } else {
            http.Error(w, err.Error(), http.StatusBadRequest)
        }
        return
    }

    // Check for duplicate delivery
    if checkIfProcessed(deliveryID) {
        log.Printf("Duplicate webhook delivery: %s", deliveryID)
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]interface{}{
            "received":  true,
            "duplicate": true,
        })
        return
    }

    // Parse and process webhook
    var payload WebhookPayload
    if err := json.Unmarshal(rawBody, &payload); err != nil {
        log.Printf("Error parsing webhook payload: %v", err)
        http.Error(w, "Invalid payload", http.StatusBadRequest)
        return
    }

    processWebhookEvent(payload)

    // Mark as processed
    markAsProcessed(deliveryID)

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]bool{"received": true})
}

func processWebhookEvent(payload WebhookPayload) {
    log.Printf("Processing event: %s", payload.EventType)

    switch payload.EventType {
    case "payment.succeeded":
        handlePaymentSucceeded(payload.Data)
    case "payment.failed":
        handlePaymentFailed(payload.Data)
    case "refund.processed":
        handleRefundProcessed(payload.Data)
    default:
        log.Printf("Unhandled event type: %s", payload.EventType)
    }
}

func main() {
    http.HandleFunc("/webhooks/paypercut", handleWebhook)

    port := os.Getenv("PORT")
    if port == "" {
        port = "3000"
    }

    log.Printf("Webhook server listening on port %s", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

Common Issues and Troubleshooting

Signature Verification Fails

Problem: Signatures don't match even with correct secret.

Common causes:

  1. Body modification: Ensure you're using the raw request body, not parsed/re-encoded JSON
  2. Character encoding: Handle payload as UTF-8
  3. Whitespace changes: Don't trim or modify whitespace in the body
  4. Header parsing: Correctly extract timestamp and signature from header
  5. Wrong secret: Verify you're using the correct webhook secret for the endpoint

Solution:

// CORRECT - Use raw body
app.use(bodyParser.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

// WRONG - Using parsed body
const signature = computeSignature(JSON.stringify(req.body));