Writing a Complete Docker Compose Development Environment for Genesys Cloud Webhook Testing with ngrok

Writing a Complete Docker Compose Development Environment for Genesys Cloud Webhook Testing with ngrok

What This Guide Covers

This guide constructs a production-grade local development stack for receiving, validating, and processing Genesys Cloud CX webhooks using Docker Compose and ngrok. The final environment provides a persistent tunnel, cryptographic signature verification, structured logging, and idempotent payload handling that mirrors production integration behavior.

Prerequisites, Roles & Licensing

  • Licensing: Genesys Cloud CX Platform (Webhooks are available on all tiers. Advanced retry policies and custom header injection require CX 2 or higher)
  • Permissions: Integration > Webhook > Edit, Integration > Webhook > View
  • OAuth Scopes: webhook:read, webhook:write (required if provisioning via the REST API)
  • External Dependencies: Active Genesys Cloud subdomain, ngrok v3+ account with authtoken, Docker Engine 24+ with Docker Compose v2.20+
  • Runtime: Node.js 18 LTS (or equivalent Python/Go runtime for the receiver service)

The Implementation Deep-Dive

1. Local Webhook Receiver Architecture

The receiver service must acknowledge Genesys Cloud within thirty seconds, verify the cryptographic signature, and offload payload processing to a background queue. Genesys Cloud treats any non-two-hundred response or timeout as a delivery failure and initiates an exponential backoff retry sequence up to seventy-two hours. Blocking the response thread with database writes or external API calls guarantees eventual timeout failures under load.

We implement the receiver using Node.js with Express. The critical architectural decision here is preserving the raw request body for HMAC verification before any middleware deserializes it. Express body parsers modify string representation by stripping whitespace or reordering keys, which breaks the signature calculation. We capture the raw buffer first, verify the signature, then parse the JSON for business logic.

Create server.js:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Configuration from environment
const WEBHOOK_SECRET = process.env.GENESYS_WEBHOOK_SECRET;
const PORT = process.env.PORT || 3000;

// Raw body capture middleware
app.use(express.raw({ type: 'application/json', limit: '10mb' }));

app.post('/webhooks/genesys', (req, res) => {
  const signatureHeader = req.headers['x-genesys-webhook-signature'];
  const rawBody = req.body;

  // The Trap: Skipping signature verification or verifying against req.body instead of raw bytes.
  // Genesys signs the exact byte stream transmitted over the wire. Any JSON reparsing changes the hash.
  if (!signatureHeader || !WEBHOOK_SECRET) {
    return res.status(401).json({ error: 'Missing authentication parameters' });
  }

  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  // Timing-safe comparison prevents timing attacks
  if (!crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expectedSignature))) {
    return res.status(403).json({ error: 'Invalid webhook signature' });
  }

  // Acknowledge immediately to satisfy the 30s timeout contract
  res.status(200).json({ received: true });

  // Async offload to prevent request thread blocking
  setImmediate(() => {
    try {
      const payload = JSON.parse(rawBody.toString('utf8'));
      const webhookId = req.headers['x-genesys-webhook-id'];
      console.log(`[WEBHOOK] ID: ${webhookId} | Event: ${payload.eventType} | Payload:`, payload);
      
      // Production pattern: Push to Redis queue, Kafka topic, or DB insert with idempotency key
      // processPayload(payload, webhookId);
    } catch (parseError) {
      console.error('[WEBHOOK] JSON parse failure:', parseError);
    }
  });
});

app.listen(PORT, () => {
  console.log(`Receiver listening on port ${PORT}`);
});

Create Dockerfile for the receiver:

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]

The architectural reasoning for this pattern is isolation of concerns. The HTTP handler performs only authentication and acknowledgment. Heavy computation moves to setImmediate or a dedicated worker process. This prevents connection pool exhaustion when Genesys Cloud batches events during peak routing hours.

2. ngrok Tunnel Lifecycle & Configuration

ngrok v3 operates as a reverse proxy that establishes an outbound WebSocket connection to the ngrok edge network, then bridges inbound HTTPS traffic to your local container. We containerize ngrok to guarantee consistent environment variables, logging, and restart behavior across developer machines.

