The failure is always the same shape. The visitor presses submit, PHP makes one wp_remote_post() call, the CRM answers 502 or the connection hangs until the request is killed, and the lead exists nowhere. The visitor saw a thank-you message. Nobody follows up.

This is the version I would ship: write the lead into a queue table inside WordPress first, then let a worker deliver it with backoff, an idempotency key, and a dead-letter state that a human is forced to look at. Everything below is one must-use plugin.

Where leads get lost

  1. The outbound request blocks the visitor. The default timeout is generous, the form looks frozen, and people press submit again.
  2. A retryable error is treated as success. 429, 500, 502, 503 and connection timeouts are the normal case, not the exception.
  3. A permanent error is invisible. 400, 401, 403, 422 never succeed on retry, and if the only copy of the lead was in a PHP variable, it is gone.
  4. Retries create duplicates. Sales gets two rows for one person, stops trusting the import, and goes back to reading email.
  5. wp-cron never fires. No traffic at 3am means no worker at 3am.
  6. Two workers overlap and both pick up the same row.

Shape of the fix

visitor -> POST /wp-json/leadrelay/v1/intake validate, honeypot, throttle v wp_leadrelay_queue status=pending, attempts=0 v POST crm Idempotency-Key: <lead_key> | +---------+-----------+ | | | 2xx retryable permanent 4xx or attempts exhausted | | | sent pending+backoff dead + alert 

The rule that matters: the lead is stored before any network call happens. Everything else is recovery.

Step 1 - the queue table

Create wp-content/mu-plugins/lead-relay.php and keep adding to it as you read.

<?php /** * Plugin Name: Lead Relay * Description: Durable webhook delivery from WordPress forms to a CRM. */ if ( ! defined( 'ABSPATH' ) ) { exit; } const LEADRELAY_CRON_HOOK = 'leadrelay_deliver'; const LEADRELAY_MAX_ATTEMPTS = 8; function leadrelay_table() { global $wpdb; return $wpdb->prefix . 'leadrelay_queue'; } function leadrelay_install() { global $wpdb; $table = leadrelay_table(); $collate = $wpdb->get_charset_collate(); $sql = 'CREATE TABLE ' . $table . ' ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, lead_key CHAR(36) NOT NULL, payload LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT \'pending\', attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, next_attempt_at DATETIME NOT NULL, last_code SMALLINT NULL, last_error TEXT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY lead_key (lead_key), KEY status_next (status, next_attempt_at) ) ' . $collate . ';'; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); } add_action( 'init', function () { if ( ! get_option( 'leadrelay_db_ready' ) ) { leadrelay_install(); update_option( 'leadrelay_db_ready', 1, false ); } } ); 

dbDelta is picky about formatting: two spaces after PRIMARY KEY, one column per line, KEY names spelled out. Keep the shape above.

The UNIQUE KEY on lead_key is the last line of defence against a double insert.

Step 2 - the intake endpoint

