A B2B inquiry form that emails a shared inbox is not a lead pipeline. It's a race condition.
Two people reply to the same inquiry, or nobody does. The CRM gets a contact with no owner. The form handler times out while the CRM API is restarting and the submission is gone — the customer sees a success message and never hears back.
This is a process writeup, not a plugin recommendation. It's the sequence I'd use to wire any WordPress form to any CRM: persist first, route deterministically, sync through an outbox, and watch the clock.
The four failure modes worth designing against
| Failure | What it looks like | Fix |
|---|---|---|
| Lost on the way out | CRM API times out, submission discarded | persist before sending |
| Nobody owns it | record created, owner empty |
routing rules evaluated at capture time |
| Two people reply | same company submits twice, two deals | dedupe key, unique index |
| Silence | owned, but no first contact for days | SLA query + escalation |
Every step below exists to kill one of those four.
Step 0 — Define the field contract
Before wiring anything, split the form fields into three groups. This split is what makes routing possible at all.
Routing fields — structured, validated, never free text:
-
email(required) -
company(required) -
country(required, 2-letter ISO) -
product_line(select, one of your lines) -
qty(number, optional but strongly encouraged)
Context fields — hidden inputs, captured automatically:
- landing page URL, referrer
-
utm_source/utm_medium/utm_campaign - form id and locale
- submit timestamp (server side)
Free text — message. Store it, show it to the owner, but never route on it. A regex over a paragraph written in three languages is not a routing rule.
If qty is empty, don't guess the tier. The autoresponder asks one question, and the reply updates the record.
Step 1 — Persist before you send
Your database is the source of truth. The CRM is a downstream copy. That ordering is the whole trick: if the CRM is down for twenty minutes, nothing is lost.
register_activation_hook(__FILE__, function () {
global $wpdb;
$table = $wpdb->prefix . 'inquiries';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
created_at DATETIME NOT NULL,
email VARCHAR(190) NOT NULL,
company VARCHAR(190) NULL,
country CHAR(2) NULL,
product_line VARCHAR(64) NULL,
qty INT UNSIGNED NULL,
message TEXT NULL,
payload LONGTEXT NOT NULL,
dedupe_key CHAR(64) NOT NULL,
owner VARCHAR(190) NULL,
crm_state VARCHAR(16) NOT NULL DEFAULT 'pending',
crm_id VARCHAR(64) NULL,
attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
next_attempt_at DATETIME NULL,
first_touch_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY dedupe_key (dedupe_key),
KEY crm_state_next (crm_state, next_attempt_at)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
});
The raw payload column is not laziness — when someone claims they asked for 500 units and the record says 5, you want the original request body, untouched.
Step 2 — One dedupe key, hashed once
Dedupe on email + company inside a rolling window. A monthly bucket is usually right for B2B: the same buyer asking about a different product line next month is a new conversation, the same buyer double-submitting after a timeout is not.
function acx_dedupe_key(array $lead) {
$bucket = (int) floor(time() / 2592000); // 30-day window
return hash('sha256', implode('|', array(
strtolower(trim($lead['email'])),
strtolower(trim((string) $lead['company'])),
$bucket,
)));
}
With the unique index in place, INSERT IGNORE makes the race safe — two simultaneous submissions produce one row, and the second call returns the existing id instead of a duplicate deal.
function acx_capture_inquiry(array $raw) {
global $wpdb;
$lead = acx_normalize($raw);
$lead['dedupe_key'] = acx_dedupe_key($lead);
$table = $wpdb->prefix . 'inquiries';
$wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$table}
(created_at, email, company, country, product_line, qty, message, payload, dedupe_key, owner)
VALUES (UTC_TIMESTAMP(), %s, %s, %s, %s, %d, %s, %s, %s, %s)",
$lead['email'],
$lead['company'],
$lead['country'],
$lead['product_line'],
(int) $lead['qty'],
$lead['message'],
wp_json_encode($raw),
$lead['dedupe_key'],
acx_pick_owner($lead)
));
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE dedupe_key = %s",
$lead['dedupe_key']
));
}
function acx_normalize(array $raw) {
$country = strtoupper(preg_replace('/[^A-Za-z]/', '', (string) ($raw['country'] ?? '')));
return array(
'email' => sanitize_email($raw['email'] ?? ''),
'company' => sanitize_text_field($raw['company'] ?? ''),
'country' => substr($country, 0, 2),
'product_line' => sanitize_key($raw['product_line'] ?? ''),
'qty' => absint($raw['qty'] ?? 0),
'message' => sanitize_textarea_field($raw['message'] ?? ''),
);
}
Hook it from whatever your form fires after a successful submit — one line, one entry point, and every form on the site goes through the same contract:
add_action('acx_inquiry_submitted', 'acx_capture_inquiry', 10, 1);
Step 3 — Route deterministically, not by luck
Two rules, evaluated in this order:
- Territory — country maps to a region, region maps to a pool of owners.
- Tier — quantity above your threshold swaps the pool for the senior one.
function acx_region($country) {
$apac = array('SG','MY','ID','TH','VN','PH','AU','NZ','JP','KR','IN','CN','HK','TW');
$emea = array('GB','DE','FR','NL','ES','IT','PL','SE','AE','SA','ZA','TR');
$amer = array('US','CA','MX','BR','CL');
if (in_array($country, $apac, true)) { return 'apac'; }
if (in_array($country, $emea, true)) { return 'emea'; }
if (in_array($country, $amer, true)) { return 'amer'; }
return 'row';
}
function acx_pick_owner(array $lead) {
$region = acx_region($lead['country']);
$tier = ((int) $lead['qty'] >= 500) ? 'key_' : '';
$pools = apply_filters('acx_owner_pool', array(
'apac' => array('sia@example.com', 'jun@example.com'),
'key_apac' => array('sia@example.com'),
'emea' => array('omar@example.com'),
'amer' => array('dana@example.com'),
'row' => array('sia@example.com'),
), $lead);
$key = $tier . $region;
if (empty($pools[$key])) {
$key = isset($pools[$region]) ? $region : 'row';
}
if (empty($pools[$key])) {
return null;
}
$candidates = $pools[$key];
$index = hexdec(substr($lead['dedupe_key'], 0, 8)) % count($candidates);
return $candidates[$index];
}
Hashing the dedupe key instead of using a counter is the important detail. Assignment becomes stateless and idempotent: the retry, the duplicate submission and the backfill all compute the same owner, so you can never end up with two rows owned by two people. The tradeoff is that distribution is only approximate — fine for pools of two to five people, not fine for a 50-seat call centre.
Because the owner is decided at capture time, the CRM record arrives with an owner already set. No assignment rule to debug on the other side.
Step 4 — Sync through an outbox
Nothing calls the CRM during the request. A worker drains the queue:
add_action('acx_crm_flush', 'acx_flush_outbox');
function acx_flush_outbox() {
global $wpdb;
$table = $wpdb->prefix . 'inquiries';
$rows = $wpdb->get_results(
"SELECT * FROM {$table}
WHERE crm_state = 'pending'
AND (next_attempt_at IS NULL OR next_attempt_at <= UTC_TIMESTAMP())
ORDER BY id ASC LIMIT 20"
);
foreach ($rows as $row) {
$result = acx_push_to_crm($row);
if (is_wp_error($result)) {
$backoff = min(3600, 60 * pow(4, (int) $row->attempts));
$wpdb->query($wpdb->prepare(
"UPDATE {$table}
SET attempts = attempts + 1,
next_attempt_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL %d SECOND)
WHERE id = %d",
$backoff, $row->id
));
continue;
}
$wpdb->update($table, array(
'crm_state' => 'synced',
'crm_id' => $result['id'],
), array('id' => $row->id));
}
}
function acx_push_to_crm($row) {
$response = wp_remote_post('https://crm.example.com/api/v1/leads', array(
'timeout' => 10,
'headers' => array(
'Content-Type' => 'application/json',
'Idempotency-Key' => $row->dedupe_key,
'Authorization' => 'Bearer ' . ACME_CRM_TOKEN,
),
'body' => wp_json_encode(array(
'email' => $row->email,
'company' => $row->company,
'country' => $row->country,
'product_line' => $row->product_line,
'qty' => (int) $row->qty,
'message' => $row->message,
'owner' => $row->owner,
'source' => 'website_form',
)),
));
if (is_wp_error($response)) {
return $response;
}
$code = wp_remote_retrieve_response_code($response);
if ($code < 200 || $code >= 300) {
return new WP_Error('acx_crm_push', 'CRM returned HTTP ' . $code);
}
$data = json_decode(wp_remote_retrieve_body($response), true);
return array('id' => isset($data['id']) ? $data['id'] : null);
}
Retries are safe because the Idempotency-Key is the same on every attempt — even a "failed" call that actually reached the CRM won't create a second lead.
One honest caveat: WP-Cron fires on page views, so it is not a reliable one-minute scheduler. Point a real system cron at wp-cron.php every minute, or run the drain from a small worker outside WordPress.
Step 5 — Watch the clock, not the pipeline
A lead with an owner and no first contact is worse than an unassigned one, because it looks handled. Run this on the same schedule and escalate:
$stale = $wpdb->get_results(
"SELECT id, owner, email, company, created_at
FROM {$wpdb->prefix}inquiries
WHERE crm_state = 'synced'
AND first_touch_at IS NULL
AND created_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 4 HOUR)"
);
What "escalate" means is your call, but it must be an action, not a report: reassign to the pool, notify the owner's backup, and write a line into the record so the next audit sees it happened. Set first_touch_at from the CRM webhook when the owner sends the first reply — if you have no webhook, a manual "mark contacted" button in the admin list beats nothing.
Step 6 — Three queries you run weekly
-- stuck: never reached the CRM
SELECT id, email, attempts, next_attempt_at
FROM wp_inquiries
WHERE crm_state = 'pending' AND created_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 HOUR);
-- unowned: routing table has a hole
SELECT country, product_line, COUNT(*) AS n
FROM wp_inquiries
WHERE owner IS NULL GROUP BY country, product_line;
-- dedupe rate: a spike means double submits or a bot
SELECT DATE(created_at) AS d, COUNT(*) AS rows_total,
COUNT(DISTINCT dedupe_key) AS unique_leads
FROM wp_inquiries
GROUP BY DATE(created_at) ORDER BY d DESC LIMIT 14;
The first query is the one that matters. Any row older than an hour in pending means either the worker isn't running or the CRM is rejecting your payload — and both are silent without this check.
What you've actually built
- A submission that survives a CRM outage, because the row is written first
- One record per real inquiry, enforced by a unique index rather than a hope
- An owner decided at capture time, computed idempotently from the lead itself
- Retries with backoff, safe to run twice
- An SLA query that turns silent leads into an action
None of that is CRM-specific, and none of it needs a new dependency. It's a table, a routing function and a worker.
If you'd rather have this built against your own field list and CRM rather than assembling it, that's the kind of WordPress work we take on: aumcreate.com/services.
Top comments (0)