Using a CF7 Field to Query an External API and Auto-Populate Other Fields

A developer posted on the WordPress support forums asking whether a CF7 field value could be used to query an external API and populate other fields on the same form in real time. The plugin author replied that it required custom code: an AJAX function calling the API, then JavaScript populating the fields from the response. Thread closed as resolved.
That direction is correct. The implementation detail is everything.
This post builds the complete working solution: a WordPress AJAX proxy endpoint that keeps API credentials off the client, debounced JavaScript that fires on field input, sanitized response handling, field population with CF7 validation awareness, and loading state feedback for the user.
Architecture: Why a Server-Side Proxy Is Required
The natural instinct is to call the external API directly from browser-side JavaScript. This is wrong for any API that requires authentication.
An API key in client-side JavaScript is visible to every user who opens browser DevTools and looks at the network tab. The key can be copied, used against your quota, or used to run up charges on your billing account.
The correct architecture:
[User types in CF7 field]
|
| fetch() POST to /wp-admin/admin-ajax.php
v
[WordPress AJAX handler]
|
| wp_remote_get() with API key from wp-config.php constants
v
[External API]
|
| JSON response
v
[WordPress sanitizes and filters the response]
|
| JSON back to browser (only the fields needed)
v
[JavaScript populates the CF7 fields]
Your API key exists only on the server. The browser sees only the sanitized subset of the API response that you explicitly return.
Step 1: WordPress AJAX Handler
WordPress provides two action hooks for AJAX endpoints:
wp_ajax_{action}fires for logged-in userswp_ajax_nopriv_{action}fires for non-logged-in users
CF7 forms on public pages are typically submitted by non-logged-in visitors. You need both.
add_action('wp_ajax_cf7_field_lookup', 'cf7_field_lookup_handler');
add_action('wp_ajax_nopriv_cf7_field_lookup', 'cf7_field_lookup_handler');
function cf7_field_lookup_handler(): void {
// Verify nonce to prevent CSRF and endpoint abuse
check_ajax_referer('cf7_field_lookup_nonce', 'nonce');
\(query = sanitize_text_field(wp_unslash(\)_POST['query'] ?? ''));
if (empty($query)) {
wp_send_json_error(['message' => 'Query parameter is required'], 400);
}
// Optional: validate the format of the query before hitting the API
// Example for UK company number (8 digits, optionally zero-padded):
// if (!preg_match('/^\d{6,8}\(/', \)query)) {
// wp_send_json_error(['message' => 'Invalid format'], 422);
// }
$api_key = defined('LOOKUP_API_KEY') ? LOOKUP_API_KEY : '';
$base_url = defined('LOOKUP_API_URL') ? LOOKUP_API_URL : '';
if (empty(\(api_key) || empty(\)base_url)) {
error_log('[CF7 Lookup] API credentials not configured in wp-config.php');
wp_send_json_error(['message' => 'Service not configured'], 503);
}
\(endpoint = add_query_arg(['number' => urlencode(\)query)], $base_url);
\(response = wp_remote_get(\)endpoint, [
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
'Accept' => 'application/json',
],
'timeout' => 8,
]);
if (is_wp_error($response)) {
error_log('[CF7 Lookup] wp_remote_get failed: ' . $response->get_error_message());
wp_send_json_error(['message' => 'Could not reach lookup service'], 502);
}
\(http_code = wp_remote_retrieve_response_code(\)response);
\(body = json_decode(wp_remote_retrieve_body(\)response), true);
if (\(http_code === 404 || empty(\)body)) {
wp_send_json_error(['message' => 'No results found for that value'], 404);
}
if ($http_code !== 200) {
error_log('[CF7 Lookup] API error ' . \(http_code . ': ' . wp_remote_retrieve_body(\)response));
wp_send_json_error(['message' => 'Lookup service error'], 502);
}
// Return ONLY the data the browser needs
// Never return the raw $body — it may contain internal data you do not want exposed
wp_send_json_success([
'company_name' => sanitize_text_field($body['company_name'] ?? ''),
'street' => sanitize_text_field($body['registered_address']['address_line_1'] ?? ''),
'city' => sanitize_text_field($body['registered_address']['locality'] ?? ''),
'postcode' => sanitize_text_field($body['registered_address']['postal_code'] ?? ''),
'country' => sanitize_text_field($body['registered_address']['country'] ?? ''),
'status' => sanitize_text_field($body['company_status'] ?? ''),
]);
}
Add credentials to wp-config.php:
define('LOOKUP_API_KEY', 'your-bearer-token');
define('LOOKUP_API_URL', 'https://api.example.com/v1/company');
Step 2: Enqueue Script and Pass Nonce to Browser
The nonce must be generated server-side and passed to JavaScript. wp_localize_script is the correct WordPress mechanism for this.
add_action('wp_enqueue_scripts', 'cf7_lookup_enqueue_assets');
function cf7_lookup_enqueue_assets(): void {
// Condition this to only load on pages where the CF7 form appears
// to avoid loading unnecessary JS on every page
if (!is_page('contact') && !is_page('get-quote')) { // adjust as needed
return;
}
wp_enqueue_script(
'cf7-field-lookup',
get_template_directory_uri() . '/js/cf7-field-lookup.js',
[], // no jQuery dependency needed — using native fetch()
'1.0.0',
true // load in footer
);
wp_localize_script('cf7-field-lookup', 'CF7Lookup', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('cf7_field_lookup_nonce'),
'strings' => [
'loading' => 'Looking up...',
'not_found' => 'No results found. Please fill in manually.',
'error' => 'Lookup unavailable. Please fill in manually.',
'found' => 'Details populated',
],
]);
}
Passing user-facing strings via wp_localize_script rather than hardcoding them in JavaScript keeps the script translatable and lets you adjust messaging without touching the JS file.
Step 3: The Complete JavaScript Implementation
// js/cf7-field-lookup.js
(function () {
'use strict';
// ── Utilities ────────────────────────────────────────────────────────────
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function setStatus(triggerEl, message, state) {
// state: 'idle' | 'loading' | 'success' | 'error'
let el = triggerEl.closest('.wpcf7-form-control-wrap')
?.querySelector('.cf7-lookup-feedback');
if (!el) {
el = document.createElement('small');
el.className = 'cf7-lookup-feedback';
triggerEl.closest('.wpcf7-form-control-wrap')?.appendChild(el);
}
const palette = {
idle: { color: 'inherit', text: '' },
loading: { color: '#888888', text: message },
success: { color: '#2a7a2a', text: message },
error: { color: '#cc3333', text: message },
};
const style = palette[state] || palette.idle;
el.style.color = style.color;
el.style.display = style.text ? 'block' : 'none';
el.textContent = style.text;
}
function setFieldValue(fieldName, value) {
const el = document.querySelector(
'.wpcf7-form [name="' + fieldName + '"]'
);
if (!el) return;
el.value = value || '';
// Fire both input and change so CF7's validation
// and any third-party listeners re-evaluate the field
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function clearDependentFields(fieldNames) {
fieldNames.forEach(name => setFieldValue(name, ''));
}
// ── Field map: API response key -> CF7 field name ─────────────────────
// Adjust these to match your CF7 form's field names exactly
const FIELD_MAP = {
company_name: 'company-name',
street: 'street-address',
city: 'city',
postcode: 'post-code',
country: 'country',
status: 'company-status',
};
const DEPENDENT_FIELD_NAMES = Object.values(FIELD_MAP);
// ── Core lookup function ──────────────────────────────────────────────
function performLookup(query, triggerEl) {
if (!query || query.length < 2) return;
setStatus(triggerEl, CF7Lookup.strings.loading, 'loading');
clearDependentFields(DEPENDENT_FIELD_NAMES);
const body = new FormData();
body.append('action', 'cf7_field_lookup');
body.append('nonce', CF7Lookup.nonce);
body.append('query', query);
fetch(CF7Lookup.ajaxUrl, { method: 'POST', body })
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(response => {
if (!response.success) {
setStatus(
triggerEl,
response.data?.message || CF7Lookup.strings.not_found,
'error'
);
return;
}
Object.entries(FIELD_MAP).forEach(([dataKey, fieldName]) => {
if (response.data[dataKey] !== undefined) {
setFieldValue(fieldName, response.data[dataKey]);
}
});
setStatus(triggerEl, CF7Lookup.strings.found, 'success');
})
.catch(() => {
setStatus(triggerEl, CF7Lookup.strings.error, 'error');
});
}
// ── Initialization ────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Replace 'company-number' with your actual CF7 trigger field name
const triggerEl = document.querySelector(
'.wpcf7-form [name="company-number"]'
);
if (!triggerEl) return;
// Option A: debounced input (fires while typing, waits for pause)
triggerEl.addEventListener(
'input',
debounce(e => performLookup(e.target.value.trim(), triggerEl), 600)
);
// Option B: blur (fires once when field loses focus — better for paste/autofill)
triggerEl.addEventListener('blur', e => {
const val = e.target.value.trim();
// Only fire on blur if the debounced input hasn't already handled it
if (val && val.length >= 2) {
performLookup(val, triggerEl);
}
});
});
})();
Handling the Case Where the Lookup Returns No Results
When the API returns a 404 or empty result, the dependent fields are cleared and a message is shown. The user can fill them in manually. This is the correct fallback the form should always be submittable even if the lookup fails, assuming the dependent fields are not strictly required.
If they are required, CF7's built-in required field validation ([text* field-name]) will prevent submission when they are empty, and the error message will tell the user to fill them in.
For optional dependent fields, add a note to your CF7 form copy: "Details will be populated automatically if found, or you can fill them in manually."
Rate Limiting the AJAX Endpoint
A public wp_ajax_nopriv_ endpoint can be called by anyone, not just users who have your CF7 form open. Without rate limiting, it can be abused to run up charges on your external API.
Add basic rate limiting using WordPress transients:
function cf7_field_lookup_handler(): void {
check_ajax_referer('cf7_field_lookup_nonce', 'nonce');
// Rate limit: max 20 requests per IP per minute
\(ip = sanitize_text_field(\)_SERVER['REMOTE_ADDR'] ?? '');
\(rate_key = 'cf7_lookup_rate_' . md5(\)ip);
\(request_count = (int) get_transient(\)rate_key);
if ($request_count >= 20) {
wp_send_json_error(['message' => 'Too many requests'], 429);
}
set_transient(\(rate_key, \)request_count + 1, 60); // resets after 60 seconds
// ... rest of handler
}
20 requests per minute per IP is generous for legitimate form use and blocks automated abuse effectively.
Sending the Populated Fields to an External API on Submission
This pattern handles the lookup before submission. Once the user submits the form, the populated fields (company name, address, etc.) are included in the CF7 submission data just like any other field. For sending all the submitted data to a CRM or external API on form submit, Contact Form to API handles the outbound POST from the server side without additional custom PHP.
Summary
| Layer | What it does |
|---|---|
wp_ajax_nopriv_ handler |
Server-side proxy: receives query, calls external API, returns sanitized data |
wp_create_nonce() + check_ajax_referer() |
Prevents CSRF abuse of the AJAX endpoint |
API key in wp-config.php |
Never exposed to browser |
debounce(fn, 600) |
Prevents one API call per keystroke |
dispatchEvent(new Event('change')) |
Triggers CF7 validation re-evaluation after field population |
setStatus() with loading/success/error states |
User feedback during and after lookup |
| Transient-based rate limiting | Protects against endpoint abuse |