add_action( 'rest_api_init', function () { register_rest_route( 'leadrelay/v1', '/intake', [ 'methods' => 'POST', 'callback' => 'leadrelay_handle_intake', 'permission_callback' => '__return_true', ] ); } ); function leadrelay_client_ip(): string { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0'; return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '0.0.0.0'; } function leadrelay_handle_intake( WP_REST_Request $request ) { $params = $request->get_json_params(); if ( ! is_array( $params ) ) { return new WP_Error( 'bad_payload', 'Expected a JSON body', [ 'status' => 400 ] ); } // Honeypot: real people never fill a field they cannot see. if ( ! empty( $params['company_website'] ) ) { return new WP_REST_Response( [ 'ok' => true ], 202 ); // pretend success } foreach ( [ 'name', 'email', 'message' ] as $field ) { if ( empty( $params[ $field ] ) ) { return new WP_Error( 'missing_field', 'Missing ' . $field, [ 'status' => 422 ] ); } } if ( ! is_email( $params['email'] ) ) { return new WP_Error( 'bad_email', 'Invalid email', [ 'status' => 422 ] ); } // Throttle: 5 submissions per IP per 10 minutes. $throttle_key = 'leadrelay_rl_' . md5( leadrelay_client_ip() ); $hits = (int) get_transient( $throttle_key ); if ( $hits >= 5 ) { return new WP_Error( 'rate_limited', 'Too many submissions', [ 'status' => 429 ] ); } set_transient( $throttle_key, $hits + 1, 10 * MINUTE_IN_SECONDS ); // Persist before anything goes over the network. $lead_key = wp_generate_uuid4(); $payload = [ 'lead_key' => $lead_key, 'name' => sanitize_text_field( $params['name'] ), 'email' => sanitize_email( $params['email'] ), 'message' => sanitize_textarea_field( $params['message'] ), 'source_url' => isset( $params['source_url'] ) ? esc_url_raw( $params['source_url'] ) : '', 'submitted_at' => gmdate( 'c' ), ]; global $wpdb; $now = gmdate( 'Y-m-d H:i:s' ); $wpdb->insert( leadrelay_table(), [ 'lead_key' => $lead_key, 'payload' => wp_json_encode( $payload ), 'status' => 'pending', 'attempts' => 0, 'next_attempt_at' => $now, 'created_at' => $now, 'updated_at' => $now, ], [ '%s', '%s', '%s', '%d', '%s', '%s', '%s' ] ); if ( ! $wpdb->insert_id ) { return new WP_Error( 'storage_failed', 'Could not queue the lead', [ 'status' => 500 ] ); } // Best-effort first attempt. See the note below before you keep this line. leadrelay_deliver_one( (int) $wpdb->insert_id ); return new WP_REST_Response( [ 'ok' => true, 'lead_key' => $lead_key ], 201 ); } 

The inline delivery attempt is a trade-off. If your CRM normally answers in 200ms, it means the lead is in the CRM before the visitor closes the tab. If your CRM is slow, delete that line and let the worker do it within a minute - the visitor gets an instant 201 either way, because the row is already saved.

Step 3 - the delivery function

function leadrelay_deliver_one( int $row_id ): bool { global $wpdb; $table = leadrelay_table(); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . $table . ' WHERE id = %d', $row_id ) ); if ( ! $row || 'sent' === $row->status ) { return true; } $endpoint = defined( 'LEADRELAY_ENDPOINT' ) ? LEADRELAY_ENDPOINT : ''; $token = defined( 'LEADRELAY_TOKEN' ) ? LEADRELAY_TOKEN : ''; if ( ! $endpoint ) { return false; } $response = wp_remote_post( $endpoint, [ 'timeout' => 5, 'redirection' => 0, 'blocking' => true, 'headers' => [ 'Content-Type' => 'application/json', 'Idempotency-Key' => $row->lead_key, 'Authorization' => 'Bearer ' . $token, ], 'body' => $row->payload, ] ); $attempts = (int) $row->attempts + 1; $now = gmdate( 'Y-m-d H:i:s' ); if ( is_wp_error( $response ) ) { return leadrelay_mark_failed( $row, $attempts, 0, $response->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = substr( (string) wp_remote_retrieve_body( $response ), 0, 500 ); if ( $code >= 200 && $code < 300 ) { $wpdb->update( $table, [ 'status' => 'sent', 'attempts' => $attempts, 'last_code' => $code, 'last_error' => null, 'updated_at' => $now, ], [ 'id' => $row->id ], [ '%s', '%d', '%d', '%s', '%s' ], [ '%d' ] ); return true; } // 4xx other than 408/429 will never succeed. Park it for a human. if ( $code >= 400 && $code < 500 && ! in_array( $code, [ 408, 429 ], true ) ) { $wpdb->update( $table, [ 'status' => 'dead', 'attempts' => $attempts, 'last_code' => $code, 'last_error' => $body, 'updated_at' => $now, ], [ 'id' => $row->id ], [ '%s', '%d', '%d', '%s', '%s' ], [ '%d' ] ); leadrelay_alert( $row->lead_key, $code, $body ); return false; } return leadrelay_mark_failed( $row, $attempts, $code, $body ); } function leadrelay_mark_failed( $row, int $attempts, int $code, string $error ): bool { global $wpdb; $table = leadrelay_table(); $now = time(); if ( $attempts >= LEADRELAY_MAX_ATTEMPTS ) { $wpdb->update( $table, [ 'status' => 'dead', 'attempts' => $attempts, 'last_code' => $code, 'last_error' => $error, 'updated_at' => gmdate( 'Y-m-d H:i:s', $now ), ], [ 'id' => $row->id ], [ '%s', '%d', '%d', '%s', '%s' ], [ '%d' ] ); leadrelay_alert( $row->lead_key, $code, $error ); return false; } // 1, 2, 4, 8, 16, 32, 64 minutes, plus up to 30% jitter, capped at an hour. $delay = (int) ( MINUTE_IN_SECONDS * ( 2 ** ( $attempts - 1 ) ) ); $delay = (int) ( $delay * ( 1 + wp_rand( 0, 30 ) / 100 ) ); $delay = min( $delay, HOUR_IN_SECONDS ); $wpdb->update( $table, [ 'status' => 'pending', 'attempts' => $attempts, 'last_code' => $code, 'last_error' => $error, 'next_attempt_at' => gmdate( 'Y-m-d H:i:s', $now + $delay ), 'updated_at' => gmdate( 'Y-m-d H:i:s', $now ), ], [ 'id' => $row->id ], [ '%s', '%d', '%d', '%s', '%s', '%s' ], [ '%d' ] ); return false; } function leadrelay_alert( string $lead_key, int $code, string $error ): void { wp_mail( get_option( 'admin_email' ), 'Lead relay: delivery failed (' . $code . ')', 'Lead ' . $lead_key . ' could not be delivered. HTTP ' . $code . ' - ' . substr( $error, 0, 1000 ) ); } 

The classification is the whole point. 5xx, 408, 429 and network errors are temporary - retry. 400, 401, 403, 422 are permanent - retrying them eight times just delays the moment you notice. Those go straight to dead with an alert.

Step 4 - the worker

add_filter( 'cron_schedules', function ( $schedules ) { $schedules['leadrelay_minute'] = [ 'interval' => 60, 'display' => 'Every minute (Lead Relay)', ]; return $schedules; } ); add_action( 'init', function () { if ( ! wp_next_scheduled( LEADRELAY_CRON_HOOK ) ) { wp_schedule_event( time() + 60, 'leadrelay_minute', LEADRELAY_CRON_HOOK ); } } ); add_action( LEADRELAY_CRON_HOOK, function () { // One worker at a time. if ( get_transient( 'leadrelay_lock' ) ) { return; } set_transient( 'leadrelay_lock', 1, 5 * MINUTE_IN_SECONDS ); global $wpdb; $table = leadrelay_table(); $now = gmdate( 'Y-m-d H:i:s' ); $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM ' . $table . ' WHERE status = %s AND next_attempt_at <= %s ORDER BY next_attempt_at ASC LIMIT 20', 'pending', $now ) ); foreach ( $ids as $id ) { leadrelay_deliver_one( (int) $id ); } delete_transient( 'leadrelay_lock' ); } ); 

Now the part most tutorials skip: wp-cron is triggered by page views. A site with no overnight traffic runs no overnight deliveries. Replace it with a real system cron entry:

* * * * * cd /var/www/example && wp cron event run leadrelay_deliver --quiet 

Leave the wp-cron registration in place so a low-traffic site still makes progress, and treat the system cron as the reliable path.

Step 5 - dead letters a human can act on

if ( defined( 'WP_CLI' ) && WP_CLI ) { WP_CLI::add_command( 'leadrelay', 'LeadRelay_CLI' ); } class LeadRelay_CLI { /** * List queued leads by status. * * ## OPTIONS * [--status=<status>] */ public function leads( $args, $assoc ) { global $wpdb; $status = isset( $assoc['status'] ) ? $assoc['status'] : 'dead'; $table = leadrelay_table(); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, lead_key, status, attempts, last_code, created_at FROM ' . $table . ' WHERE status = %s ORDER BY id DESC LIMIT 50', $status ), ARRAY_A ); WP_CLI\Utils\format_items( 'table', $rows, [ 'id', 'lead_key', 'status', 'attempts', 'last_code', 'created_at' ] ); } /** * Requeue a lead for immediate delivery. */ public function retry( $args ) { global $wpdb; $id = (int) $args[0]; $now = gmdate( 'Y-m-d H:i:s' ); $wpdb->update( leadrelay_table(), [ 'status' => 'pending', 'attempts' => 0, 'next_attempt_at' => $now, 'updated_at' => $now, ], [ 'id' => $id ], [ '%s', '%d', '%s', '%s' ], [ '%d' ] ); WP_CLI::success( 'Requeued lead ' . $id ); } } 
wp leadrelay leads --status=dead wp leadrelay retry 42 

A queue nobody reads is just a slower way to lose the lead. Put wp leadrelay leads --status=dead in the same place you check the error log.

Step 6 - the form

<form id='lead-form'> <input name='name' required> <input name='email' type='email' required> <textarea name='message' required></textarea> <input name='company_website' tabindex='-1' autocomplete='off' aria-hidden='true' style='position:absolute;left:-9999px'> <button type='submit'>Send</button> </form> 

Related posts