A developer asked on the WordPress forums: can a CF7 field value be used to query an external API and populate other fields on the same form?
The plugin author replied: not out of the box, you need custom AJAX code and JavaScript. Thread closed.
That answer points in the right direction but gives nothing to work with. This post builds the complete implementation: a WordPress AJAX proxy endpoint on the server side (to keep your API keys out of the browser), JavaScript that fires on field input, and DOM manipulation that populates CF7 fields from the API response.
Why You Need a Server-Side Proxy
The instinct is to call the external API directly from JavaScript in the browser. Do not do this if the API requires an API key or any form of authentication.
Calling an authenticated API from client-side JavaScript exposes your credentials in the browser's network tab. Anyone can open DevTools, copy your API key, and use it against your quota or billing account.
The correct pattern is a two-hop architecture:
User types in CF7 field
|
| fetch() to your WordPress AJAX endpoint
v
WordPress handles the request server-side
|
| wp_remote_get() with your API key (never visible to browser)
v
External API returns data
|
| WordPress passes sanitized response back to browser
v
JavaScript populates the other CF7 fields
Your API key lives only in wp-config.php. The browser never sees it.
Step 1: Register the WordPress AJAX Endpoint
// Register for both logged-in and non-logged-in users
// (CF7 forms are typically on public pages)
add_action('wp_ajax_cf7_api_lookup', 'cf7_api_lookup_handler');
add_action('wp_ajax_nopriv_cf7_api_lookup', 'cf7_api_lookup_handler');
function cf7_api_lookup_handler(): void {
// Verify the nonce to prevent CSRF abuse of your proxy
check_ajax_referer('cf7_api_lookup_nonce', 'nonce');
$query = sanitize_text_field($_POST['query'] ?? '');
if (empty($query)) {
wp_send_json_error(['message' => 'Query is required'], 400);
}
// Validate format if needed (e.g. postcode pattern, company number format)
// if (!preg_match('/^\d{5}$/', $query)) {
// wp_send_json_error(['message' => 'Invalid format'], 422);
// }
$api_key = defined('EXTERNAL_API_KEY') ? EXTERNAL_API_KEY : '';
$api_url = add_query_arg(
['q' => urlencode($query), 'key' => $api_key],
'https://api.example.com/v1/lookup'
);
$response = wp_remote_get($api_url, [
'timeout' => 8,
'headers' => ['Accept' => 'application/json'],
]);
if (is_wp_error($response)) {
wp_send_json_error(['message' => 'API request failed'], 502);
}
$status = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
if ($status !== 200 || empty($body)) {
wp_send_json_error(['message' => 'No results found'], 404);
}
// Return only the fields you need — do not expose raw API response to browser
wp_send_json_success([
'company_name' => sanitize_text_field($body['company']['name'] ?? ''),
'street_address' => sanitize_text_field($body['address']['street'] ?? ''),
'city' => sanitize_text_field($body['address']['city'] ?? ''),
'postcode' => sanitize_text_field($body['address']['postcode'] ?? ''),
]);
}
Store your API key in wp-config.php:
define('EXTERNAL_API_KEY', 'your-api-key-here');
Step 2: Pass the Nonce to JavaScript
WordPress AJAX requires a nonce for wp_ajax_nopriv_ endpoints. Pass it to your script using wp_localize_script:
add_action('wp_enqueue_scripts', 'cf7_api_lookup_enqueue');
function cf7_api_lookup_enqueue(): void {
// Only enqueue on pages that have the CF7 form
// Adjust the condition to match where your form appears
wp_enqueue_script(
'cf7-api-lookup',
get_template_directory_uri() . '/js/cf7-api-lookup.js',
['jquery'],
'1.0.0',
true
);
wp_localize_script('cf7-api-lookup', 'CF7ApiLookup', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('cf7_api_lookup_nonce'),
]);
}
Step 3: The JavaScript
This is where the CF7 field triggers the lookup and the other fields get populated.
// js/cf7-api-lookup.js
(function() {
'use strict';
// Debounce: wait until the user stops typing before firing the API call
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function showStatus(inputEl, message, type) {
// type: 'loading' | 'success' | 'error' | ''
let statusEl = inputEl.parentElement.querySelector('.cf7-lookup-status');
if (!statusEl) {
statusEl = document.createElement('span');
statusEl.className = 'cf7-lookup-status';
statusEl.style.cssText = 'display:block;font-size:0.85em;margin-top:4px;';
inputEl.parentElement.appendChild(statusEl);
}
const colors = { loading: '#888', success: '#2a7', error: '#c33', '': '#888' };
statusEl.style.color = colors[type] || '#888';
statusEl.textContent = message;
}
function clearFields(fieldNames) {
fieldNames.forEach(function(name) {
const el = document.querySelector('[name="' + name + '"]');
if (el) el.value = '';
});
}
function populateFields(data) {
// Map API response keys to CF7 field names
// Adjust these to match your actual CF7 field names
const fieldMap = {
company_name: 'company-name',
street_address: 'street-address',
city: 'city',
postcode: 'postcode',
};
Object.entries(fieldMap).forEach(function([dataKey, fieldName]) {
const el = document.querySelector('[name="' + fieldName + '"]');
if (el && data[dataKey]) {
el.value = data[dataKey];
// Trigger change event so CF7 validation re-evaluates
el.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}
function doLookup(value, triggerInput) {
if (!value || value.length < 3) return;
showStatus(triggerInput, 'Looking up...', 'loading');
clearFields(['company-name', 'street-address', 'city', 'postcode']);
const formData = new FormData();
formData.append('action', 'cf7_api_lookup');
formData.append('nonce', CF7ApiLookup.nonce);
formData.append('query', value);
fetch(CF7ApiLookup.ajaxUrl, {
method: 'POST',
body: formData,
})
.then(function(res) { return res.json(); })
.then(function(response) {
if (response.success) {
populateFields(response.data);
showStatus(triggerInput, 'Details found', 'success');
} else {
showStatus(triggerInput, response.data.message || 'No results found', 'error');
}
})
.catch(function() {
showStatus(triggerInput, 'Lookup failed. Please fill in manually.', 'error');
});
}
document.addEventListener('DOMContentLoaded', function() {
// Replace 'company-number' with your actual CF7 trigger field name
const triggerInput = document.querySelector('[name="company-number"]');
if (!triggerInput) return;
const debouncedLookup = debounce(function(e) {
doLookup(e.target.value.trim(), triggerInput);
}, 600); // fires 600ms after the user stops typing
triggerInput.addEventListener('input', debouncedLookup);
});
})();
What the 600ms Debounce Does
Without debouncing, every keystroke fires an AJAX request. A user typing "12345" produces five requests, four of which are abandoned as the user keeps typing. Debouncing waits until the user stops for 600ms before firing. This reduces API calls from one-per-keystroke to one-per-completed-entry.
For postcode or company number lookups where users typically paste the value rather than type it, you can also listen on blur (when the field loses focus) instead of input:
triggerInput.addEventListener('blur', function(e) {
doLookup(e.target.value.trim(), triggerInput);
});
blur fires exactly once when the user leaves the field, regardless of how many characters they typed.
Making Populated Fields Required in CF7
If you want CF7's built-in validation to treat auto-populated fields as required, they need to be marked as required in your CF7 form builder using [text* field-name]. The * makes the field required. Since your JavaScript populates them before submission, CF7 will validate them as filled.
If a user skips the trigger field and the populated fields remain empty, CF7 blocks the form submission with a validation error on the required fields. This is the correct behaviour.
Using Contact Form to API for the Submission Side
This pattern handles the lookup before submission. For sending the submitted data (including the populated fields) to an external API after the user clicks submit, Contact Form to API handles the outbound POST to your CRM or backend without custom PHP on the submission side. The two approaches work together: JavaScript for the lookup, Contact Form to API for the outbound submission.
Quick Checklist
- AJAX endpoint registered for both
wp_ajax_andwp_ajax_nopriv_ - Nonce verified in the handler with
check_ajax_referer() - API key in
wp-config.phpconstants, never in JavaScript - Response sanitized before sending back to browser
- Debounce set on the trigger field (600ms for typing, or use
blur) -
dispatchEvent(new Event('change'))after populating each field so CF7 validation re-evaluates
Top comments (0)