Skip to content

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:

  1. The JSON payload is serialized to a string
  2. The string is signed using HMAC-SHA256 with your webhook secret
  3. The resulting signature (a 64-character hex string) is sent in the X-Webhook-Signature header

2. You Verify the Signature

When you receive a webhook:

  1. Read the raw request body (before parsing JSON)
  2. Compute HMAC-SHA256 of the raw body using your webhook secret
  3. Compare your computed signature to the X-Webhook-Signature header
  4. If they match, the request is authentic

Webhook Headers

Every webhook request includes these headers:

HeaderDescriptionExample
X-Webhook-SignatureHMAC-SHA256 signature of the payloada1b2c3d4e5f6... (64 hex chars)
X-Webhook-EventThe event typeorder.received
X-Webhook-IDUnique delivery ID12345
Content-TypeAlways JSONapplication/json
User-AgentIdentifies PixlpayPrism-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.

bash
# .env
PIXLPAY_WEBHOOK_SECRET=whsec_your_secret_here

Step 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
<?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

javascript
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

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

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:

php
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:

  1. Use the raw body - Don't parse JSON before computing the signature
  2. Check your secret - Ensure you're using the correct secret for this webhook
  3. Check for middleware - Ensure no middleware modifies the request body
  4. Check encoding - The payload must be treated as UTF-8
php
// 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:

  1. Check header name - The header is X-Webhook-Signature (case-insensitive)
  2. Check your framework - Some frameworks/proxies strip custom headers
  3. Check PHP conversion - PHP converts headers: X-Webhook-Signature becomes $_SERVER['HTTP_X_WEBHOOK_SIGNATURE']

Signature Works Locally but Fails in Production

Solutions:

  1. Different secrets - Ensure production uses the production webhook secret
  2. Proxy issues - Some proxies modify the body (gzip, encoding)
  3. Load balancer - Check if your load balancer modifies requests

Framework-Specific Issues

Express.js:

javascript
// 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:

python
# Access raw body before DRF/Django parses it
raw_body = request.body  # bytes

Laravel:

php
// Use request()->getContent() for raw body
$payload = request()->getContent();

Testing Verification

Use the webhook test feature to verify your implementation:

bash
# 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:

bash
# 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:

  1. Store processed delivery IDs - Track which X-Webhook-ID values you've processed
  2. Check for duplicates - Reject requests with previously-seen IDs
  3. Set an expiry - Only store IDs for a reasonable period (e.g., 24 hours)
php
// 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

Built for game developers, by game developers.