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
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
- 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.
- You (the developer) add the form key to the website and send the visitor's answers to
POST /public/forms/submit. - 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.
- 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:
| Item | Where it comes from | Notes |
|---|---|---|
| Form key | Copy form key on the Website Forms list | Starts with pfk_. Safe to put in page source. |
| Allowed websites | The form's Allowed websites | The exact origins the form will be submitted from, e.g. https://example.com. |
| Field matching | The form's Field matching | The field names your form must send (e.g. full_name, email, mobile) and where each is saved. |
| Turnstile site key | The form's Advanced settings > Spam protection | Only 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 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
| Part | Value |
|---|---|
| Method and path | POST /public/forms/submit |
X-Form-Key header | The form key. Required. |
Origin header | Sent by the browser automatically. Must match one of the form's allowed websites. |
Content-Type header | application/json |
| Body | {"fields": {...}, "captcha_token": "..."} |
| Body field | Required | Notes |
|---|---|---|
fields | Yes | Object keyed by your form's own field names, exactly as set up in Field matching. |
captcha_token | Only with Turnstile | The 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:
Emailandemailare 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
400and 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.
-
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.
-
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> -
Send the widget's token as
captcha_token, and keep it out offields. Inside a<form>, the widget adds a hiddencf-turnstile-responseinput 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 | |
|---|---|
| Tags | The form's tags are added. Existing tags are kept. |
| Custom fields | The fields this submission sends are updated. Other custom fields are left alone. |
| Name | Filled in only if the lead has none. An existing name is never overwritten. |
| Email and phone | Never changed, not even filled in when empty. |
| Lead source | Becomes this form's lead source. |
| AI settings | Handled 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. |
| Automation | The lead is enrolled in the form's automation, unless it unsubscribed from that automation before, in which case it stays unsubscribed. |
| Webhooks | leads_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.
| Setting | Effect |
|---|---|
| Lead source | The 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 run | The one automation each lead is enrolled in, or none. A form never enrolls leads in any other automation. |
| Tags | Up to 10 tags added to every lead from this form. |
| Handled by AI | Whether the AI Employee replies to leads this form creates. On by default. Turned off, the leads wait in the Inbox for a person. |
| Scheduled Messages | Whether 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.comandhttps://www.example.comare different websites. Add both if the form is served from both.- Ports count:
http://localhost:3000andhttp://localhost:5173are different. Add your local and staging origins too, if you test there. - Paths don't matter:
https://example.com/contactis saved ashttps://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.
| Status | Cause |
|---|---|
201 Created | Submitted. The lead was created or updated. |
400 Bad Request | The 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 Unauthorized | The 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 Large | The body is over 1 MB. Files go through the upload flow, not the submission body. |
429 Too Many Requests | Rate limit reached. See below. |
500 Internal Server Error | Something 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
| Endpoint | Limit |
|---|---|
POST /public/forms/submit | 20 requests per 10 minutes |
GET and POST /public/forms/upload-url | 60 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:
- Submit a test entry from the real page, in a private window, using a phone number and email that aren't already leads.
- Confirm in the app that the lead appears in the right account, with the right name, contact details, custom fields, lead source and tags.
- Submit again with the same phone number and check that the same lead was updated instead of a new one appearing.
- If the form runs an automation or has AI on, check that the follow-up you expect actually started.
- 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.