Chris Tam · Last updated 16 October 2026
A submit button shows an attempt; a server response tells you whether the form was accepted. Product analytics lets you keep those actions separate when you measure a WordPress funnel.
In this tutorial, I'll add a shortcode form, validate it in WordPress, save a private acceptance record, and track its successful response.
What you'll build
Pricing CTA Clicked and Demo Form Accepted events, with the latter sent only after WordPress saves the example acceptance record.
Prerequisites
- A staging WordPress site with permission to upload a custom plugin and edit
wp-config.php. This tutorial uses a plugin you control. On managed hosting, confirm that your plan permits those changes. - WordPress 6.3 or later and PHP 7.4 or later, plus a theme that calls
wp_head()andwp_footer(). The executable example was tested on WordPress 6.9.10 and PHP 8.3.33. - An Amplitude project API key: Settings > Projects > your project > General > Manage next to API Key. Managers and Admins can view it. The browser key is public and limited to the minimum scope needed for ingestion. Keep secret keys out of this plugin.
- The shared plugin from the WordPress web analytics tutorial, plus public Home and Pricing pages.
A free Amplitude account covers this example within its product limits. Sign up for free to get your project API key.
1. Keep the existing SDK installation
Use the shared plugin and wp-config.php constants from the WordPress web analytics tutorial. Keep one project loader and initializer. This page adds demo.php and events.js; the existing PHP bootstrap detects those files. Preserve replay privacy, engagement, and experiment consent additions.
# Existing plugin folder, followed by this page's additions
wp-content/plugins/amplitude-build/amplitude-build.php
wp-content/plugins/amplitude-build/analytics.js
wp-content/plugins/amplitude-build/demo.php
wp-content/plugins/amplitude-build/events.js2. Add the shortcode and its server handler
Create demo.php below. The shortcode API renders the form. The authenticated and anonymous admin_post hooks reach the same handler, which checks the nonce and email, then uses wp_insert_post to save a private acceptance record.
<?php
// wp-content/plugins/amplitude-build/demo.php
defined('ABSPATH') || exit;
add_action('init', function () {
register_post_type('amp_build_request', array(
'label' => 'Build demo requests',
'public' => false,
'show_ui' => false,
'supports' => array('title'),
));
});
add_shortcode('amplitude_demo_form', function () {
ob_start();
?>
<form id="demo" data-amp-demo-form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="amp_build_demo">
<?php wp_nonce_field('amp_build_demo', 'amp_build_nonce', false); ?>
<label>Test email <input name="email" type="email" required autocomplete="off" data-replay-mask="true"></label>
<button type="submit">Send demo request</button>
<p role="status" aria-live="polite" data-amp-form-status></p>
</form>
<?php
return ob_get_clean();
});
function amp_build_accept_demo() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
wp_send_json_error(array('message' => 'Use POST.'), 405);
}
$nonce = isset($_POST['amp_build_nonce']) && is_string($_POST['amp_build_nonce']) ? sanitize_text_field(wp_unslash($_POST['amp_build_nonce'])) : '';
if (!wp_verify_nonce($nonce, 'amp_build_demo')) {
wp_send_json_error(array('message' => 'Refresh the page and try again.'), 403);
}
$email = isset($_POST['email']) && is_string($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : '';
if (!is_email($email)) {
wp_send_json_error(array('message' => 'Enter a valid test email.'), 400);
}
// The example stores an acceptance record only. No email or form values persist.
$saved = wp_insert_post(array(
'post_type' => 'amp_build_request',
'post_status' => 'private',
'post_title' => 'Synthetic demo form accepted',
), true);
if (is_wp_error($saved) || !$saved) {
wp_send_json_error(array('message' => 'The request could not be saved.'), 500);
}
wp_send_json_success(array('accepted' => true));
}
add_action('admin_post_nopriv_amp_build_demo', 'amp_build_accept_demo');
add_action('admin_post_amp_build_demo', 'amp_build_accept_demo');This is a synthetic example form, not a replacement for your lead-processing system. It validates the test email without storing it and sends no email. WordPress stores only the acceptance record. Before adapting this to public traffic, add your actual processing, abuse controls, and retention rules. A WordPress nonce is not authentication, rate limiting, or a single-use submission ID.
3. Track only the accepted response
Create events.js below. The text-only form uses URL-encoded fields. Read the endpoint with getAttribute('action'): WordPress's hidden input named action shadows the DOM form.action property. The handler disables repeat clicks while a request is pending, then checks the HTTP status and JSON result before tracking a conversion. The SDK is optional for form delivery: blocked analytics must not break submission.
// wp-content/plugins/amplitude-build/events.js
// Keep this handler once; experiments and guides reuse its event names.
(() => {
if (window.__ampBuildEvents) return;
window.__ampBuildEvents = true;
document.addEventListener('click', event => {
if (!(event.target instanceof Element)) return;
if (!event.target.closest('[data-analytics="pricing-cta"]')) return;
window.amplitude?.track('Pricing CTA Clicked', {
placement: 'pricing', page_path: location.pathname
});
});
document.querySelectorAll('[data-amp-demo-form]').forEach(form => {
let pending = false;
form.addEventListener('submit', async event => {
event.preventDefault();
if (pending || !form.reportValidity()) return;
pending = true;
const button = form.querySelector('[type="submit"]');
const status = form.querySelector('[data-amp-form-status]');
button.disabled = true;
status.textContent = 'Sending test request...';
try {
// WordPress's input[name="action"] shadows form.action.
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: new URLSearchParams(new FormData(form)), credentials: 'same-origin'
});
const body = await response.json();
if (!response.ok || body.success !== true || body.data?.accepted !== true) {
throw new Error('The server did not accept this request.');
}
status.textContent = 'Test request accepted.';
window.amplitude?.track('Demo Form Accepted', {
form_name: 'demo', page_path: location.pathname
});
} catch {
status.textContent = 'The request failed. Refresh and try again.';
} finally {
button.disabled = false;
pending = false;
}
});
});
})();On Pricing, add the CTA in a Custom HTML block. Put [amplitude_demo_form] in a separate Shortcode block. Keep the other data attributes when present.
<!-- Pricing page: Custom HTML block -->
<a href="#demo" data-analytics="pricing-cta" data-experiment="pricing-cta" data-guide="pricing-help">
<span>Request a demo</span>
</a># Pricing page: Shortcode block
[amplitude_demo_form]4. Exercise rejection and acceptance
Publish, click the nested CTA text once, and try an empty or invalid email. No accepted conversion should follow. Submit a valid synthetic email and wait for Test request accepted. Then run:
// Browser Console after WordPress accepts the form
window.amplitude.flush();
window.amplitude.getDeviceId();Verify one Pricing CTA Clicked with placement=pricing and one Demo Form Accepted with form_name=demo. Both include page_path; neither custom event includes the email. Autocapture's submit event is a separate attempt signal. For another form plugin, use its documented successful-processing callback rather than reusing this shortcode handler.
WordPress nonces expire. If a page cache serves an old form, the server can reject an otherwise valid test with 403. Purge or exclude this synthetic form page from full-page caching so a reload receives a current nonce; see WordPress nonce verification.
MCP: Ask your coding agent to inspect the project's event taxonomy with the Amplitude MCP server before adding events. Reuse existing names when they describe the same action.
Verify it worked
- WordPress output: Open the public staging page while logged out. Check that the project loader appears once and its inline initialization follows it. After deployment or configuration changes, purge the relevant page and CDN caches.
- Network: Confirm an event POST receives HTTP 200 from
api2.amplitude.comfor US projects orapi.eu.amplitude.comfor EU projects. Large batches can be compressed; use this check for delivery status. - Instrumentation Explorer: Inspect event names, properties, counts, and
device_idin the Chrome extension. Compare one action with one expected custom event. - User Profiles: Search that device ID in Amplitude and inspect its event stream. The docs describe a delay of ten seconds to one minute. Data > Sources > Ingestion Debugger shows request and event counts, rather than individual event payloads.
Confirm the server-saved acceptance and one custom conversion together. An HTTP 200 analytics response alone does not establish form processing.
Troubleshooting
A previously working form starts failing
Refresh the page and inspect the form POST status. A cached nonce can expire; exclude the form page from a cache lifetime that outlives its nonce or refresh nonces through an appropriate server flow. Never cache the form-processing response.
The browser shows Success but downstream delivery is absent
This example saves an acceptance marker only. Add and verify your actual downstream processing before naming a conversion after that downstream outcome.
The plugin is active but no SDK loads
Check that AMP_BUILD_API_KEY is defined before wp-settings.php, then inspect the public page source. This plugin uses the front-end enqueue hook, so its SDK is absent from the WordPress login and administration pages.
The script order differs from the example
Check caching and script-optimization exclusions for amp-build-sdk and amp-build-engagement. The project loader and inline initialization must remain together. Check for an earlier installation in a theme, tag manager, or snippet plugin.
Requests are blocked
Check the key, region, browser extensions, and Console. Follow the relevant SDK's CSP and endpoint guidance while preserving your site's security policy.
Autocapture settings differ from the file
Review Data > Settings > Autocapture. When remote configuration is enabled, remote values take precedence for the features they specify.
FAQ
Is this a built-in WordPress contact form?
It is the example shortcode supplied by this tutorial. Other form plugins have their own validation and success callbacks.
Does a valid nonce prevent duplicate submissions?
WordPress nonces can be reused during their validity window. The browser guard prevents concurrent clicks here; production retries need a server-side idempotency design when duplicate records matter.
Full code reference
Unchanged from earlier pages; keep your versions: amplitude-build.php, analytics.js, engagement.php, experiment-consent.js. Merge the changes into existing files; preserve sibling additions named in the comments.
<?php
// wp-content/plugins/amplitude-build/demo.php
defined('ABSPATH') || exit;
add_action('init', function () {
register_post_type('amp_build_request', array(
'label' => 'Build demo requests',
'public' => false,
'show_ui' => false,
'supports' => array('title'),
));
});
add_shortcode('amplitude_demo_form', function () {
ob_start();
?>
<form id="demo" data-amp-demo-form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="amp_build_demo">
<?php wp_nonce_field('amp_build_demo', 'amp_build_nonce', false); ?>
<label>Test email <input name="email" type="email" required autocomplete="off" data-replay-mask="true"></label>
<button type="submit">Send demo request</button>
<p role="status" aria-live="polite" data-amp-form-status></p>
</form>
<?php
return ob_get_clean();
});
function amp_build_accept_demo() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
wp_send_json_error(array('message' => 'Use POST.'), 405);
}
$nonce = isset($_POST['amp_build_nonce']) && is_string($_POST['amp_build_nonce']) ? sanitize_text_field(wp_unslash($_POST['amp_build_nonce'])) : '';
if (!wp_verify_nonce($nonce, 'amp_build_demo')) {
wp_send_json_error(array('message' => 'Refresh the page and try again.'), 403);
}
$email = isset($_POST['email']) && is_string($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : '';
if (!is_email($email)) {
wp_send_json_error(array('message' => 'Enter a valid test email.'), 400);
}
// The example stores an acceptance record only. No email or form values persist.
$saved = wp_insert_post(array(
'post_type' => 'amp_build_request',
'post_status' => 'private',
'post_title' => 'Synthetic demo form accepted',
), true);
if (is_wp_error($saved) || !$saved) {
wp_send_json_error(array('message' => 'The request could not be saved.'), 500);
}
wp_send_json_success(array('accepted' => true));
}
add_action('admin_post_nopriv_amp_build_demo', 'amp_build_accept_demo');
add_action('admin_post_amp_build_demo', 'amp_build_accept_demo');// wp-content/plugins/amplitude-build/events.js
// Keep this handler once; experiments and guides reuse its event names.
(() => {
if (window.__ampBuildEvents) return;
window.__ampBuildEvents = true;
document.addEventListener('click', event => {
if (!(event.target instanceof Element)) return;
if (!event.target.closest('[data-analytics="pricing-cta"]')) return;
window.amplitude?.track('Pricing CTA Clicked', {
placement: 'pricing', page_path: location.pathname
});
});
document.querySelectorAll('[data-amp-demo-form]').forEach(form => {
let pending = false;
form.addEventListener('submit', async event => {
event.preventDefault();
if (pending || !form.reportValidity()) return;
pending = true;
const button = form.querySelector('[type="submit"]');
const status = form.querySelector('[data-amp-form-status]');
button.disabled = true;
status.textContent = 'Sending test request...';
try {
// WordPress's input[name="action"] shadows form.action.
const response = await fetch(form.getAttribute('action'), {
method: 'POST', body: new URLSearchParams(new FormData(form)), credentials: 'same-origin'
});
const body = await response.json();
if (!response.ok || body.success !== true || body.data?.accepted !== true) {
throw new Error('The server did not accept this request.');
}
status.textContent = 'Test request accepted.';
window.amplitude?.track('Demo Form Accepted', {
form_name: 'demo', page_path: location.pathname
});
} catch {
status.textContent = 'The request failed. Refresh and try again.';
} finally {
button.disabled = false;
pending = false;
}
});
});
})();Hand it to your agent
Point your agent at https://amplitude.com/build/wordpress-product-analytics.
Agent task: WordPress product analytics.
Preserve the shared plugin, analytics init, replay privacy, guides, and consent configuration. Add the documented shortcode and anonymous/authenticated WordPress handlers. Track Demo Form Accepted only after a successful JSON response following a saved record. Test invalid nonce, invalid email, actual persistence, duplicate script evaluation, blocked SDK, and mobile submission. Send no form field values to custom events. Report hosted and local evidence separately.What this unlocks
Product analytics measures the accepted-form outcome when Demo Form Accepted is installed.
Session replay shows the interaction around the test form after capture and masking are verified.
Web Experiment can use Demo Form Accepted as a conversion metric.
Guides and surveys can ask a visitor what they need on Pricing after targeting is configured.