Site
A website/domain in FormBox. Sites own hooks, timezone overrides, and scoped dashboard views.
FormBox.ai docs
FormBox gives each website secure Hooks, ordered Inbox submissions, clean notifications, file handling, AI review, reports, and an API/MCP surface agents can operate without turning the dashboard into a maze.
Start here
Docs menu
Jump to any section
Quickstart
Add a Site with the website domain.
Create a Hook for the form, like Quote Request or Contact.
Set notification emails, CC emails, and allowed origins if needed.
Copy the live Hook URL and intake secret from the Hook page.
Wire HTML, fetch, or a same-origin server proxy.
Submit one safe test, then confirm the item appears in Inbox with delivery Activity.
Use this exact Hook URL shape
https://hooks.formbox.ai/f/{hookId}/{slug}. Expected success response: { "ok": true, "inboxItemId": "...", "fileCount": 0 }.Model
A website/domain in FormBox. Sites own hooks, timezone overrides, and scoped dashboard views.
A secure intake URL for one form or workflow. Hooks store notification routing, allowed origins, status, and an intake secret.
The operational queue of submissions. Each item keeps readable fields, raw payload JSON, metadata, files, delivery activity, and AI review state.
Filtered submission totals, charts by day/site/hook, failed-delivery counts, and CSV export.
Workspace defaults: timezone, email templates, AI review, users, API keys, and delivery ops.
REST/OpenAPI is the source of truth. The local MCP server wraps the same API for agent workflows.
Product boundary
Integration
Best for: Static sites and simple contact forms
Post the form directly to the Hook URL with hidden security fields.
Best for: Custom frontend submit handlers
Send JSON with payload and metadata objects.
Best for: Production forms with Turnstile/captcha
Verify bot checks on your site, then forward to FormBox with a server-side secret.
Best for: Photo, PDF, document, or text attachments
Use multipart/form-data and normal file inputs.
Security take
Example
Best fit for static sites and normal contact or quote forms. This example includes the Hook secret, honeypot, timing field, field order hint, Residential/Commercial conditional company field, and common field normalization.
<form action="https://hooks.formbox.ai/f/{hookId}/{slug}" method="POST" data-formbox-form>
<input type="hidden" name="_formbox_secret" value="fbsec_...">
<input type="text" name="_formbox_hp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">
<input type="hidden" name="_formbox_started_at" value="">
<input type="hidden" name="_formbox_field_order" value='["property_type","project_type","project_details","address","city","state","zip","company_name","name","phone","email"]'>
<fieldset>
<legend>Property Type</legend>
<label><input type="radio" name="property_type" value="Residential" checked> Residential</label>
<label><input type="radio" name="property_type" value="Commercial"> Commercial</label>
</fieldset>
<label>
Project Type
<input name="project_type" autocomplete="off" required>
</label>
<label>
Project Details
<textarea name="project_details"></textarea>
</label>
<label>
Street Address
<input name="address" autocomplete="address-line1" required>
</label>
<label>
City
<input name="city" autocomplete="address-level2" required>
</label>
<label>
State
<input name="state" autocomplete="address-level1" maxlength="2" pattern="[A-Za-z]{2}" required>
</label>
<label>
ZIP Code
<input name="zip" autocomplete="postal-code" inputmode="numeric" pattern="[0-9]{5}" maxlength="5" required>
</label>
<label data-company-field hidden>
Company Name
<input name="company_name" autocomplete="organization" disabled>
</label>
<label>
Name
<input name="name" autocomplete="name" required>
</label>
<label>
Phone
<input name="phone" type="tel" inputmode="tel" autocomplete="tel" pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}" required>
</label>
<label>
Email
<input name="email" type="email" autocomplete="email" required>
</label>
<button type="submit">Send</button>
</form>
<script>
(() => {
const form = document.currentScript && document.currentScript.previousElementSibling;
if (!(form instanceof HTMLFormElement)) return;
const companyField = form.querySelector('[data-company-field]');
const companyInput = form.querySelector('[name="company_name"]');
const propertyTypeInputs = form.querySelectorAll('[name="property_type"]');
const startedAtInput = form.querySelector('[name="_formbox_started_at"]');
if (startedAtInput instanceof HTMLInputElement) startedAtInput.value = String(Date.now());
const titleCaseWhenLowercase = (value) => {
const trimmed = value.replace(/\s+/g, ' ').trimStart();
if (!/[a-z]/.test(trimmed) || trimmed !== trimmed.toLowerCase()) return trimmed;
return trimmed.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
};
const collapseRepeatedAddress = (value) => {
const normalized = String(value || '').trim().replace(/\s*,\s*/g, ', ').replace(/\s+/g, ' ');
const parts = normalized.split(',').map((part) => part.trim()).filter(Boolean);
if (parts.length === 2 && parts[0].toLowerCase() === parts[1].toLowerCase()) return parts[0];
return normalized;
};
const formatPhone = (value) => {
const digits = value.replace(/\D/g, '').slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return digits.slice(0, 3) + '-' + digits.slice(3);
return digits.slice(0, 3) + '-' + digits.slice(3, 6) + '-' + digits.slice(6);
};
const syncCompanyField = () => {
const checked = form.querySelector('[name="property_type"]:checked');
const isCommercial = checked && checked.value === 'Commercial';
if (companyField instanceof HTMLElement) companyField.hidden = !isCommercial;
if (companyInput instanceof HTMLInputElement) {
companyInput.disabled = !isCommercial;
companyInput.required = Boolean(isCommercial);
if (!isCommercial) companyInput.value = '';
}
};
const normalizeField = (input) => {
if (['name', 'company_name', 'address', 'city'].includes(input.name)) input.value = titleCaseWhenLowercase(input.name === 'address' ? collapseRepeatedAddress(input.value) : input.value);
if (input.name === 'email') input.value = input.value.trim().toLowerCase();
if (input.name === 'phone') input.value = formatPhone(input.value);
if (input.name === 'state') input.value = input.value.replace(/[^a-z]/gi, '').slice(0, 2).toUpperCase();
if (input.name === 'zip') input.value = input.value.replace(/\D/g, '').slice(0, 5);
if (input instanceof HTMLTextAreaElement) input.value = input.value.replace(/\s+/g, ' ').trim();
};
propertyTypeInputs.forEach((input) => input.addEventListener('change', syncCompanyField));
form.addEventListener('input', (event) => {
const input = event.target;
if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)) return;
normalizeField(input);
});
form.addEventListener('submit', () => {
form.querySelectorAll('input:not([type="hidden"]):not([type="radio"]), textarea').forEach((field) => {
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) normalizeField(field);
});
});
syncCompanyField();
})();
</script>Agent integration brief
X-FormBox-Secret or hidden field _formbox_secret. Keep _formbox_hp empty and include _formbox_started_at for bot checks. Normalize and validate common contact fields in the website form or same-origin server/function before posting.Example
Best fit when the site already intercepts submits with JavaScript. Send business fields inside payload and source/tracking context inside metadata.
await fetch("https://hooks.formbox.ai/f/{hookId}/{slug}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-FormBox-Secret": "fbsec_...",
},
body: JSON.stringify({
payload: {
property_type: "Residential",
project_type: "Driveway gate",
project_details: "Hello from FormBox",
name: "Test Lead",
phone: "801-555-0100",
email: "test@example.com",
},
metadata: {
fieldOrder: ["property_type", "project_type", "project_details", "name", "phone", "email"],
pageUrl: "https://demo-site.com/quote?utm_source=google&utm_medium=cpc",
landingPageUrl: "https://demo-site.com/?utm_source=google&utm_medium=cpc&gclid=test123",
referrer: "https://www.google.com/",
userAgent: navigator.userAgent,
utm: {
source: "google",
medium: "cpc",
campaign: "spring-gates",
},
clickIds: {
gclid: "test123",
},
},
}),
});Example
This is the safest production pattern for client sites: browser form to same-origin endpoint, validate captcha/Turnstile and required fields there, then forward to FormBox with the server-side Hook secret.
// Browser -> /api/quote on the same website -> FormBox
export async function POST(request) {
const body = await request.json();
// Verify Turnstile/captcha here when the website uses one.
// Normalize fields before forwarding: phone, email, state, ZIP, etc.
const response = await fetch(process.env.FORMBOX_INTAKE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-FormBox-Secret": process.env.FORMBOX_HOOK_SECRET,
},
body: JSON.stringify({
payload: {
property_type: body.property_type,
project_details: body.project_details,
name: body.name,
phone: body.phone,
email: body.email,
},
metadata: {
fieldOrder: ["property_type", "project_details", "name", "phone", "email"],
pageUrl: body._formbox_page_url,
landingPageUrl: body._formbox_landing_page_url,
referrer: body._formbox_referrer,
userAgent: request.headers.get("user-agent") || body._formbox_user_agent,
utm: {
source: body._formbox_utm_source,
medium: body._formbox_utm_medium,
campaign: body._formbox_utm_campaign,
term: body._formbox_utm_term,
content: body._formbox_utm_content,
},
clickIds: {
gclid: body._formbox_gclid,
gbraid: body._formbox_gbraid,
wbraid: body._formbox_wbraid,
fbclid: body._formbox_fbclid,
msclkid: body._formbox_msclkid,
},
},
}),
});
if (!response.ok) return Response.json({ ok: false }, { status: 502 });
return Response.json({ ok: true });
}Fail closed on website protection
Example
Use multipart/form-data for files. FormBox stores private file objects, shows files on submission detail, and includes token-protected file links in notification emails.
| Max files | 5 files per submission |
|---|---|
| Max size | 10MB per file |
| Allowed types | JPG, JPEG, PNG, WEBP, HEIC, HEIF, PDF, DOC, DOCX, and TXT |
| Downloads | Dashboard downloads require session/org access. Email links use signed FormBox routes and private storage redirects. |
<form action="https://hooks.formbox.ai/f/{hookId}/{slug}" method="POST" enctype="multipart/form-data">
<input type="hidden" name="_formbox_secret" value="fbsec_...">
<input type="hidden" name="_formbox_field_order" value='["name","phone","project_details","photos"]'>
<input name="name" required>
<input name="phone" type="tel" required>
<textarea name="project_details"></textarea>
<input name="photos" type="file" accept="image/*,.pdf,.doc,.docx,.txt" multiple>
<button type="submit">Send</button>
</form>Data model
Payload is what the visitor submitted. Metadata is context about how the submission arrived. Keeping this split clean is why client notification emails stay readable instead of turning into tracking soup.
Do not send tracking as client-facing fields
metadata or supported _formbox_* fields. FormBox strips those from visible payload fields and uses them for internal metadata, reporting, and Global BCC email context.Attribution
Paid traffic usually lands on the homepage or a service page, then the visitor clicks to a quote form later. Capture first-touch source data on every page, store it with first-party browser storage, and inject reserved _formbox_* fields at submit time.
// Run on every page, before the visitor reaches the form.
const ATTRIBUTION_KEY = "formbox_attribution_v1";
const TTL_MS = 90 * 24 * 60 * 60 * 1000;
const params = new URLSearchParams(window.location.search);
const stored = JSON.parse(localStorage.getItem(ATTRIBUTION_KEY) || "null");
const fresh = !stored || Date.now() - stored.createdAt > TTL_MS;
const hasSource = ["utm_source", "utm_medium", "utm_campaign", "gclid", "gbraid", "wbraid", "fbclid", "msclkid"].some((key) => params.get(key));
if (fresh || (!stored?.hasClickId && hasSource)) {
const attribution = {
createdAt: Date.now(),
landingPageUrl: window.location.href,
referrer: document.referrer,
hasClickId: Boolean(params.get("gclid") || params.get("gbraid") || params.get("wbraid") || params.get("fbclid") || params.get("msclkid")),
utm: {
source: params.get("utm_source"),
medium: params.get("utm_medium"),
campaign: params.get("utm_campaign"),
term: params.get("utm_term"),
content: params.get("utm_content"),
},
clickIds: {
gclid: params.get("gclid"),
gbraid: params.get("gbraid"),
wbraid: params.get("wbraid"),
fbclid: params.get("fbclid"),
msclkid: params.get("msclkid"),
},
};
localStorage.setItem(ATTRIBUTION_KEY, JSON.stringify(attribution));
sessionStorage.setItem(ATTRIBUTION_KEY, JSON.stringify(attribution));
}
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) return;
const attribution = JSON.parse(sessionStorage.getItem(ATTRIBUTION_KEY) || localStorage.getItem(ATTRIBUTION_KEY) || "{}");
const setHidden = (name, value) => {
if (!value) return;
let input = form.querySelector('input[name="' + name + '"]');
if (!input) {
input = document.createElement("input");
input.type = "hidden";
input.name = name;
form.appendChild(input);
}
input.value = String(value);
};
setHidden("_formbox_page_url", window.location.href);
setHidden("_formbox_landing_page_url", attribution.landingPageUrl);
setHidden("_formbox_referrer", attribution.referrer || document.referrer);
setHidden("_formbox_user_agent", navigator.userAgent);
setHidden("_formbox_utm_source", attribution.utm?.source);
setHidden("_formbox_utm_medium", attribution.utm?.medium);
setHidden("_formbox_utm_campaign", attribution.utm?.campaign);
setHidden("_formbox_utm_term", attribution.utm?.term);
setHidden("_formbox_utm_content", attribution.utm?.content);
setHidden("_formbox_gclid", attribution.clickIds?.gclid);
setHidden("_formbox_gbraid", attribution.clickIds?.gbraid);
setHidden("_formbox_wbraid", attribution.clickIds?.wbraid);
setHidden("_formbox_fbclid", attribution.clickIds?.fbclid);
setHidden("_formbox_msclkid", attribution.clickIds?.msclkid);
}, true);Global BCC emails use this metadata
includeMetadataForGlobalBcc is enabled.Display order
Send the visible business-field order every time an agent builds or reorders a form. FormBox stores it as metadata, removes it from visible payloads, and uses it for notification emails, dashboard Submitted fields, API payloadFields, and CSV exports.
<input type="hidden" name="_formbox_field_order" value='["property_type","project_details","name","phone","email"]'>{
"payload": {
"property_type": "Residential",
"project_details": "Gate repair",
"name": "Test Lead",
"phone": "801-555-0100",
"email": "test@example.com"
},
"metadata": {
"fieldOrder": ["property_type", "project_details", "name", "phone", "email"]
}
}Agents
Do not blindly add every standard field. Choose fields from the form purpose, site context, and lead-quality needs.
Dashboard
This is the current app surface area. If it exists in the dashboard, it belongs here — no mystery buttons, no tribal knowledge tax.
| Inbox | Search/filter submissions by date, site, hook, delivery status, files, and payload text. Detail pages show submitted fields, raw JSON, metadata, files, activity, AI review, and owner-only permanent delete. |
|---|---|
| Sites | Create sites by domain, filter website list, set site timezone, create connected hooks, and review recent submissions for that site. |
| Hooks | Copy the live Hook URL and intake secret, send a test inbox item, pause/reactivate, set notification and CC emails, set allowed origins, and review latest submissions. |
| Reports | Run filtered reports across all time, date presets, custom ranges, sites, and hooks. Export CSV with ordered payload fields. |
| Settings / General | Set the account timezone used when a site does not have its own timezone override. |
| Settings / Email | Configure Global BCC copy emails, metadata on Global BCC copies, table/plain text format, subject and sender templates, and reference numbers. |
| Settings / AI Review | Enable spam review, choose label-only or block high-confidence spam emails, and set the spam threshold. |
| Settings / Users | Owner-only user creation, role changes, and non-owner user removal. Roles are owner, manager, and viewer. |
| Settings / API | Owner-only API key creation, reveal/copy, revoke/reinstate, and delete. Presets include Agent — Read/Write, Agent — Read Only, and Admin. |
| Settings / Ops | Focused failed/stuck delivery review for operational cleanup. |
| Account | Update user profile name, agency workspace name, and see the current role/preferences. |
Roles
Routing
Delivery model
Automation
Use API keys for agents and automation. Send the key as a bearer token.
Authorization: Bearer fb_...curl -X POST https://app.formbox.ai/api/v1/provision-site-intake -H "Authorization: Bearer fb_..." -H "Content-Type: application/json" -d '{
"domain": "demo-site.com",
"forms": [
{
"name": "Quote Request",
"notificationEmail": "leads@demo-site.com",
"notificationCcEmails": ["owner@demo-site.com"],
"allowedOrigins": ["https://demo-site.com", "https://www.demo-site.com"]
}
]
}'| Sites | GET/POST /api/v1/sites | GET/PATCH/DELETE /api/v1/sites/{siteId} |
|---|---|---|
| Hooks | GET/POST /api/v1/hooks | GET/PATCH/DELETE /api/v1/hooks/{hookId}; POST /api/v1/hooks/{hookId}/rotate-secret |
| Inbox | GET /api/v1/inbox | GET /api/v1/inbox/{inboxItemId}; files, downloads, deliveries, and retry routes |
| Settings | GET/PUT /api/v1/settings/email | API keys, users, and ops delivery routes under /api/v1/settings and /api/v1/ops |
| Provisioning | POST /api/v1/provision-site-intake | Creates a site plus one or more hooks in one agent workflow. |
| Schema | GET /api/v1/openapi.json | Full machine-readable API reference. |
| Agent — Read/Write | sites:*, hooks:*, inbox:*, settings:*, api_keys:*, users:*, ops:*, provision:write |
|---|---|
| Agent — Read Only | sites:read, hooks:read, inbox:read, settings:read, api_keys:read, users:read, ops:read |
| Admin | * |
The local stdio MCP wrapper exposes these workflow tools and maps them to REST:
list_sitescreate_siteget_siteupdate_sitearchive_sitelist_hookscreate_hookget_hookupdate_hookarchive_hookrotate_hook_secretlist_inboxget_inbox_itemlist_inbox_filesget_inbox_file_download_urllist_inbox_deliveriesretry_inbox_deliveryget_email_settingsupdate_email_settingslist_api_keyscreate_api_keyrevoke_api_keydelete_api_keylist_userscreate_userupdate_user_roleremove_userlist_ops_deliveriesprovision_site_intakeVerification
curl -X POST https://hooks.formbox.ai/f/{hookId}/{slug} -H "Content-Type: application/json" -H "X-FormBox-Secret: fbsec_..." -d '{"payload":{"name":"Test Lead","email":"test@example.com","message":"Hello from FormBox"},"metadata":{"fieldOrder":["name","email","message"]}}'Help
| 403 invalid_secret | The Hook secret is missing or wrong. | Send X-FormBox-Secret from a server, or _formbox_secret in direct HTML form mode. |
|---|---|---|
| 403 origin_not_allowed | The browser Origin is not in the Hook allowed origins list. | Add the production domain and preview domains to allowed origins, or leave the list empty intentionally. |
| 400 too_fast or honeypot errors | The bot trap fields look suspicious. | Keep _formbox_hp empty and set _formbox_started_at when the form loads. |
| 409 hook_paused | The Hook is paused. | Reactivate the Hook from Hook settings or PATCH the Hook status through the API. |
| 429 rate_limit_exceeded | Too many submissions hit the same Hook/IP window. | Wait for the Retry-After value or reduce automated tests. |
| Fields appear in the wrong order | No field-order hint was sent, or it does not match the visible form. | Send _formbox_field_order or metadata.fieldOrder with business field keys in display order. |
| Metadata appears as client-facing fields | Tracking/source values were sent as payload fields. | Move page URL, referrer, user agent, UTMs, and click IDs into metadata or _formbox_* fields. |
| Attribution is missing after navigation | The site only captured UTMs on the form page, but the visitor landed elsewhere first. | Capture first-touch attribution on every page with first-party storage, then inject _formbox_* fields before submit. |
| Submission stores but no email arrives | No notification recipient, delivery failure, or email provider delay. | Check the Hook notification emails, the submission Activity card, and Settings / Ops. |
| File upload rejected | File count, size, type, or storage config failed validation. | Use up to 5 files, 10MB each, allowed image/PDF/doc/text types, and verify storage is configured. |
| AI review is pending | Classifier is disabled, still running, or timed out. | Use label-only as the safe default and manually run AI review from the submission detail if needed. |
When in doubt, inspect the Inbox item