DEV Community

Risky Egbuna
Risky Egbuna

Posted on

Scaling CRM API Integrations: Background Job Queue Design

Resolving CRM Sync Crashing: How We Built a Stable Webhook Worker


It was a rainy Thursday morning when my dashboard alerts started screaming.

A client of mine runs a support center with about eighty active agents. They handle thousands of customer queries every day. Their operations rely heavily on their CRM system to track tickets, customer profiles, and service resolution times.

That morning, they launched a massive promotional campaign. Within twenty minutes, a wave of customer inquiries hit their support lines. Their third-party messaging apps started flooding their CRM with webhook requests.

Then, the server gave up.

The CPU load spiked to 98%. The CRM interface became completely unresponsive. Agents couldn't load customer files, and tickets were getting lost in transit. The webhook requests were timing out, which caused the third-party messaging APIs to retry sending the same data. This doubled the traffic and created a loop of doom.

I spent the next eight hours digging through server logs, analyzing PHP execution trees, and restructuring their backend pipeline.

For more than ten years, I have worked as a systems architect, back-end developer, and SEO engineer. I have built custom WordPress tools, HTML5 game systems, and complex CRM setups. I have seen many developers make the same critical mistake: processing external API data synchronously inside the web request pool.

Today, I am going to walk you through a detailed, real-world case study. We will look at why synchronous webhooks kill CRM systems, how to build a highly stable Redis-backed queue in PHP, and how to configure a system background worker that can handle millions of customer service updates without breaking a sweat.


Why Synchronous API Handlers Kill Your CRM

Most developers write webhook handlers like this:

  1. Your server receives a POST request from an external API (like a customer support tool or payment gateway).
  2. Your PHP script reads the incoming payload.
  3. Your PHP script opens a connection to your database, updates a record, and maybe makes another cURL call to send an email confirmation.
  4. Your script finally returns a 200 OK status to the sender.

On paper, this is simple and easy to read. But in production, it is a ticking time bomb.

An external cURL request can take anywhere from 500 milliseconds to 5 seconds to complete, depending on network latency. While your PHP script is waiting for that external API to respond, it is holding a database connection open and keeping a PHP-FPM worker process busy.

If you get 200 webhook requests in a single minute, you will quickly run out of available PHP-FPM workers. Your web server will start throwing 502 Bad Gateway or 504 Gateway Timeout errors.

To make your CRM resilient, you have to break this chain. You must transition your customer service integrations from a synchronous model to an asynchronous queue model.


Step 1: The Fast Ingest Pattern (Preventing Timeout Loops)

Our first priority was to make sure the CRM could receive incoming webhooks instantly without waiting for any database or API processing. We needed to accept the payload, save it to a temporary memory storage, and return a fast 200 OK back to the sender in under 10 milliseconds.

We used Redis for this temporary memory storage. Redis is an in-memory data store that can handle tens of thousands of write operations per second with minimal CPU usage.

Here is the lightweight PHP endpoint I wrote to replace their broken, slow webhook receiver file:

<?php
// Fast Ingest Webhook Receiver (webhook_ingest.php)
header('Content-Type: application/json');

// Only allow POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => 'Method not allowed']);
    exit;
}

// Read the raw webhook payload
$payload = file_get_contents('php://input');

if (empty($payload)) {
    http_response_code(400);
    echo json_encode(['error' => 'Empty payload']);
    exit;
}

try {
    // Connect to local Redis instance
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);

    // Create a structured job payload
    $jobData = [
        'id' => uniqid('webhook_', true),
        'timestamp' => time(),
        'data' => json_decode($payload, true)
    ];

    // Push the job to the tail of our Redis queue list (FIFO - First In, First Out)
    $redis->rPush('customer_service_queue', json_encode($jobData));

    // Return a rapid 200 OK response to the API provider
    http_response_code(200);
    echo json_encode(['status' => 'queued', 'job_id' => $jobData['id']]);

} catch (Exception $e) {
    // If Redis is down, log it and write to a backup file to prevent data loss
    error_log('Redis failure in webhook ingest: ' . $e->getMessage());
    file_put_contents('/var/log/webhook_backup.log', $payload . PHP_EOL, FILE_APPEND);

    // Still return a success code to prevent the API provider from retrying and flooding us
    http_response_code(202);
    echo json_encode(['status' => 'backup_logged']);
}
Enter fullscreen mode Exit fullscreen mode

Why This Works

