Skip to main content

Website Forms

Website Forms turn a form on your own website (contact form, landing page, event signup, waitlist, job application) into leads in ABC Sales AI. The visitor's browser posts the answers straight to ABC Sales AI, with no backend of your own in between:

POST https://api.abcsalesbot.com/public/forms/submit
This is not the API key lane

Website Forms authenticate with a form key (pfk_...) and the page's Origin, not with your company API key. A form key is public by design: it ships in your page's JavaScript and can only create or update leads through the one form it belongs to. It cannot read anything, and it is not accepted by any /v1 endpoint.

If the submission reaches your server first (a CRM sync, a backend form handler, a Zapier step), use POST /v1/webhooks/leads with your API key instead. It accepts tags, automations and custom fields per request and doesn't depend on a browser Origin.

How it works​

  1. An Owner or Admin creates the form in the app under Settings > Website Forms > New form. Saving it creates a form key and the rules for that form: allowed websites, field matching, tags, automation, spam protection, and AI settings.
  2. You (the developer) add the form key to the website and send the visitor's answers to POST /public/forms/submit.
  3. ABC Sales AI checks the form key and the page's Origin, optionally verifies a Cloudflare Turnstile token, maps the answers onto lead fields, and creates the lead, or updates the visitor's existing one.
  4. Whatever follow-up the form is configured for starts from there: the form's automation, AI replies, and your own webhooks.

Everything about what happens to the lead lives on the form in the app, not in your request. The request only carries the answers.

What you need from the account owner​

Ask whoever set the form up for:

ItemWhere it comes fromNotes
Form keyCopy form key on the Website Forms listStarts with pfk_. Safe to put in page source.
Allowed websitesThe form's Allowed websitesThe exact origins the form will be submitted from, e.g. https://example.com.
Field matchingThe form's Field matchingThe field names your form must send (e.g. full_name, email, mobile) and where each is saved.
Turnstile site keyThe form's Advanced settings > Spam protectionOnly if Cloudflare Turnstile is turned on. The site key is public. The secret key stays in ABC Sales AI and is never needed in your code.
The form key is not a Cloudflare key

The ABC Sales AI form key (pfk_...) and the Cloudflare Turnstile keys are unrelated. Don't put the form key into either Turnstile field, and never put the Turnstile secret key in website code.

Submitting a form​

Request​

PartValue
Method and pathPOST /public/forms/submit
X-Form-Key headerThe form key. Required.
Origin headerSent by the browser automatically. Must match one of the form's allowed websites.
Content-Type headerapplication/json
Body{"fields": {...}, "captcha_token": "..."}
Body fieldRequiredNotes
fieldsYesObject keyed by your form's own field names, exactly as set up in Field matching.
captcha_tokenOnly with TurnstileThe token from the Turnstile widget. See Spam protection.

Example​

<form id="contact-form">
<input name="full_name" required />
<input name="email" type="email" />
<input name="mobile" type="tel" required />
<textarea name="message"></textarea>
<button type="submit">Send</button>
</form>

<script>
const FORM_KEY = 'pfk_your_form_key';

document.getElementById('contact-form').addEventListener('submit', async (event) => {
event.preventDefault();
const form = event.currentTarget;
const fields = Object.fromEntries(new FormData(form)); // { full_name, email, mobile, message }

const response = await fetch('https://api.abcsalesbot.com/public/forms/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Form-Key': FORM_KEY,
},
body: JSON.stringify({ fields }),
});

if (response.status === 201) {
form.reset();
// show your thank-you message
} else {
// show an error and let the visitor try again; see "Errors" below
}
});
</script>

For this form, Field matching in the app would map full_name to Name, email to Email, mobile to Phone, and message to a text custom field.

Response​

A successful submission returns 201 Created:

{"data": {"submitted": true}}

The response deliberately says nothing else about the lead (no ID, no fields), because anyone can call this endpoint. To act on the lead from your systems, subscribe to the leads_created and leads_updated webhook events or poll leads with your API key.

Unlike POST /v1/webhooks/leads, this endpoint is synchronous: by the time you get 201, the lead exists.

Field matching​

Field matching is the bridge between your HTML and the lead. Each row maps one of your form's field names to where its value is saved:

  • Name, Email, or Phone, the lead's built-in fields, or
  • a custom field configured on the account.

