Webhook Verification
Always verify webhook signatures to ensure requests are from Pixlpay.
Why Verify?
Without verification, attackers could send fake webhook payloads to your endpoint. Signature verification ensures the request actually came from Pixlpay and hasn't been tampered with in transit.
Security risks of skipping verification:
- Attackers could trigger fake purchases in your system
- Malicious actors could grant themselves in-game items or Discord roles
- Fraudsters could mark orders as fulfilled without payment
How Signature Verification Works
Pixlpay uses HMAC-SHA256 to sign all webhook payloads. Here's the process:
1. Pixlpay Signs the Payload
When Pixlpay sends a webhook:
- The JSON payload is serialized to a string
- The string is signed using HMAC-SHA256 with your webhook secret
- The resulting signature (a 64-character hex string) is sent in the
X-Webhook-Signatureheader
2. You Verify the Signature
When you receive a webhook:
- Read the raw request body (before parsing JSON)
- Compute HMAC-SHA256 of the raw body using your webhook secret
- Compare your computed signature to the
X-Webhook-Signatureheader - If they match, the request is authentic
Webhook Headers
Every webhook request includes these headers:
| Header | Description | Example |
|---|---|---|
X-Webhook-Signature | HMAC-SHA256 signature of the payload | a1b2c3d4e5f6... (64 hex chars) |
X-Webhook-Event | The event type | order.received |
X-Webhook-ID | Unique delivery ID | 12345 |
Content-Type | Always JSON | application/json |
User-Agent | Identifies Pixlpay | Prism-Webhooks/1.0 |
Step-by-Step Verification Process
Step 1: Get the Raw Body
Critical: You must use the raw request body, not a parsed or modified version. Parsing and re-serializing JSON can change whitespace or key ordering, which will break verification.
Step 2: Get Your Webhook Secret
Your webhook secret was provided when you created the webhook endpoint. Store it securely as an environment variable.
# .env
PIXLPAY_WEBHOOK_SECRET=whsec_your_secret_hereStep 3: Compute the Expected Signature
Use HMAC-SHA256 with:
- Key: Your webhook secret
- Message: The raw request body (as bytes/string)
- Output: Hex-encoded string
Step 4: Compare Signatures Securely
Always use timing-safe comparison to prevent timing attacks. Never use == or === for signature comparison.
Code Examples
PHP
<?php
/**
* Verify webhook signature using HMAC-SHA256.
*
* @param string $payload Raw request body
* @param string $signature Value of X-Webhook-Signature header
* @param string $secret Your webhook secret
* @return bool True if signature is valid
*/
function verifyWebhookSignature(string $payload, string $signature, string $secret): bool
{
$computed = hash_hmac('sha256', $payload, $secret);
// Use timing-safe comparison to prevent timing attacks
return hash_equals($computed, $signature);
}
// Complete webhook handler
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$eventType = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
$deliveryId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';
$secret = getenv('PIXLPAY_WEBHOOK_SECRET');
// Verify signature first
if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
error_log("Invalid webhook signature for delivery: {$deliveryId}");
exit('Invalid signature');
}
// Now safe to parse the payload
$event = json_decode($payload, true);
// Process based on event type
switch ($eventType) {
case 'order.received':
handleOrderReceived($event['data']);
break;
case 'subscription.created':
handleSubscriptionCreated($event['data']);
break;
// ... handle other events
}
http_response_code(200);
echo 'OK';Node.js
const crypto = require('crypto');
const express = require('express');
/**
* Verify webhook signature using HMAC-SHA256.
*
* @param {Buffer|string} payload - Raw request body
* @param {string} signature - Value of X-Webhook-Signature header
* @param {string} secret - Your webhook secret
* @returns {boolean} True if signature is valid
*/
function verifyWebhookSignature(payload, signature, secret) {
const computed = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(computed, 'utf8'),
Buffer.from(signature, 'utf8')
);
} catch (e) {
// Lengths don't match
return false;
}
}
const app = express();
// Important: Use express.raw() to get the raw body before JSON parsing
app.post('/webhooks/pixlpay', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const eventType = req.headers['x-webhook-event'];
const deliveryId = req.headers['x-webhook-id'];
const secret = process.env.PIXLPAY_WEBHOOK_SECRET;
// Verify signature first
if (!verifyWebhookSignature(req.body, signature, secret)) {
console.error(`Invalid webhook signature for delivery: ${deliveryId}`);
return res.status(401).send('Invalid signature');
}
// Now safe to parse the payload
const event = JSON.parse(req.body.toString());
// Process based on event type
switch (eventType) {
case 'order.received':
handleOrderReceived(event.data);
break;
case 'subscription.created':
handleSubscriptionCreated(event.data);
break;
// ... handle other events
}
res.sendStatus(200);
});
app.listen(3000);Python
import hmac
import hashlib
import os
from flask import Flask, request, abort
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
"""
Verify webhook signature using HMAC-SHA256.
Args:
payload: Raw request body as bytes
signature: Value of X-Webhook-Signature header
secret: Your webhook secret
Returns:
True if signature is valid
"""
computed = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
# Use timing-safe comparison to prevent timing attacks
return hmac.compare_digest(computed, signature)
app = Flask(__name__)
@app.route('/webhooks/pixlpay', methods=['POST'])
def handle_webhook():
# Get raw body before Flask parses it
payload = request.data
signature = request.headers.get('X-Webhook-Signature', '')
event_type = request.headers.get('X-Webhook-Event', '')
delivery_id = request.headers.get('X-Webhook-ID', '')
secret = os.environ['PIXLPAY_WEBHOOK_SECRET']
# Verify signature first
if not verify_webhook_signature(payload, signature, secret):
app.logger.error(f'Invalid webhook signature for delivery: {delivery_id}')
abort(401, 'Invalid signature')
# Now safe to parse the payload
event = request.json
# Process based on event type
handlers = {
'order.received': handle_order_received,
'subscription.created': handle_subscription_created,
# ... handle other events
}
handler = handlers.get(event_type)
if handler:
handler(event['data'])
return '', 200
def handle_order_received(data: dict):
print(f"Order received: {data['order_number']}")
def handle_subscription_created(data: dict):
print(f"Subscription created: {data['id']}")
if __name__ == '__main__':
app.run(port=3000)Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
)
// verifyWebhookSignature verifies the HMAC-SHA256 signature.
func verifyWebhookSignature(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
computed := hex.EncodeToString(mac.Sum(nil))
// Use constant-time comparison to prevent timing attacks
return subtle.ConstantTimeCompare([]byte(computed), []byte(signature)) == 1
}
type WebhookEvent struct {
ID string `json:"id"`
EventType string `json:"event_type"`
CreatedAt string `json:"created_at"`
Data json.RawMessage `json:"data"`
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
// Read raw body
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-Webhook-Signature")
eventType := r.Header.Get("X-Webhook-Event")
deliveryId := r.Header.Get("X-Webhook-ID")
secret := os.Getenv("PIXLPAY_WEBHOOK_SECRET")
// Verify signature first
if !verifyWebhookSignature(payload, signature, secret) {
log.Printf("Invalid webhook signature for delivery: %s", deliveryId)
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Now safe to parse the payload
var event WebhookEvent
if err := json.Unmarshal(payload, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Process based on event type
switch eventType {
case "order.received":
handleOrderReceived(event.Data)
case "subscription.created":
handleSubscriptionCreated(event.Data)
// ... handle other events
}
w.WriteHeader(http.StatusOK)
}
func handleOrderReceived(data json.RawMessage) {
log.Printf("Order received: %s", string(data))
}
func handleSubscriptionCreated(data json.RawMessage) {
log.Printf("Subscription created: %s", string(data))
}
func main() {
http.HandleFunc("/webhooks/pixlpay", webhookHandler)
log.Fatal(http.ListenAndServe(":3000", nil))
}Best Practices
1. Always Verify Signatures
Never skip signature verification, even in development. Use test mode webhooks to verify your implementation works correctly.
2. Use Timing-Safe Comparison
Regular string comparison (==) can leak information through timing differences. Always use:
- PHP:
hash_equals() - Node.js:
crypto.timingSafeEqual() - Python:
hmac.compare_digest() - Go:
subtle.ConstantTimeCompare()
3. Use the Raw Request Body
Parse JSON after verification. Parsing and re-encoding can change the payload (whitespace, key order), breaking the signature.
4. Secure Your Secret
- Store secrets in environment variables, never in code
- Rotate secrets periodically
- Use different secrets for test and production environments
- Never log or expose your webhook secret
5. Log Verification Failures
Track failed signature attempts for security monitoring:
if (!verifyWebhookSignature($payload, $signature, $secret)) {
error_log(json_encode([
'error' => 'webhook_signature_invalid',
'delivery_id' => $deliveryId,
'ip' => $_SERVER['REMOTE_ADDR'],
'timestamp' => date('c'),
]));
http_response_code(401);
exit;
}6. Respond Quickly
Return a 200 response as soon as you've verified the signature and queued the event for processing. Don't do heavy processing before responding, or the webhook may time out and retry.
Troubleshooting
Signature Mismatch
Problem: Your computed signature doesn't match X-Webhook-Signature.
Solutions:
- Use the raw body - Don't parse JSON before computing the signature
- Check your secret - Ensure you're using the correct secret for this webhook
- Check for middleware - Ensure no middleware modifies the request body
- Check encoding - The payload must be treated as UTF-8
// WRONG - parsing changes the payload
$event = json_decode(file_get_contents('php://input'), true);
$computed = hash_hmac('sha256', json_encode($event), $secret);
// CORRECT - use raw body
$payload = file_get_contents('php://input');
$computed = hash_hmac('sha256', $payload, $secret);Missing Header
Problem: X-Webhook-Signature header is empty or missing.
Solutions:
- Check header name - The header is
X-Webhook-Signature(case-insensitive) - Check your framework - Some frameworks/proxies strip custom headers
- Check PHP conversion - PHP converts headers:
X-Webhook-Signaturebecomes$_SERVER['HTTP_X_WEBHOOK_SIGNATURE']
Signature Works Locally but Fails in Production
Solutions:
- Different secrets - Ensure production uses the production webhook secret
- Proxy issues - Some proxies modify the body (gzip, encoding)
- Load balancer - Check if your load balancer modifies requests
Framework-Specific Issues
Express.js:
// WRONG - body-parser/express.json() parses before your handler
app.use(express.json());
app.post('/webhooks', (req, res) => { /* req.body is parsed */ });
// CORRECT - use express.raw() for this specific route
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
// req.body is a Buffer containing the raw body
});Django:
# Access raw body before DRF/Django parses it
raw_body = request.body # bytesLaravel:
// Use request()->getContent() for raw body
$payload = request()->getContent();Testing Verification
Use the webhook test feature to verify your implementation:
# Send a test webhook to your endpoint
curl -X POST "https://yourstore.pixlpay.net/api/external/v1/webhooks/1/test" \
-H "Authorization: Bearer YOUR_API_TOKEN"You can also manually test signature verification:
# Generate a test signature
SECRET="your_webhook_secret"
PAYLOAD='{"event_type":"webhook.test","data":{"test":true}}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
# Send test request
curl -X POST "https://your-server.com/webhooks/pixlpay" \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: $SIGNATURE" \
-H "X-Webhook-Event: webhook.test" \
-H "X-Webhook-ID: test-123" \
-d "$PAYLOAD"Replay Attack Prevention
While Pixlpay's signature verification prevents payload tampering, you should also implement replay attack prevention:
- Store processed delivery IDs - Track which
X-Webhook-IDvalues you've processed - Check for duplicates - Reject requests with previously-seen IDs
- Set an expiry - Only store IDs for a reasonable period (e.g., 24 hours)
// Example: Using Redis for idempotency
$deliveryId = $_SERVER['HTTP_X_WEBHOOK_ID'];
$cacheKey = "webhook_processed:{$deliveryId}";
if (Redis::exists($cacheKey)) {
// Already processed this delivery
http_response_code(200);
exit('Already processed');
}
// Process the webhook...
// Mark as processed (expire after 24 hours)
Redis::setex($cacheKey, 86400, '1');Next Steps
- Webhook Events Reference - List of all webhook events
- Creating Webhooks - How to set up webhook endpoints
- Code Examples - Full integration examples in multiple languages