This file does zero database writing, zero cURL connections, and zero complex business logic calculations. It simply receives the string, drops it into Redis, and closes the connection.

Now, even if 10,000 customers send messages at the exact same moment, our web server will handle the incoming traffic smoothly without running out of PHP processes.


Step 2: Designing the PHP CLI Queue Worker

With our webhook payloads safely stored in Redis, we needed a separate background process to do the actual work. This background process runs via the Command Line Interface (CLI) of PHP.

Running scripts via PHP CLI is highly efficient because it does not have the overhead of web servers like Nginx or Apache. It can run continuously in the background, processing jobs one by one.

To handle external integrations within our worker, we rely on cURL to transmit updates. If you are customizing your own background handlers, you can study the PHP cURL documentation to configure optimal connection timeouts.

Here is the production-ready PHP CLI worker script we deployed. It pops jobs off the Redis list, decodes them, and processes the business logic safely:

<?php
// High-Performance Background Worker (worker.php)
if (php_sapi_name() !== 'cli') {
    die("This script must be run via the command line.\n");
}

// Define paths to your CRM bootstrapper to access database models
// For this example, we assume a standard modular MVC layout
define('BASEPATH', dirname(__FILE__) . '/');
require_once BASEPATH . 'crm_bootstrap.php';

echo "=== Background CRM Worker Started: " . date('Y-m-d H:i:s') . " ===\n";

try {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    // Set a timeout of 0 to block indefinitely until a new job arrives
    $redis->setOption(Redis::OPT_READ_TIMEOUT, -1);
} catch (Exception $e) {
    die("Fatal: Could not connect to Redis: " . $e->getMessage() . "\n");
}

$running = true;

// Handle system signals to shut down the worker cleanly without interrupting active jobs
if (function_exists('pcntl_signal')) {
    declare(ticks = 1);
    pcntl_signal(SIGINT, function() use (&$running) {
        echo "Shutting down worker gracefully...\n";
        $running = false;
    });
}

while ($running) {
    try {
        // blPop stands for 'blocking Left Pop'. 
        // It pauses execution here until a job is added to the list. 
        // This keeps CPU usage at 0% when the queue is empty.
        $job = $redis->blPop('customer_service_queue', 10);

        if (!$job) {
            // Keep-alive check
            continue;
        }

        // blPop returns an array: [0 => queue_name, 1 => job_payload]
        $payload = json_decode($job[1], true);

        if ($payload) {
            processJob($payload, $dbConnection);
        }

    } catch (Exception $e) {
        echo "Error during job execution: " . $e->getMessage() . "\n";
        sleep(2); // Prevent rapid looping on persistent errors
    }

    // Memory safety check: if our worker is leaking memory, exit.
    // Supervisor will automatically restart a fresh copy.
    if (memory_get_usage() > 64 * 1024 * 1024) { // 64MB limit
        echo "Memory limit reached. Exiting for recycling.\n";
        $running = false;
    }
}

