Validating Incoming Webhook Signatures

When UPayments sends real-time transaction updates to your notificationUrl (webhook), an HMAC signature is included in the request headers. Validating this signature on your server ensures that the webhook originated from UPayments and that the payload was not tampered with during transit.

How Webhook Validation Works

To verify an incoming webhook, follow the exact same steps used for generating outbound request signatures:

  1. Extract Headers & Body: Retrieve the X-Timestamp (timestamp in UTC seconds) and X-Signature headers, along with the raw, unparsed JSON request body sent by UPayments.
  2. Reconstruct the Payload String: Build the string-to-sign using the timestamp, HTTP method (POST), webhook URL, and raw request body.
  3. Generate the Expected Signature: Calculate the HMAC SHA-256 hash using your API Secret Key and Base64-encode the result.
  4. Compare Signatures: Compare your generated signature against the X-Signature header value received in the request. If they match, the request is authentic.

⚠️

Key Difference: Full URL vs. Relative Path

When generating signatures for outbound API requests, the payload uses only the relative endpoint path following /api/v1/ (e.g., charge).

However, for incoming Webhook signature validation, the payload string must contain the full notification URL starting with https:// or http://.

Webhook Payload Format

timestamp + HTTP_METHOD + FULL_NOTIFICATION_URL + RAW_REQUEST_BODY
  • timestamp: The value from the X-Timestamp header.
  • HTTP_METHOD: Always POST for webhooks.
  • FULL_NOTIFICATION_URL: The complete, absolute webhook URL configured in your request (e.g., https://yourdomain.com/api/v1/webhook).
  • RAW_REQUEST_BODY: The exact raw JSON body received in the HTTP request.

Webhook Verification Examples

const crypto = require('crypto');

function verifyWebhookSignature(req, apiSecret) {
    const receivedSignature = req.headers['x-signature'];
    const timestamp = req.headers['x-timestamp'];
    const httpMethod = 'POST';
    
    // Use the full URL for webhooks (e.g., https://yourdomain.com/webhook)
    const fullUrl = req.protocol + '://' + req.get('host') + req.originalUrl;
    const rawBody = JSON.stringify(req.body); // Ensure raw unformatted string

    // Construct payload
    const payload = timestamp + httpMethod + fullUrl + rawBody;

    // Calculate HMAC SHA-256
    const expectedSignature = crypto
        .createHmac('sha256', apiSecret)
        .update(payload)
        .digest('base64');

    return receivedSignature === expectedSignature;
}
const crypto = require('crypto');

/**
 * Validates the HMAC signature of an incoming UPayments webhook request.
 * @param {Object} req - Express request object.
 * @param {string} apiSecret - Your Merchant API Secret Key.
 * @returns {boolean} True if the signature is authentic.
 */
function verifyWebhookSignature(req, apiSecret) {
    const receivedSignature = req.headers['x-signature'];
    const timestamp = req.headers['x-timestamp'];
    const httpMethod = req.method.toUpperCase(); // 'POST'

    // Full notification URL (e.g., https://yourdomain.com/api/v1/webhook)
    const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;

    // Raw unparsed JSON string
    const rawBody = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);

    // Payload: timestamp + HTTP_METHOD + FULL_NOTIFICATION_URL + RAW_REQUEST_BODY
    const payload = timestamp + httpMethod + fullUrl + rawBody;

    // Calculate HMAC SHA-256 Base64 signature
    const expectedSignature = crypto
        .createHmac('sha256', apiSecret)
        .update(payload)
        .digest('base64');

    // Secure constant-time comparison
    return crypto.timingSafeEqual(
        Buffer.from(receivedSignature || ''),
        Buffer.from(expectedSignature)
    );
}
<?php

/**
 * Validates the HMAC signature of an incoming UPayments webhook request.
 * 
 * @param string $apiSecret Your Merchant API Secret Key.
 * @return bool True if the signature is authentic.
 */
function verifyWebhookSignature($apiSecret) {
    // Retrieve HTTP headers
    $headers = array_change_key_case(getallheaders(), CASE_LOWER);
    $receivedSignature = $headers['x-signature'] ?? '';
    $timestamp = $headers['x-timestamp'] ?? '';
    $httpMethod = $_SERVER['REQUEST_METHOD']; // 'POST'

    // Reconstruct full notification URL
    $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    $fullUrl = $protocol . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];

    // Read raw input body from stream
    $rawBody = file_get_contents('php://input');

    // Payload: timestamp + HTTP_METHOD + FULL_NOTIFICATION_URL + RAW_REQUEST_BODY
    $payload = $timestamp . $httpMethod . $fullUrl . $rawBody;

    // Calculate HMAC SHA-256 and Base64 encode
    $hash = hash_hmac('sha256', $payload, $apiSecret, true);
    $expectedSignature = base64_encode($hash);

    // Timing-attack safe comparison
    return hash_equals($expectedSignature, $receivedSignature);
}
import hmac
import hashlib
import base64

def verify_webhook_signature(headers, full_url, raw_body_bytes_or_str, api_secret, http_method="POST"):
    """
    Validates the HMAC signature of an incoming UPayments webhook request.
    
    :param headers: Request headers dict (e.g. request.headers)
    :param full_url: Full notification URL (e.g. 'https://yourdomain.com/webhook')
    :param raw_body_bytes_or_str: Raw unparsed HTTP request body
    :param api_secret: Merchant API Secret Key
    :param http_method: HTTP Method ('POST')
    :return: bool indicating signature validity
    """
    received_signature = headers.get('X-Signature') or headers.get('x-signature', '')
    timestamp = headers.get('X-Timestamp') or headers.get('x-timestamp', '')

    if isinstance(raw_body_bytes_or_str, bytes):
        raw_body_str = raw_body_bytes_or_str.decode('utf-8')
    else:
        raw_body_str = str(raw_body_bytes_or_str)

    # Payload: timestamp + HTTP_METHOD + FULL_NOTIFICATION_URL + RAW_REQUEST_BODY
    payload = f"{timestamp}{http_method.upper()}{full_url}{raw_body_str}"

    # Calculate HMAC SHA-256 and Base64 encode
    signature_hash = hmac.new(
        api_secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).digest()

    expected_signature = base64.b64encode(signature_hash).decode('utf-8')

    # Timing-attack safe comparison
    return hmac.compare_digest(expected_signature, received_signature)