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:
tis the Unix timestamp (in seconds) when the signature was generatedv1is the HMAC-SHA256 signature encoded as hexadecimal
How the Signature Is Generated
Paypercut computes the signature using the following algorithm:
- Take the Unix timestamp (t)
- Concatenate it with the raw request body using a dot (.) timestamp.raw_body
- Compute the HMAC-SHA256 of this string using your webhook endpoint secret
- 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:
- Authentication: Verify webhook signature (HMAC-SHA256)
- Validation: Check payload structure, timestamps, and content
- Rate Limiting: Prevent brute force and denial-of-service attacks
- Monitoring: Log all webhook activity for security analysis
- 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-Idheader 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 successfully401 Unauthorized: Signature verification failed400 Bad Request: Invalid payload structure500 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:
- Body modification: Ensure you're using the raw request body, not parsed/re-encoded JSON
- Character encoding: Handle payload as UTF-8
- Whitespace changes: Don't trim or modify whitespace in the body
- Header parsing: Correctly extract timestamp and signature from header
- 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));