formconfirmed

Developer documentation

Two steps: the widget in your form, then a server-side check of the token before you process anything. The API answers like reCAPTCHA and Turnstile, plus an action and reasons.

Quick start

1. Add the widget to your form. Replace the site key with your own from the dashboard.

<script src="https://formconfirmed.com/v1/widget.js" async defer></script>

<form method="post" action="/contact">
  ...
  <div class="fcp-widget" data-sitekey="fcp_pub_..." data-purpose="contact"></div>
  <button type="submit">Send</button>
</form>

2. Before sending or storing the submission, send the token to /v1/verify from your server. The secret key stays on the server.

<?php
$ch = curl_init('https://formconfirmed.com/v1/verify');
curl_setopt_array($ch, [
    CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 3,
    CURLOPT_POSTFIELDS => http_build_query([
        'secret'           => getenv('FCP_SECRET'),
        'response'         => $_POST['fcp-response'] ?? '',
        'remoteip'         => $_SERVER['REMOTE_ADDR'] ?? '',
        'content'          => $_POST['message'] ?? '',
        'expected_purpose' => 'contact',
    ]),
]);
$res = json_decode((string)curl_exec($ch), true);

if (!is_array($res) || !isset($res['success'], $res['action'])) {
    $decision = 'hold';     // outage: never a pass
} elseif (!$res['success']) {
    $decision = 'reject';
} else {
    $decision = $res['action'] === 'flag' ? 'hold' : 'accept';
}

POST /v1/verify

https://formconfirmed.com/v1/verify

Send the fields as form data (application/x-www-form-urlencoded) or as JSON. Use a short timeout (3 seconds).

FieldMeaning
secretrequiredYour secret key (fcp_sec_…). Never put it in the page.
responserequiredThe token from the form field fcp-response. Send it even if it is empty – do not reject a missing token yourself.
remoteipoptionalIP address of the visitor. Sharpens rate and network signals.
contentoptionalMessage text. Spam pre-filter in every plan, AI content check depending on plan and settings. Checked in memory, not stored.
emailoptionalE-mail address from the form: disposable providers and domains without mail are recognised.
hpoptionalValue of the hidden field fcp-website (honeypot).
expected_purposeoptionalThe purpose the widget was signed for, e.g. contact, login, register. A token for another purpose is rejected.
operation_idoptionalStable ID of the business action (16–128 characters) – repeated requests do not count twice. Only together with expected_purpose.
recipientoptionalRecipient of an action mail (e.g. password reset) for the per-recipient limits.
form_modeoptionalinteractive (default) or deferred for forms that are sent later.

Response

{ "success": true, "score": 0.92, "action": "allow", "reasons": [],
  "error-codes": [], "challenge_ts": "2026-11-06T09:41:12Z", "hostname": "example.com" }
successfalse if the action is block or the request is invalid.
score0.0 (bot) to 1.0 (human).
actionallow, flag or block – the recommended handling.
reasonsSignals that led to the result, e.g. too_fast, honeypot_filled.
error-codesTechnical errors, see below.
challenge_tsTime the task was solved (UTC).
hostnameHost on which the widget ran.
contentOnly with content check: category and confidence.

Handling the action

When the service cannot be reached

A timeout, network error, 5xx or invalid answer is never a pass. Hold the message marked for moderation or show an error asking to try again. Accept it normally only after an independent check of your own, for example an existing account check. In the dashboard you can set the outage behaviour per site.

Error codes

missing-input-secretNo secret key sent.
invalid-input-secretUnknown or revoked secret key.
missing-input-responseNo token sent.
invalid-input-responseToken invalid, expired or already used.
purpose-mismatchThe token was issued for another purpose.
purpose-not-allowedThis purpose is not allowed for the site (dashboard setting).
site-pausedThe site is paused in the dashboard.
service-disabledThe service is switched off temporarily.
bad-requestFields missing, too long or in the wrong format.
service-unavailableThe widget could not reach the service – treat it like an outage.

Forms sent with JavaScript

For forms sent with fetch or XMLHttpRequest, get the token first and send it in the field fcp-response:

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const data = new FormData(form);
  data.set('fcp-response', await window.__fcpWidget.token(form));
  await fetch(form.action, { method: 'POST', body: data });
});

WordPress

The formexposed plugin (version 1.8 or later) adds the widget and the server check to Contact Form 7, WPForms and comments automatically. Enter the site key and the secret key under Plugins → formexposed → Form protection.

Data protection

No cookies, nothing stored on the device, no fingerprint. Processed: the solved task, the filling time, whether the hidden field was filled and – if you send it – the message text, which is only checked in memory. IP addresses only shortened and as a daily changing hash. A text for your privacy policy is in the dashboard.