function processJob($job, $db) {
    $jobId = $job['id'];
    $data = $job['data'];

    echo "Processing Job ID: {$jobId} - ";

    // Simulate updating customer profile and assigning a customer support ticket
    $customerEmail = filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL);
    $ticketMessage = htmlspecialchars($data['message'] ?? '', ENT_QUOTES, 'UTF-8');

    if (!$customerEmail || !$ticketMessage) {
        echo "Invalid payload data. Skipping.\n";
        return;
    }

    // Wrap your database updates in a transaction to guarantee data integrity
    $db->beginTransaction();
    try {
        // 1. Find or create the customer record
        $stmt = $db->prepare("SELECT id FROM customers WHERE email = ? FOR UPDATE");
        $stmt->execute([$customerEmail]);
        $customer = $stmt->fetch();

        if ($customer) {
            $customerId = $customer['id'];
        } else {
            $insert = $db->prepare("INSERT INTO customers (email, created_at) VALUES (?, NOW())");
            $insert->execute([$customerEmail]);
            $customerId = $db->lastInsertId();
        }

        // 2. Insert the customer service ticket
        $ticketStmt = $db->prepare("INSERT INTO support_tickets (customer_id, message, status, created_at) VALUES (?, ?, 'open', NOW())");
        $ticketStmt->execute([$customerId, $ticketMessage]);

        $db->commit();
        echo "SUCCESS: Ticket created for customer ID: {$customerId}\n";

    } catch (Exception $e) {
        $db->rollBack();
        echo "DATABASE ERROR: " . $e->getMessage() . "\n";
        // Here you could re-queue the job to try again later if needed
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Keeping the Worker Alive with Supervisor

Our background script (worker.php) is designed to run forever in your server terminal. But what happens if the server restarts, your database connection blips, or the PHP script runs out of memory and crashes?

You cannot manually log into your server every time a script dies to restart it. You need a process monitor to watch your worker and automatically restart it if it stops.

The industry standard tool for this is Supervisor. Supervisor is a client/server system that allows users to monitor and control processes on Linux operating systems.

Here is how I configured Supervisor on my client's Ubuntu server:

1. Install Supervisor

sudo apt-get update
sudo apt-get install supervisor -y
Enter fullscreen mode Exit fullscreen mode

2. Create the Configuration File

Create a new configuration file in Supervisor's program directory:

sudo nano /etc/supervisor/conf.d/crm-worker.conf
Enter fullscreen mode Exit fullscreen mode

3. Add the Configuration Block

Paste this configuration inside the file. It tells Supervisor to run our PHP worker script, keep three separate worker threads running at the same time to process jobs in parallel, and automatically restart them if they crash:

[program:crm-worker]
; Command to execute our worker script
command=php /var/www/html/scripts/worker.php

; Number of parallel workers to run
numprocs=3
process_name=%(program_name)s_%(process_num)02d

; Working directory
directory=/var/www/html/scripts/

; Automatically start the workers on system boot
autostart=true

; Automatically restart the process if it exits or crashes
autorestart=true

; Run the process as a secure system user (never run as root!)
user=www-data

; Path to save output logs (useful for debugging)
stdout_logfile=/var/log/supervisor/crm-worker-out.log
stderr_logfile=/var/log/supervisor/crm-worker-err.log

; Limit log size to prevent filling up the disk
stdout_logfile_maxbytes=10MB
stderr_logfile_maxbytes=10MB
Enter fullscreen mode Exit fullscreen mode

4. Load the New Configuration

Save the file and run these commands to tell Supervisor to scan for changes and start our background workers:

sudo supervisorctl reread
sudo supervisorctl update
Enter fullscreen mode Exit fullscreen mode

You can verify that your processes are running smoothly by typing:

sudo supervisorctl status
Enter fullscreen mode Exit fullscreen mode

You should see output similar to this:

crm-worker:crm-worker_00   RUNNING   pid 18452, uptime 2:14:12
crm-worker:crm-worker_01   RUNNING   pid 18453, uptime 2:14:12
crm-worker:crm-worker_02   RUNNING   pid 18454, uptime 2:14:12
Enter fullscreen mode Exit fullscreen mode

Now, even if a worker crashes because of a database timeout, Supervisor will instantly spin up a fresh copy of the script. Our customer service queue remains completely bulletproof.


Step 4: Adding API Rate-Limitation to Your Queue Workers

When building dynamic CRM connections, you must remember that external APIs (like ticketing software or chat platforms) have strict rate limits. If your background workers process webhooks too fast, you might get temporarily blocked by those external servers.

To prevent this, you can implement a simple Token Bucket Algorithm inside your background worker script. This limits how many external API calls your workers can make per minute.

Here is a lightweight implementation of a rate-limiter that we added to our worker script:

<?php
// Rate-Limiter Helper Class for PHP Workers
class APIRateLimiter {
    private $redis;
    private $rateLimit; // Max requests
    private $timeWindow; // Time window in seconds

    public function __construct($redisInstance, $limit = 60, $window = 60) {
        $this->redis = $redisInstance;
        $this->rateLimit = $limit;
        $this->timeWindow = $window;
    }

    public function throttle($apiKey) {
        $key = "rate_limit:" . md5($apiKey);
        $currentTime = time();

        // Remove old timestamps outside of our sliding window
        $this->redis->zRemRangeByScore($key, 0, $currentTime - $this->timeWindow);

        // Count how many requests were made within our active window
        $requestCount = $this->redis->zCard($key);

        if ($requestCount >= $this->rateLimit) {
            // Rate limit exceeded. Calculate sleep duration based on the oldest request
            $oldestRequest = $this->redis->zRange($key, 0, 0, true);
            if (!empty($oldestRequest)) {
                $oldestTimestamp = array_values($oldestRequest)[0];
                $sleepTime = max(1, ($oldestTimestamp + $this->timeWindow) - $currentTime);

                echo " [RATE LIMIT EXCEEDED] Pausing execution for {$sleepTime} seconds...\n";
                sleep($sleepTime);
            } else {
                sleep(1);
            }
        }

        // Record the current execution timestamp
        $this->redis->zAdd($key, $currentTime, $currentTime);
        // Set an expiration on the key so it cleans up if the queue stops
        $this->redis->expire($key, $this->timeWindow * 2);
    }
}
Enter fullscreen mode Exit fullscreen mode

By calling $rateLimiter->throttle('api_key_here'); right before making an external cURL call, your background processes will automatically slow down if they approach the rate limit.


The Business Cost of Unstable Support Infrastructures

After we successfully deployed the Redis queue and configured the Supervisor worker system, I sat down with the client's operations manager to analyze their setup.

"We got it running beautifully," I told him. "But look at what we had to do. We spent days writing custom Redis connectors, rate-limiters, CLI scripts, and Supervisor rules just to keep your custom CRM from crashing when a promo campaign launches."

This is the hidden cost of trying to build complex CRM systems entirely from scratch.

Many growing businesses think they need a completely custom solution for their sales, customer relations, and support tracking. But building a secure, stable, and scalable support backend is incredibly difficult. You have to account for database locking, ticket routing logic, automated auto-responders, multi-channel support sync, and API stability.

If you are using a popular, robust business platform like Perfex CRM, it is almost always safer, cheaper, and faster to use pre-built, production-tested modules that have already solved these scaling issues.

For example, instead of writing your own support workflows, automated ticket queues, and channel synchronizers from scratch, you can look at the Customer Service Management module for Perfex CRM.

By deploying a professional, ready-made module, you get access to clean database structures, optimized message pipelines, and proven agent routing workflows. This allows you to launch enterprise-grade customer support portals immediately, without having to manage custom process loops or risking server crashes under high volume.

And for development agencies that build custom portals for business clients, you don't need to waste weeks writing boilerplate ticket trackers and pipeline managers. You can search for tested integrations via a professional PHP Scripts download directory.

Using reliable template directories like GPLPAL allows you to access secure, modular add-ons that fit perfectly into your clients' business tools. This saves you hundreds of coding hours and lets you deliver highly stable systems to your clients without the risk of system crashes.


Step 5: Setting Up Cron Jobs to Clean Up Old Webhook Logs

Even with an optimized queue, your system will build up a massive history of processed jobs over time. If you do not clean out old processed job logs from your database, your tables will grow bloated, which slows down search queries.

To keep our database light and fast, I wrote a simple database cleanup script and scheduled it to run as a nightly cron job on our client's server.

Create a file called /etc/cron.daily/crm-cleanup:

#!/bin/bash
# Clean up processed support ticket logs older than 30 days
DB_USER="crm_db_user"
DB_PASS="YourSecurePassword"
DB_NAME="crm_database"

mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "DELETE FROM webhook_logs WHERE processed_at < DATE_SUB(NOW(), INTERVAL 30 DAY);"

echo "CRM daily cleanup executed successfully: $(date)" >> /var/log/crm_cleanup.log
Enter fullscreen mode Exit fullscreen mode

Make the script executable:

sudo chmod +x /etc/cron.daily/crm-cleanup
Enter fullscreen mode Exit fullscreen mode

This simple daily cron job keeps your CRM tables defragmented and ensures that your agent dashboards load in milliseconds, even after years of active customer service operations.


Key Takeaways for Resilient CRM Integrations

If you are running, designing, or optimizing a business CRM, make sure to follow these system-design principles:

  • Never Process Webhooks Synchronously: Receive payloads rapidly, drop them in memory (like Redis), and return a 200 OK instantly to keep your PHP pool free.
  • Use Background CLI Workers: Process heavy database queries and external cURL operations inside background terminal scripts that do not block web traffic.
  • Monitor Processes with Supervisor: Ensure your background worker threads automatically restart on crashes or system reboots.
  • Configure Rate Limiting: Never assume external servers will accept infinite requests. Build rate-limiters inside your workers to avoid getting blocked.
  • Utilize Proven Business Modules: Avoid building complex customer support flows from scratch. Lean on reliable, pre-made modules to guarantee security and system speed.

Building a stable customer relationship system doesn't require a massive infrastructure budget. It just requires a smart separation of concernsβ€”keeping your web interface light, offloading heavy processing to memory queues, and monitoring your background workers. Take care of your backend pipeline, and your business will scale smoothly without interruptions.

Top comments (0)