DEV Community

Cover image for WordPress forms to a CRM over webhook: persist first, retry with backoff, dedupe on replay
AumCreate
AumCreate

Posted on

WordPress forms to a CRM over webhook: persist first, retry with backoff, dedupe on replay

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
Enter fullscreen mode Exit fullscreen mode

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 );
    }
} );
Enter fullscreen mode Exit fullscreen mode

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 );
}
Enter fullscreen mode Exit fullscreen mode

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 )
    );
}
Enter fullscreen mode Exit fullscreen mode

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' );
} );
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 );
    }
}
Enter fullscreen mode Exit fullscreen mode
wp leadrelay leads --status=dead
wp leadrelay retry 42
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
document.getElementById('lead-form').addEventListener('submit', async function (event) {
  event.preventDefault();
  const form = event.currentTarget;
  const data = Object.fromEntries(new FormData(form).entries());
  data.source_url = location.href;

  try {
    const res = await fetch('/wp-json/leadrelay/v1/intake', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });

    if (res.ok) {
      form.innerHTML = '<p>Thanks - we got it.</p>';
      return;
    }
  } catch (err) {
    // fall through to the fallback below
  }

  // Never destroy what the visitor typed.
  document.getElementById('form-error').textContent =
    'Something broke on our side. Please email us directly - your text is still in the form.';
});
Enter fullscreen mode Exit fullscreen mode

That form-error element is the cheapest insurance in the whole build. If the intake itself fails (database down, nonce plugin misbehaving), the visitor still has a path.

Test it against a local sink before touching production

You do not need a real CRM to prove the retry loop works. Save this as sink.php:

<?php
// php -S 127.0.0.1:8080 sink.php
$body = file_get_contents( 'php://input' );
file_put_contents( __DIR__ . '/leads.log', $body . PHP_EOL, FILE_APPEND );

header( 'Content-Type: application/json' );
// Flip this to 503 to watch the retry loop do its job.
$mode = file_exists( __DIR__ . '/fail' ) ? 503 : 200;
http_response_code( $mode );
echo json_encode( [ 'ok' => $mode === 200 ] );
Enter fullscreen mode Exit fullscreen mode

Point the plugin at it in wp-config.php:

define( 'LEADRELAY_ENDPOINT', 'http://127.0.0.1:8080/' );
define( 'LEADRELAY_TOKEN', 'test-token' );
Enter fullscreen mode Exit fullscreen mode

Then:

  1. Submit the form once. leads.log gets one line and the row flips to sent.
  2. touch fail and submit again. The form still returns 201, the row stays pending, and attempts climbs.
  3. wp leadrelay leads --status=pending shows the backoff schedule in next_attempt_at.
  4. rm fail, run wp cron event run leadrelay_deliver, and the row flips to sent.
  5. Return 400 from the sink instead: the row goes straight to dead and the admin email fires.

If any of those five steps is not what you see, the ordering in your code is wrong, not the CRM.

Idempotency is a contract with the receiving end

Sending Idempotency-Key only helps if the CRM honours it. Two things to check before you trust it:

  • Does the CRM dedupe on that header, or only on email address? Deduping on email merges two different people who share a shared inbox.
  • What does it return on a replay - the original 2xx, or a 409? If it is a 409, treat 409 as success in leadrelay_deliver_one, otherwise every retried lead ends up in the dead-letter pile.

If the CRM cannot dedupe at all, add a lookup before insert on their side using lead_key, or accept duplicates and reconcile on lead_key in a nightly job. Decide which one, in writing, before launch.

Checklist to carry into production

  • The row is written before any network call, and the visitor's request never depends on the CRM being up.
  • Outbound timeout is short. The visitor should not wait on your CRM's worst day.
  • One lead_key, generated at intake, reused on every attempt, sent as the idempotency key.
  • Retry classification: 5xx, 408, 429, network errors retry; other 4xx park immediately.
  • Attempts are capped and the cap triggers an alert, not silence.
  • The worker runs on system cron, with a lock so two runs cannot overlap.
  • Dead letters are visible with one command, and requeueing one is also one command.
  • A second channel exists - email yourself the lead as well, so a CRM outage is a delay rather than a data loss.
  • You have run the failure test, not just the happy path.

Disclosure: the inquiry-form template at the link is our own demo site, not a client project - inquiry form demo.

Top comments (0)