Md Nazmus Shakib
Back to articles
Technology

Building a Secure Custom Payment Gateway Webhook in PHP

Md Nazmus Shakib

Md Nazmus Shakib

MERN Stack Developer

18m Total Read
Building a Secure Custom Payment Gateway Webhook in PHP

When operating a SaaS platform, digital agency, or domain registration service, handling automated billing is a critical necessity. While initiating a payment is straightforward, securely confirming that a payment was successfully processed requires a robust background listener—a Webhook.

If a payment webhook is poorly engineered, malicious actors can simulate fake successful payment payloads to your server, granting themselves free domains or premium subscriptions. In this guide, we will engineer a bulletproof PHP payment listener capable of thwarting spoofing, replay attacks, and transaction duplication.

1. The Anatomy of a Webhook & Cryptographic Verification

When a transaction succeeds, the payment provider (e.g., Stripe, Paddle, SSLCommerz) sends an HTTP POST request to your server. To prove that this request genuinely originated from the provider and was not altered in transit, the provider signs the payload using a cryptographic hash (typically HMAC SHA-256) based on a shared secret key.

Your PHP script must capture the raw payload and independently compute this hash. If your computed hash matches the hash provided in the HTTP header, the request is authentic.

webhook_listener.php
// 1. Define your endpoint secret (Stored in environment variables, NEVER hardcoded)
$endpoint_secret = getenv('WEBHOOK_SECRET');

// 2. Retrieve the raw payload. DO NOT use $_POST for JSON payloads.
$payload = file_get_contents('php://input');
$signature_header = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';

// 3. Compute the cryptographic hash
$computed_signature = hash_hmac('sha256', $payload, $endpoint_secret);

// 4. Perform a timing-attack safe string comparison
if (!hash_equals($computed_signature, $signature_header)) {
    http_response_code(401);
    exit(json_encode(['error' => 'Invalid signature detected. Connection terminated.']));
}

// Payload is secure. Safe to decode.
$event = json_decode($payload, true);
Security Warning: Timing Attacks

Never use standard equality operators (== or ===) to compare cryptographic hashes. Standard operators return false the moment a character doesn't match, allowing attackers to measure the response time and guess the signature byte by byte. Always use PHP's native hash_equals().

2. Preventing Replay Attacks & Implementing Idempotency

A replay attack occurs when a valid webhook payload is intercepted and repeatedly sent to your server. If your script blindly credits the user account for every incoming request, a $10 payment could theoretically add $1,000 to their wallet.

To mitigate this, your database must enforce Idempotency. Every transaction must have a unique identifier provided by the gateway (e.g., trx_id). You must ensure this ID is processed exactly once.

$transaction_id = $event['data']['transaction_id'];
$amount_paid = $event['data']['amount'];
$user_id = $event['data']['metadata']['user_id'];

// Initialize PDO (Assume connection is $pdo)
$pdo->beginTransaction();

try {
    // Attempt to insert the transaction ID. 
    // The 'trx_id' column MUST have a UNIQUE constraint in MySQL/PostgreSQL.
    $stmt = $pdo->prepare("INSERT INTO processed_webhooks (trx_id, processed_at) VALUES (?, NOW())");
    $stmt->execute([$transaction_id]);
    
    // If the insertion succeeds, it's a new transaction. Proceed with business logic.
    $updateStmt = $pdo->prepare("UPDATE subscriptions SET status = 'active', expires_at = DATE_ADD(NOW(), INTERVAL 1 YEAR) WHERE user_id = ?");
    $updateStmt->execute([$user_id]);

    $pdo->commit();
    
    // Respond with a 200 OK so the gateway knows it was received
    http_response_code(200);
    echo json_encode(['status' => 'success']);

} catch (PDOException $e) {
    $pdo->rollBack();
    
    // Error Code 23000 indicates a Unique Constraint Violation (Duplicate Entry)
    if ($e->getCode() == '23000') {
        // This is a Replay Attack or a delayed retry. Acknowledge it to stop retries, but do not process.
        http_response_code(200);
        exit(json_encode(['status' => 'ignored_duplicate']));
    }
    
    // For actual server errors, return a 500 so the gateway tries again later
    http_response_code(500);
}

3. Asynchronous Execution for High Scalability

Payment providers typically require your server to respond with a 200 OK within 3 to 5 seconds. If your webhook script triggers heavy processes—such as provisioning a new CPanel account, executing a remote domain registrar API call, or sending PDF invoice emails—your script might time out.

When this happens, the provider assumes the webhook failed and will retry, leading to duplicated provisioning attempts.

The Solution: Your webhook file should only verify the signature, save the raw JSON to a database table or a Message Queue (like Redis or Beanstalkd), and instantly return a 200 OK. A separate background cron job or PHP worker daemon should then pick up the queued payload and execute the heavy API logic asynchronously.

Conclusion

Building a robust billing integration requires a mindset that assumes every incoming HTTP request is inherently hostile until mathematically proven otherwise. By implementing HMAC signature verification, maintaining strict idempotency through unique database constraints, and decoupling heavy provisioning logic into background workers, you can engineer PHP microservices capable of handling millions of dollars in transactions securely and reliably.

#PHP#Webhook#Payment Gateway#Web Security#FinTech#SaaS
Md Nazmus Shakib
Written By

Md Nazmus Shakib

MERN Stack Developer

Specializing in the MERN stack and modern frameworks to engineer secure, SEO-optimized, and scalable digital solutions for visionary brands.

More about the author →