Forms
Gordon CRM includes a headless form submission API that lets you capture leads from any website or application. The API uses a keyless pipeline for browser-based submissions and API key authentication for server-to-server integrations.
How It Works
Each form in Gordon CRM has a unique submission endpoint:
POST https://app.gordoncrm.com/api/forms/\{FORM_ID\}The API accepts both JSON (application/json) and form-encoded (application/x-www-form-urlencoded)
request bodies. Every submission passes through a multi-step security pipeline before creating
or updating a contact.
Form Settings
Each form has the following configurable properties:
| Field | Type | Description |
|---|---|---|
name | text | Display name for the form (e.g. "Contact Us", "Newsletter Signup"). |
allowed_domains | text[] | List of hostnames allowed to submit via browser. Empty = open mode. |
api_key | text | Secret key for server-to-server authentication. |
success_redirect_url | text | Optional URL to redirect the user after submission (HTML embed forms). |
Accepted Submission Fields
| Field | Required | Description |
|---|---|---|
email | ✅ | The contact's email address. Used as the unique identifier per workspace. |
first_name | ❌ | Contact first name. |
last_name | ❌ | Contact last name. |
phone | ❌ | Contact phone number. |
is_subscribed | ❌ | Marketing consent flag. Accepts true, "true", or "on". See Subscription Consent. |
opt_in_source | ❌ | Freeform label for consent origin (defaults to the form ID if omitted). |
notes / message | ❌ | If present, automatically attached as a contact note. See Contact Notes. |
website_url | ❌ | Honeypot field. Must be included in browser forms but left empty. See Honeypot. |
Security Pipeline
Every submission passes through these checks in order. If any check fails, the request is rejected before reaching the next step.
| Step | Check | Failure Response |
|---|---|---|
| 1 | Form lookup — Verify the form ID exists | 404 Form not found |
| 2 | Rate limiting — IP-based throttle (5 requests per 60 seconds per form+IP) | 429 Too many requests |
| 3 | Authentication — API key header OR domain whitelist (see below) | 403 Origin not allowed |
| 4 | Body parsing — JSON or form-encoded | 400 Could not parse request body |
| 5 | Honeypot — If website_url is populated, silently drop (return 200 to fool bots) | Silent drop |
| 6 | Email validation — email field must be present and non-empty | 400 email is required |
| 7 | Double opt-in — Context-aware verification for existing contacts | See Double Opt-In |
Authentication
The API supports two authentication methods depending on where the submission originates:
Browser Submissions (Domain Whitelist)
For websites and SPAs, the API validates the request's Origin or Referer header against
the form's allowed_domains list. No API key is needed.
- Exact match —
yourwebsite.commatches requests fromyourwebsite.com - Wildcard match —
*.yourwebsite.commatcheswww.yourwebsite.com,blog.yourwebsite.com, etc.
Open Mode: If the
allowed_domainslist is empty, the form accepts submissions from any origin. This is convenient for development and testing, but should be locked down before going live.
Server-to-Server (API Key)
For backend services, Zapier, Make.com, and mobile apps, include the form's secret API key
in the x-api-key header. This bypasses the domain whitelist check entirely.
Security: Never expose the API key in client-side code. It is strictly for backend use.
See the HTML Embed, React / SPA, and Server / API Key integration guides for implementation examples.
Honeypot Spam Protection
Every browser-facing form should include a hidden website_url input field. This field is
invisible to real users but gets filled out by automated spam bots:
<div style="position:absolute;left:-9999px" aria-hidden="true">
<input type="text" name="website_url" tabindex="-1" autocomplete="off" />
</div>If the API receives a non-empty website_url value, it silently drops the submission and
returns 200 OK to fool the bot. The bot's IP is still recorded by the rate limiter.
Do not remove this field from browser forms, or your form will be vulnerable to spam. Server-to-server submissions (with API key) do not need this field.
Rate Limiting
The API enforces IP-based rate limiting per form:
| Parameter | Value |
|---|---|
| Window | 60 seconds |
| Max requests per IP per form | 5 |
| Over-limit response | 429 Too many requests |
Rate limit state is stored in a form_rate_limits table and managed by a Postgres RPC function.
On RPC errors, the system fails open to avoid blocking legitimate submissions.
Context-Aware Double Opt-In
When a form submission is received, the API checks whether the email already exists as a contact in the workspace. The behavior differs based on this check:
New Contact (Email Not Found)
The submission is processed immediately:
- A new contact is created (or upserted) with the submitted data.
- The submission is logged in
form_submissions. - If
notesormessageis present, a contact note is attached. - Automations are fired (see Automation Integration).
- The user is redirected to the success URL (or receives a JSON
200response).
Existing Contact (Email Already Exists)
The submission is staged for verification:
- The payload is stored in
pending_form_submissionswith a cryptographic token (24-hour expiry). - A verification email is sent to the contact's email address with a "Verify Submission" button.
- The user sees a success response immediately (to prevent email enumeration).
- When the contact clicks the verification link (
/api/forms/verify?token=...), the staged submission is processed using the same flow as new contacts.
Why? This prevents impersonation attacks where someone submits a form using another person's email address to overwrite their contact data (name, phone, etc.).
If the verification link expires (after 24 hours) or has already been used, the user sees a friendly error page.
Subscription Consent
Forms can collect explicit marketing consent for email compliance (CAN-SPAM, GDPR). When the
is_subscribed field is present and truthy (true, "true", or "on"):
is_subscribedis set totrueon the contact record.opt_in_timestampis recorded with the current time.opt_in_sourceis set to the provided value (or defaults to the form ID).opt_in_ipis recorded from the submitter's IP address.- Any prior
unsubscribed_attimestamp is cleared. - Non-complaint suppressions (bounce, unsubscribe, manual) are cleared.
Complaint suppressions are never cleared. If a contact previously filed a spam complaint, re-subscribing via a form will not remove the complaint suppression. This is a deliberate compliance safeguard.
Contact Notes from Submissions
If a form submission includes a notes or message field, the content is automatically
attached as a note on the contact record. The note is formatted as:
Message added via '{form name}' form submission:
{content of the notes/message field}This allows contact forms, inquiry forms, and similar use cases to capture freeform messages directly into the CRM timeline without any additional configuration.
Automation Integration
Form submissions fire the form_submission trigger in the Automations engine.
This allows you to create rules like:
- When "Contact Form" is submitted → Add tag "New Lead"
- When "Demo Request" is submitted → Create task "Follow up with demo request"
- When any form is submitted → Enroll in "Welcome" campaign
The trigger passes the form ID and the resulting contact ID to the automation engine. See Automations → Actions for the full list of available actions.
Success Responses
After successful processing, the API responds based on the form's success_redirect_url setting:
| Setting | Response |
|---|---|
success_redirect_url is set | 302 Redirect to the configured URL |
success_redirect_url is empty | 200 OK with JSON \{ "success": true, "contact_id": "..." \} |
For HTML embed forms, the redirect is the expected behavior. For SPA integrations, the JSON response allows inline success handling without a page navigation.
CORS
The API returns dynamic CORS headers based on the form's allowed_domains configuration:
- Restricted mode (domains configured) —
Access-Control-Allow-Originechoes back the request origin only if it matches the whitelist. - Open mode (no domains configured) —
Access-Control-Allow-Originechoes back any origin. - The
OPTIONSpreflight handler returns the same dynamic headers.
This is relevant for React / SPA integrations where the browser enforces
CORS policies on fetch() requests.
Automation Relationships
Each form's detail page includes an inline automation relationships card that shows all automation rules referencing this form. Relationships are rendered as natural-language sentences — for example, "When this form is submitted, it adds the New Lead tag to the contact."
See Automations → Dependency Tracking for details on the dependency tracking system.
Deleting Forms
Deleting a form permanently removes the form, all its submissions, and any automation rules triggered by this form.
If automation rules reference the form, the delete confirmation dialog warns:
"This form is used in N automation rule(s). Deleting it will also remove those rules and all submissions. Continue?"
Integration Guides
Choose your integration method based on your website or application:
- HTML Embed — Squarespace, Wix, WordPress, Webflow, or any static HTML site.
- React / SPA — React, Vue, Next.js, or any application using
fetch(). - Server / API Key — Zapier, Make.com, backend services, or mobile apps.