Create ngrok.yml in the project root:

version: 3
authtoken: ${NGROK_AUTHTOKEN}
region: us
tunnels:
  genesys-receiver:
    addr: webhook-receiver:3000
    proto: http
    domain: ${NGROK_DOMAIN}
    inspect: true

We reference the Docker service name webhook-receiver directly in the tunnel address. This leverages Docker’s internal DNS resolution, eliminating port mapping conflicts between the host and containers. The inspect: true flag exposes the ngrok web dashboard at http://localhost:4040, which provides real-time request/response inspection, signature headers, and retry metadata.

The Trap: Using dynamic ngrok URLs in Genesys Cloud webhook configuration. The free tier generates a new URL on every restart. Genesys Cloud will immediately mark the webhook as failed and enter the retry queue. The downstream effect is a broken development loop where you must manually update the webhook endpoint in the Genesys UI after every container restart.

The Solution: Provision a static ngrok domain or use the paid tier with persistent authtoken configuration. We inject the authtoken and domain via environment variables to prevent credential leakage into version control.

3. Docker Compose Orchestration

The compose file orchestrates the receiver and tunnel services with strict restart policies, isolated networking, and health checks. We define a custom bridge network to prevent ngrok from accidentally routing traffic to other host services.

Create docker-compose.yml:

version: '3.8'

services:
  webhook-receiver:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: genesys-webhook-receiver
    environment:
      - PORT=3000
      - GENESYS_WEBHOOK_SECRET=${GENESYS_WEBHOOK_SECRET}
    networks:
      - webhook-net
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/"]
      interval: 10s
      timeout: 5s
      retries: 3

  ngrok:
    image: ngrok/ngrok:latest
    container_name: genesys-ngrok-tunnel
    environment:
      - NGROK_AUTHTOKEN=${NGROK_AUTHTOKEN}
      - NGROK_DOMAIN=${NGROK_DOMAIN}
    volumes:
      - ./ngrok.yml:/etc/ngrok/ngrok.yml
    command: config add-authtoken ${NGROK_AUTHTOKEN} && ngrok start --all --config /etc/ngrok/ngrok.yml
    networks:
      - webhook-net
    restart: unless-stopped
    depends_on:
      webhook-receiver:
        condition: service_healthy

networks:
  webhook-net:
    driver: bridge

Create .env.example and copy to .env:

NGROK_AUTHTOKEN=your_ngrok_authtoken_here
NGROK_DOMAIN=your-static-domain.ngrok-free.app
GENESYS_WEBHOOK_SECRET=your_genesys_webhook_secret_here

The Trap: Missing depends_on with health conditions. ngrok will attempt to tunnel to webhook-receiver:3000 before the Node.js server binds to the port. The tunnel establishes successfully at the network level but returns connection refused payloads to Genesys Cloud. This creates intermittent ECONNREFUSED logs that appear to be random network failures.

The Solution: The healthcheck on the receiver service blocks ngrok startup until the HTTP endpoint responds. We use wget in the Alpine container for minimal dependency overhead. The unless-stopped restart policy ensures both services survive host reboots without requiring manual intervention.

4. Genesys Cloud Webhook Registration & Payload Validation

With the local stack running, we register the webhook in Genesys Cloud. We use the REST API to guarantee exact payload structure and audit trail. The webhook must target the ngrok public URL, not the local Docker network address.

Retrieve your ngrok public URL from the dashboard or logs:

docker logs genesys-ngrok-tunnel | grep "Forwarding"
# Output example: Forwarding    https://your-static-domain.ngrok-free.app -> http://webhook-receiver:3000

Register the webhook via API:

POST https://YOUR_SUBDOMAIN.mypurecloud.com/api/v2/integrations/webhooks
Content-Type: application/json
Authorization: Bearer <ACCESS_TOKEN>

JSON Payload:

