Secure Payment Gateways: Webhook Queue & API Integration Guide
Making Payment Webhooks Bulletproof: A Real PHP Integration Case
A few months ago, a logistics client called me in a panic. They were using a CRM to manage heavy shipping contracts. They had just launched a new mobile payment option for their clients in East Africa.
At first, everything seemed fine. But within three days, they realized they had a major problem.
They were losing money. Some customers were getting their shipping orders approved without actually paying. Other customers paid their bills, but their accounts still showed "unpaid" in the CRM. The customer support lines were completely flooded with angry calls.
I spent a weekend looking into their setup. The problem was not the payment provider. The problem was how their server handled webhooks.
A webhook is like a text message that a payment processor sends to your server. It says: "Hey, user 123 just paid $50. Update their account."
The client’s server was trying to process these incoming webhook messages instantly. If the CRM database was busy, or if too many customers paid at the exact same moment, the server would drop the request. The payment processor thought the server was offline, and the transaction details simply disappeared.
As a developer who has spent more than ten years building custom integrations, I knew we had to rebuild this payment pipeline from scratch. We needed to stop processing transactions live and start queuing them safely.
This is the real, step-by-step guide of how I fixed their broken webhook pipeline, secured their payment endpoints, and automated their transaction audits.
Why Standard Webhook Handlers Fail Under Pressure
Most basic integrations handle webhooks by running code immediately when the payment gateway hits their server.
Here is what a bad, typical workflow looks like:
- The payment gateway sends a POST request with the payment data.
- The server receives the POST request.
- The server runs a long script: it validates the data, queries the database, updates the invoice, sends an email to the client, and sends a Slack notification to the team.
- Finally, after 5 or 10 seconds, the server sends a
200 OKresponse back to the payment gateway.
This is a terrible design. If twenty customers pay at the same time, your server will try to run twenty of these heavy scripts at once. The database will lock up, the PHP processes will hit their memory limits, and the server will return a 500 Internal Server Error.
When the payment gateway receives an error, it thinks the transmission failed. It might try to send the webhook again later, leading to double-processing or duplicated invoices. Or it might just give up, and you will never know the customer paid.
To prevent this, we must build an asynchronous queue.
When a webhook arrives, our server should do only two things: save the raw payload to a fast database table, and immediately return a 200 OK response to the payment gateway.
Then, a separate background script runs on a timer to process those saved payloads one by one. If one process fails, it does not crash the server, and we can easily retry it later.
Step 1: Receiving and Securing the Webhook Payload
Before we queue anything, we have to make sure the incoming request is actually from our payment gateway and not a hacker trying to fake a payment.
Most reliable payment portals, like the ones you access through the Safaricom Developer Portal, use security tokens, API keys, or signature headers to verify requests [Safaricom Developer Portal].
Here is the secure PHP script I wrote to receive, verify, and store incoming webhooks. It reads the raw input, checks a secret token, and writes the raw JSON payload to a simple MySQL database table.
"error", "message" => "Invalid secret token."]);
exit();
}
// 3. Read the raw body input
$raw_payload = file_get_contents('php://input');
if (empty($raw_payload)) {
http_response_code(400); // Bad Request
echo json_encode(["status" => "error", "message" => "Empty body."]);
exit();
}
// 4. Validate that the body is actual JSON
$parsed_json = json_decode($raw_payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
echo json_encode(["status" => "error", "message" => "Invalid JSON format."]);
exit();
}
// 5. Connect to the database and save the raw payload
try {
$pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// Insert payload into our staging/queue table
$stmt = $pdo->prepare("
INSERT INTO webhook_queue (raw_data, status, created_at)
VALUES (:raw_data, 'pending', NOW())
");
$stmt->execute([
':raw_data' => $raw_payload
]);
// 6. Tell the payment gateway we received it successfully
http_response_code(200); // OK
echo json_encode(["status" => "success", "message" => "Payload queued."]);
} catch (PDOException $e) {
// Log the database error internally. Do not show details to the public.
error_log("Webhook database save error: " . $e->getMessage());
http_response_code(500); // Internal Server Error
echo json_encode(["status" => "error", "message" => "Database failure."]);
}
This receiver script is incredibly fast. It does no heavy processing, runs no complex updates, and sends no emails. It simply takes the data, writes it to a table, and says "thank you" to the gateway in under 20 milliseconds.
Step 2: Creating the Database Table for Webhooks
To store the payloads, we need a simple queue table in our database.
This table needs to keep track of when the webhook arrived, what the raw data was, how many times we have tried to process it, and whether it succeeded or failed.
Here is the SQL command I ran to create this table:
CREATE TABLE webhook_queue (
id INT AUTO_INCREMENT PRIMARY KEY,
raw_data LONGTEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'failed'
attempts INT NOT NULL DEFAULT 0,
error_log TEXT DEFAULT NULL,
created_at DATETIME NOT NULL,
processed_at DATETIME DEFAULT NULL,
INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Why we use indexes here: By adding an index to both the status and created_at columns, our background processor can quickly find pending webhooks without having to scan old, completed transactions. This keeps our database fast even after we have stored millions of payments.
Step 3: Building the Background Processor Script (The Worker)
Next, we need a background script that will read the database table and process the pending webhooks.
To prevent two processes from picking up the exact same webhook at the same time, we use a database locking strategy called SELECT ... FOR UPDATE.
Here is the CLI script I wrote. It processes one pending webhook at a time, checks the transaction status, and safely updates the customer's invoice status in the CRM database.
PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
// Start a database transaction to lock the row we are processing
$pdo->beginTransaction();
// Find the oldest pending webhook and lock it
$stmt = $pdo->prepare("
SELECT id, raw_data, attempts
FROM webhook_queue
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE
");
$stmt->execute();
$webhook = $stmt->fetch();
if (!$webhook) {
$pdo->commit();
echo "No pending webhooks found.\n";
exit();
}
$webhook_id = $webhook['id'];
$raw_data = $webhook['raw_data'];
$attempts = $webhook['attempts'] + 1;
// Set status to 'processing' so no other thread touches it
$update_stmt = $pdo->prepare("
UPDATE webhook_queue
SET status = 'processing', attempts = :attempts
WHERE id = :id
");
$update_stmt->execute([':attempts' => $attempts, ':id' => $webhook_id]);
$pdo->commit();
// Now, let's process the raw data
$data = json_decode($raw_data, true);
// Process the payment safely
$payment_successful = process_crm_payment($pdo, $data);
if ($payment_successful) {
// Complete the transaction
$complete_stmt = $pdo->prepare("
UPDATE webhook_queue
SET status = 'completed', processed_at = NOW()
WHERE id = :id
");
$complete_stmt->execute([':id' => $webhook_id]);
echo "Webhook ID {$webhook_id} processed successfully.\n";
} else {
handle_failed_webhook($pdo, $webhook_id, $attempts, "Payment processing returned false.");
}
} catch (Exception $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
error_log("Worker crash: " . $e->getMessage());
echo "Worker error: " . $e->getMessage() . "\n";
}
// Function to simulate updating CRM database
function process_crm_payment($pdo, $data) {
// Look for unique payment identifiers
$transaction_reference = isset($data['TransID']) ? $data['TransID'] : '';
$amount = isset($data['TransAmount']) ? (float)$data['TransAmount'] : 0.0;
$bill_number = isset($data['BillRefNumber']) ? $data['BillRefNumber'] : '';
if (empty($transaction_reference) || empty($bill_number)) {
return false;
}
// Update the invoice in the CRM
$invoice_stmt = $pdo->prepare("
UPDATE crm_invoices
SET status = 'paid', payment_method = 'mobile_money', transaction_id = :trans_id, amount_paid = :amount, paid_at = NOW()
WHERE invoice_number = :invoice_num AND status != 'paid'
");
$invoice_stmt->execute([
':trans_id' => $transaction_reference,
':amount' => $amount,
':invoice_num' => $bill_number
]);
// Check if any row was actually updated
if ($invoice_stmt->rowCount() > 0) {
return true;
} else {
// Invoice might already be paid or does not exist
return false;
}
}
// Function to handle retries and error logging
function handle_failed_webhook($pdo, $id, $attempts, $error_msg) {
$status = ($attempts >= MAX_ATTEMPTS) ? 'failed' : 'pending';
$stmt = $pdo->prepare("
UPDATE webhook_queue
SET status = :status, error_log = :error_log
WHERE id = :id
");
$stmt->execute([
':status' => $status,
':error_log' => $error_msg,
':id' => $id
]);
echo "Webhook ID {$id} marked as {$status}. Reason: {$error_msg}\n";
}
Step 4: Automating the Queue with Linux Cron Jobs
To ensure this background worker runs continuously and automatically, we must set up a cron job on our Linux server.
Instead of letting the worker process run for hours (which can lead to memory leaks in PHP), we will write a simple cron script that launches the worker every minute.
Open your server terminal, type crontab -e, and add this line to run the script every single minute of the day:
* * * /usr/bin/php /var/www/html/scripts/webhook_worker.php >> /var/log/webhook_worker.log 2>&1
Why this is safe: If the queue is empty, the worker script will check the database, find zero pending items, print "No pending webhooks found," and close in under 5 milliseconds. It will not use any measurable server memory or CPU power.
Real-world CRM Integrations
Writing custom API queues from scratch is a fantastic way to understand the underlying mechanics of modern systems. However, if you are running a busy business, writing custom PHP code for every individual payment gateway is not always the best use of your time.
If you are using a popular tool like Perfex CRM to manage your billing, clients, and projects, you should avoid custom workarounds if pre-tested modules already exist.
For instance, if your business operates in areas where M-Pesa is a primary payment option, using a specialized module like the Mpesa Gateway - Perfex CRM module can save you weeks of debugging.
This specific plugin handles payment updates, secures transaction logs, and automatically processes invoices inside your CRM dashboard without requiring you to write custom cURL configurations or database queries.
When selecting payment modules for any CRM, make sure they support the following:
- API Response Logging: The plugin must save all communication between your CRM and the gateway inside a clear log file.
- Idempotency Checks: It must ensure that if a customer double-clicks a payment button, the system only charges them once.
- Sandbox & Live Modes: You need to be able to run test transactions using dummy money before putting the system live for real customers.
How to Safely Audit Third-Party Code
When looking for new payment modules or tools online, you might find options by searching for a PHP Scripts download or visiting alternative script resources.
While pre-made scripts can save you time, you must be careful. Payment code manages your business's money and your clients' private data. Downloading scripts from random, untrusted websites can expose you to major risks.
I always recommend sourcing your themes, extensions, and payment modules from safe, reputable platforms like GPLPAL. They offer verified, clean versions of developer scripts that do not contain hidden tracking tools, backdoors, or malicious modifications.
Before installing any third-party script on your live production server, follow this security audit checklist:
- Search for Obfuscated Code: Open the script files in a text editor and search for terms like
eval(base64_decode(...)). Safe code is always written in plain, human-readable text. If a script hides its code, do not install it. - Inspect Database Inputs: Look at how the script handles database updates. Ensure it uses prepared statements (
prepareandexecutein PDO) rather than dropping raw user inputs directly into SQL strings. This protects your database from SQL injection attacks. - Review Outbound Connections: Check if the script is silently communicating with foreign domains. A payment script should only talk to your database and the official payment gateway API.
- Validate File Permissions: Ensure the script files on your server are owned by your server user and set to standard permission levels (
644for files,755for directories). Never give payment directories777permissions, as this allows anyone to modify your billing files.
Monitoring API Health via the Command Line
To keep your payment systems running smoothly, you should set up automatic checks. This lets you know if your connection to the payment gateway is failing before a customer attempts to pay and gets an error.
I wrote this simple PHP CLI tool that sends a quick request to the payment gateway's API ping endpoint. If the connection fails or takes more than three seconds, it sends a high-priority alert to the system administrator.
```php
评论 0