Rules to code against:

  • Send the names exactly as mapped. Matching is exact: Email and email are different fields.
  • Unmapped fields are ignored, not rejected. Sending extra fields is harmless, and a field the owner hasn't mapped yet is dropped silently. If a value is missing from the lead, check the mapping first.
  • Name, email and phone must be strings. A non-string value for one of them is treated as empty. They are limited to 255, 255 and 50 characters respectively; longer values reject the submission with 400.
  • Custom field values must match the field's type (a number for a number field, and so on). A value of the wrong type returns 400 and rejects the whole submission, so nothing is saved. Coerce types in your code before sending.
  • Phone numbers are never rejected for format. Send them in international format (+60123456789) where you can. A number that can't be parsed is kept as typed and the lead is tagged for review, so a typo never loses a lead.
  • Send at least a phone or an email. They are what repeat submissions are matched on.

Tags, the lead source, and the automation are configured on the form and can't be set from the request body.

Spam protection with Cloudflare Turnstile​

Forms are unprotected by default, which means a bot can submit them. That matters most when the form runs an automation: fake submissions carrying other people's phone numbers would start messaging those people. Turnstile is optional, but recommended for any form with an automation.

When the owner turns on Cloudflare Turnstile for the form, every submission must include a valid captcha_token, and ABC Sales AI verifies it with Cloudflare before saving anything. A missing or invalid token returns 401.

  1. Create a Turnstile widget in your Cloudflare dashboard for the same hostname as the form (see Cloudflare's Turnstile get started guide). The owner pastes its site key and secret key into the form's settings.

  2. Render the widget in your form with the site key:

    <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

    <form id="contact-form">
    <!-- your inputs -->
    <div class="cf-turnstile" data-sitekey="0x4AAAAAAA_your_site_key"></div>
    <button type="submit">Send</button>
    </form>
  3. Send the widget's token as captcha_token, and keep it out of fields. Inside a <form>, the widget adds a hidden cf-turnstile-response input holding the token:

    const data = Object.fromEntries(new FormData(form));
    const captcha_token = data['cf-turnstile-response'];
    delete data['cf-turnstile-response'];

    const response = await fetch('https://api.abcsalesbot.com/public/forms/submit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Form-Key': FORM_KEY },
    body: JSON.stringify({ fields: data, captcha_token }),
    });

    if (response.status !== 201) {
    turnstile.reset(); // tokens are single-use; get a fresh one before retrying
    }

A Turnstile token can be verified only once. Reset the widget after any failed submission, otherwise the retry is rejected too.

Uploading files​

If Field matching maps a field to a custom field of type file (a resume, a video, a photo), the form uploads the file first and sends back a reference to it, not the file name. This is the same presign, upload, confirm flow as Attachments, authenticated with the form key instead of the API key. Every step sends X-Form-Key, and the browser sends Origin.

1. Request a presigned URL​

const presign = await fetch('https://api.abcsalesbot.com/public/forms/upload-url', {
headers: { 'X-Form-Key': FORM_KEY },
}).then((r) => r.json());
// presign.data.presigned_url, presign.data.max_size_bytes, presign.data.allowed_content_types

max_size_bytes (500 MB by default) and allowed_content_types (MP4, MOV, WebM, PDF, DOC, DOCX, JPG, PNG, WebP) are the policy step 3 enforces. Check them in the browser first to give the visitor a quick error.

2. Upload the file​

await fetch(presign.data.presigned_url, { method: 'PUT', body: file });

The presigned URL is short-lived. Request it when the visitor submits, not when the page loads.

3. Confirm the upload​

const confirmed = await fetch('https://api.abcsalesbot.com/public/forms/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Form-Key': FORM_KEY },
body: JSON.stringify({ file_url: presign.data.presigned_url, file_name: file.name }),
}).then((r) => r.json());
// {"data": {"s3_path": "attachments-uploads/..."}}

Both file_url and file_name are required. Confirming checks the uploaded bytes themselves (not the Content-Type the browser declared) against the size and type policy. A file that is empty, too large, or of a type that isn't allowed returns 400 and is deleted from storage, so start again from step 1.

4. Submit with the reference​

Put data.s3_path in the file field of your submission:

{
"fields": {
"full_name": "Jane Doe",
"mobile": "+60123456789",
"resume": "attachments-uploads/..."
}
}

A file field whose value isn't the s3_path of a file confirmed for this account (for example, the file name) returns 400 and rejects the submission.

Repeat submissions​

One person often fills in several forms, or the same form twice. A submission lands on that person's existing lead in the account instead of creating a duplicate. The lead is matched by phone first (in any format) and then by email (ignoring case), whatever created it: another website form, a messaging channel, an import, or the API.

When a submission matches an existing lead:

What happens
TagsThe form's tags are added. Existing tags are kept.
Custom fieldsThe fields this submission sends are updated. Other custom fields are left alone.
NameFilled in only if the lead has none. An existing name is never overwritten.
Email and phoneNever changed, not even filled in when empty.
Lead sourceBecomes this form's lead source.
AI settingsHandled by AI and Scheduled Messages keep the lead's current values. The form's settings apply only to leads it creates, so a person's earlier opt-out is never undone.
AutomationThe lead is enrolled in the form's automation, unless it unsubscribed from that automation before, in which case it stays unsubscribed.
Webhooksleads_updated fires (a new lead fires leads_created).

These limits exist because the form key is public: whoever submits the form can't use it to rename someone or plant a new contact route on their lead.

What the form controls​

Settings on the form in the app decide what happens to every lead it creates. None of them can be overridden per request.

SettingEffect
Lead sourceThe lead's source. Left blank, it defaults to public_form:<form id>. Use a stable, descriptive value (e.g. Contact Form) so the leads are easy to filter on the Leads page or with GET /v1/leads?source=.
Automation to runThe one automation each lead is enrolled in, or none. A form never enrolls leads in any other automation.
TagsUp to 10 tags added to every lead from this form.
Handled by AIWhether the AI Employee replies to leads this form creates. On by default. Turned off, the leads wait in the Inbox for a person.
Scheduled MessagesWhether follow-up messages are scheduled automatically for leads this form creates. On by default.

Turning on Handled by AI or Scheduled Messages doesn't send a welcome message by itself. Any first message comes from the automation.

Allowed websites​

The form only accepts submissions whose Origin header matches one of its allowed websites. The comparison is on the origin only (scheme, host and port), ignores case, and is otherwise exact:

  • https://example.com and https://www.example.com are different websites. Add both if the form is served from both.
  • Ports count: http://localhost:3000 and http://localhost:5173 are different. Add your local and staging origins too, if you test there.
  • Paths don't matter: https://example.com/contact is saved as https://example.com.
  • * allows any website. That makes the Origin check meaningless, so use it only for testing.

A request without an Origin header is always rejected, even with *. Browsers send it on these cross-origin requests automatically. To test from a terminal, set it yourself:

curl -X POST "https://api.abcsalesbot.com/public/forms/submit" \
-H "Content-Type: application/json" \
-H "X-Form-Key: pfk_your_form_key" \
-H "Origin: https://example.com" \
-d '{"fields": {"full_name": "Test Lead", "mobile": "+60123456789"}}'

CORS preflight succeeds from any origin, so a disallowed website gets a readable 401 instead of a CORS error. The Origin check is what blocks it.

Errors​

Errors use the standard error shape. Messages are localized and may change, so branch on the status code.

StatusCause
201 CreatedSubmitted. The lead was created or updated.
400 Bad RequestThe body isn't valid JSON or has no fields; name, email or phone is too long; a custom-field value has the wrong type; or a file field doesn't reference a confirmed upload. Nothing is saved.
401 UnauthorizedThe form key is wrong, deleted or deactivated; the Origin is missing or not an allowed website; or the Turnstile token is missing or invalid.
413 Payload Too LargeThe body is over 1 MB. Files go through the upload flow, not the submission body.
429 Too Many RequestsRate limit reached. See below.
500 Internal Server ErrorSomething went wrong on our side. Safe to retry.

A deactivated or deleted form key returns the same 401 as a key that never existed.

Rate limits​

EndpointLimit
POST /public/forms/submit20 requests per 10 minutes
GET and POST /public/forms/upload-url60 requests per 10 minutes, shared between the two

Limits are counted per account and per visitor IP, so one busy visitor can't use up the form for everyone else. Visitors behind one shared IP (an office or a campus network) share a budget. A 429 carries Retry-After, as described in Rate Limits. Show the visitor a "please try again in a few minutes" message rather than retrying automatically.

Testing checklist​

Before going live:

  1. Submit a test entry from the real page, in a private window, using a phone number and email that aren't already leads.
  2. Confirm in the app that the lead appears in the right account, with the right name, contact details, custom fields, lead source and tags.
  3. Submit again with the same phone number and check that the same lead was updated instead of a new one appearing.
  4. If the form runs an automation or has AI on, check that the follow-up you expect actually started.
  5. If Turnstile is on, check that a submission without a token is rejected, and that a retry after a failure works (widget reset).

A thank-you page alone is not proof that the lead was saved. Check the 201 in your code and the lead in the app.