{
  "name": "Dev Webhook Receiver - ngrok",
  "url": "https://your-static-domain.ngrok-free.app/webhooks/genesys",
  "secret": "your_genesys_webhook_secret_here",
  "events": [
    "routing:conversation:updated",
    "routing:queue:member:added",
    "routing:queue:member:removed"
  ],
  "enabled": true,
  "retryPolicy": {
    "type": "exponential",
    "maxRetries": 5,
    "initialDelay": 1000,
    "maxDelay": 60000
  },
  "headers": {
    "X-Integration-Source": "dev-docker-stack"
  }
}

The Trap: Overloading the events array with high-frequency telemetry events during development. Events like routing:conversation:updated fire on every state change (answered, transferred, muted, recording started). Under load, this generates hundreds of payloads per second. Your local Node.js process will exhaust the event loop, ngrok will throttle the tunnel, and Genesys will mark the endpoint as unhealthy.

The Solution: Scope events strictly to the workflow you are debugging. Use the X-Integration-Source header to filter payloads in your receiver if you must enable broad events. Implement a rate limiter in the receiver middleware that logs a sample of payloads rather than processing every single event. The retry policy configuration above caps retries at five attempts with a sixty-second maximum delay, preventing your local stack from being hammered by Genesys backoff logic when you intentionally stop the container.

Validation, Edge Cases & Troubleshooting

Edge Case 1: Raw Body Serialization Drift

The Failure Condition: Signature verification fails consistently despite matching secrets and correct HMAC algorithm. The logs show Invalid webhook signature on every request.
The Root Cause: Express body parsers or upstream proxies modify the payload before verification. Genesys Cloud signs the exact byte stream transmitted over HTTPS. If your middleware parses JSON, strips null bytes, or reorders object keys, the resulting string no longer matches the original payload. The HMAC digest diverges immediately.
The Solution: Use express.raw() to capture the buffer before any parsing middleware runs. Verify the signature against req.body (which is now a Buffer). Convert to string only after successful verification. Disable any global express.json() middleware that intercepts the route. This pattern guarantees cryptographic parity between the Genesys edge and your local receiver.

Edge Case 2: Tunnel Rebinding & Endpoint Drift

The Failure Condition: Webhooks deliver successfully for several hours, then suddenly fail with 404 Not Found or Connection Reset. The ngrok dashboard shows tunnel reconnects.
The Root Cause: ngrok tunnels drop due to idle timeout, network interface changes, or Docker daemon restarts. When the tunnel reconnects, the underlying WebSocket session rebinds to a new edge IP. If you are using a dynamic ngrok URL, the public endpoint changes. Genesys Cloud retains the old URL in its webhook configuration. Even with a static domain, transient DNS propagation or edge routing table updates can cause brief delivery gaps.
The Solution: Enforce static ngrok domains for development environments. Configure Docker Compose with restart: unless-stopped and health checks to auto-recover tunnel drops. Implement a webhook health endpoint (GET /webhooks/health) that returns 200 OK. Use a synthetic monitoring tool to ping the ngrok URL every sixty seconds. If the tunnel drops, the health check fails, triggering an immediate Docker restart before Genesys Cloud exhausts its retry queue.

Edge Case 3: At-Least-Once Delivery & Idempotency Failure

The Failure Condition: Duplicate business logic executes. Database records multiply. State machines transition incorrectly. Logs show identical X-Genesys-Webhook-Id values processing multiple times.
The Root Cause: Genesys Cloud guarantees at-least-once delivery. If the receiver returns a non-two-hundred status, times out, or crashes after acknowledging but before completing processing, Genesys retries the exact same payload. The X-Genesys-Webhook-Id header remains constant across retries. Without idempotency checks, your system treats retries as new events.
The Solution: Extract req.headers['x-genesys-webhook-id'] immediately upon receipt. Check a persistent store (Redis SET with TTL, PostgreSQL unique constraint, or DynamoDB conditional put) for the ID. If the ID exists, return 200 OK and skip processing. If the ID is new, process the payload, store the ID, then return 200 OK. This pattern ensures exactly-once semantics despite at-least-once transport. Never rely on Genesys Cloud to deduplicate. The platform explicitly documents that retries are normal behavior under network instability.

Official References