Collect leads from your website
Send every website form submission straight into Vision CRM, from a one-field contact form to a multi-step enquiry with its own consent and booking steps. Your site keeps its own design and flow; only its server talks to Vision.
How it works
- Your website's server holds a Vision API client (client ID and secret).
- It exchanges them for a short-lived CRM token at
https://app.runonvision.com/oauth/token. - It posts each submission to CRM:
POST https://crm.runonvision.com/api/crm/v1/intake-submissions. - The lead waits in CRM's Leads queue. When a person accepts it, it becomes a contact, a company if you sent one, and a deal in the pipeline you chose.
The scope is crm:intake:create. It can only add a lead for review: it cannot read anything in CRM, and it only ever writes to the workspace the client belongs to.
The secret belongs on your server. Never put it in browser JavaScript, a
NEXT_PUBLIC_ variable, a mobile app, a repository or a log. A browser-only
paste-in form with a publishable key is planned for a later release.
One-time setup
- In Vision, open Settings → Developer and choose Connect a website form (owners and administrators). Vision makes a key that runs on your website's server and holds only
crm:intake:create, tells CRM about it, and shows the client ID, the secret and ready-to-paste code once. See API keys. - Store
VISION_CLIENT_IDandVISION_CLIENT_SECRETas server environment variables. The secret is shown once; if you lose it, replace it from the same page. - Optional: to send a form's leads to a pipeline other than your main one, create that pipeline in CRM and note its key (for example
vision-early-access).
Get a CRM token
Token exchange
curl https://app.runonvision.com/oauth/token \
--request POST \
--user "$VISION_CLIENT_ID:$VISION_CLIENT_SECRET" \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'scope=crm:intake:create' \
--data-urlencode 'resource=vision-crm-live'
Successful response
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "crm:intake:create"
}
resource names the App the token is for; vision-crm-live is live CRM. Cache the token in server memory until about 30 seconds before it expires. invalid_scope means the key was not given crm:intake:create; invalid_target means the key does not reach CRM or belongs to a different environment.
Submit a lead
Request
POST https://crm.runonvision.com/api/crm/v1/intake-submissions
Authorization: Bearer eyJ...
Content-Type: application/json
Body
{
"contractVersion": "vision-crm-lead-form.v1",
"source": "Website contact form",
"name": "Morgan Lee",
"email": "morgan@example.com",
"phone": "+1 757 555 0112",
"company": "Lakeview Design Group",
"message": "We need a clearer website and better search visibility.",
"answers": [
{ "label": "Project type", "value": "Website redesign" },
{ "label": "Budget", "value": "$10k-$25k" },
{ "label": "Timeline", "value": "Next quarter" }
],
"consent": {
"purpose": "inquiry_response",
"channel": "email",
"action": "granted",
"captureMethod": "explicit_checkbox",
"noticeVersion": "contact-2026-10-28",
"occurredAt": "2026-10-28T14:03:11-04:00"
},
"pipelineKey": null,
"page": "/contact",
"website": "",
"test": false,
"idempotencyKey": "contact-3f2b9c1e-6d4a-4f0b-9a57-0c8e2d1b7a44"
}
Fields
- Name
contractVersion- Type
- string
- Description
Always
vision-crm-lead-form.v1.
- Name
source- Type
- string
- Description
Which form this is, in your words (1 to 120 characters). Shown on the lead and used as its tag.
- Name
name- Type
- string
- Description
The person's name, 2 to 120 characters.
- Name
email / phone- Type
- string | null
- Description
At least one is required. Phone: an optional leading
+, then 7 to 32 digits, spaces or( ) . -.
- Name
company, message- Type
- string | null
- Description
Optional. Accept creates the company from
company.
- Name
answers- Type
- array
- Description
Up to 100
labelandvaluepairs, in display order: project type, budget, timeline, team size, anything your form asks. Shown on the lead as Form answers and copied to the contact's timeline on accept.
- Name
consent- Type
- object | null
- Description
Send only when the form asked with an explicit checkbox, with the version of the wording the person agreed to. A plain Send click is not consent.
- Name
pipelineKey- Type
- string | null
- Description
The deal pipeline an accepted lead opens in (its first open stage). Omit for your main pipeline. An unknown key is refused.
- Name
website- Type
- string
- Description
Your honeypot field, passed through (up to 200 characters). If an otherwise valid submission has any value here, nothing is stored and you get 204.
- Name
test- Type
- boolean
- Description
A test submission: never shown in Leads, never accepted, expires after 30 days. Use it to check a deployment.
- Name
idempotencyKey- Type
- string
- Description
One per form submission (16 to 120 letters, digits,
_or-). Reuse it only to retry that same submission. See Idempotency and concurrency.
Unknown fields are refused. The body limit is 64 KiB.
Responses and errors
| Status | Meaning | What to do |
|---|---|---|
| 201 | Received for review; the body is a receipt with publicReference (IN-...) | Show your thank-you |
| 200 | Same key, same payload as before: the earlier receipt with replayed: true | Treat as success |
| 204 | Honeypot filled on a valid body; nothing stored | Treat as success |
| 400 | Invalid body; fieldIssues names the fields (pipelineKey / pipeline_not_found) | Fix the payload; do not retry unchanged |
| 401 | Token missing, expired or wrong | Get a new token and retry once |
| 403 | The key may not submit (scope not granted, or CRM not yet told about the key) | Check the key in Settings → Developer |
| 409 | The idempotency key was already used for a different payload | Use a new key per submission |
| 413 | Body over 64 KiB | Trim the answers |
| 429 | Over 300 submissions in 5 minutes from this client; see Retry-After | Retry after the delay |
| 503 | CRM briefly unavailable | Retry with backoff, with the same key |
Keep rate limiting your own visitors and keep your honeypot: CRM's limit is per API client, not per visitor. Quote the correlationId from an error body when asking for help.
Next.js
lib/vision-crm.ts (server only)
import 'server-only'
import { randomUUID } from 'node:crypto'
let cached: { value: string; expiresAt: number } | null = null
async function token() {
if (cached && cached.expiresAt - 30_000 > Date.now()) return cached.value
const basic = Buffer.from(
`${process.env.VISION_CLIENT_ID}:${process.env.VISION_CLIENT_SECRET}`,
).toString('base64')
const response = await fetch('https://app.runonvision.com/oauth/token', {
method: 'POST',
headers: {
authorization: `Basic ${basic}`,
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'crm:intake:create',
resource: 'vision-crm-live',
}),
cache: 'no-store',
})
if (!response.ok) throw new Error(`token request failed: ${response.status}`)
const body = await response.json()
cached = {
value: body.access_token,
expiresAt: Date.now() + body.expires_in * 1000,
}
return cached.value
}
export async function submitLead(
lead: Record<string, unknown>,
key = `lead-${randomUUID()}`,
) {
const body = JSON.stringify({
contractVersion: 'vision-crm-lead-form.v1',
...lead,
idempotencyKey: key,
})
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(
'https://crm.runonvision.com/api/crm/v1/intake-submissions',
{
method: 'POST',
headers: {
authorization: `Bearer ${await token()}`,
'content-type': 'application/json',
},
body,
cache: 'no-store',
signal: AbortSignal.timeout(5_000),
},
).catch(() => null)
if (response && [200, 201, 204].includes(response.status))
return { ok: true }
if (response?.status === 401) cached = null
else if (response && [400, 403, 409, 413].includes(response.status))
return { ok: false, status: response.status }
const wait = Number(response?.headers.get('retry-after') ?? 0)
await new Promise((resolve) =>
setTimeout(resolve, wait ? wait * 1000 : 300 * 2 ** attempt),
)
}
return { ok: false, status: 503 }
}
Server action: final step of a multi-step form
'use server'
import { submitLead } from '@/lib/vision-crm'
export async function sendEnquiry(_: unknown, form: FormData) {
const result = await submitLead(
{
source: 'Website contact form',
name: String(form.get('name') ?? ''),
email: String(form.get('email') ?? '') || null,
company: String(form.get('business') ?? '') || null,
message: String(form.get('message') ?? '') || null,
answers: [
{ label: 'Project type', value: String(form.get('projectType') ?? '') },
{ label: 'Budget', value: String(form.get('budget') ?? '') },
{ label: 'Timeline', value: String(form.get('timeline') ?? '') },
],
consent:
form.get('consent') === 'on'
? {
purpose: 'inquiry_response',
channel: 'email',
action: 'granted',
captureMethod: 'explicit_checkbox',
noticeVersion: 'contact-2026-10-28',
occurredAt: new Date().toISOString(),
}
: null,
page: '/contact',
website: String(form.get('website') ?? ''),
},
// Create this key when the form is first shown (a hidden field), so a
// retry after a network error replays instead of duplicating.
String(form.get('submissionKey')),
)
return result.ok ? { status: 'sent' } : { status: 'error' }
}
Route handler: a form that posts JSON
import { submitLead } from '@/lib/vision-crm'
export async function POST(request: Request) {
const input = await request.json()
// validate input and rate limit the visitor first
const result = await submitLead(
{
source: 'Website contact form',
name: input.name,
email: input.email,
message: input.message,
answers: Object.entries(input.extra ?? {}).map(([label, value]) => ({
label,
value: String(value),
})),
website: input.website ?? '',
},
input.submissionKey,
)
return Response.json({ ok: result.ok }, { status: result.ok ? 200 : 502 })
}
curl
Send a test lead
TOKEN=$(curl -s -u "$VISION_CLIENT_ID:$VISION_CLIENT_SECRET" \
-d grant_type=client_credentials -d scope=crm:intake:create -d resource=vision-crm-live \
https://app.runonvision.com/oauth/token | jq -r .access_token)
curl -s https://crm.runonvision.com/api/crm/v1/intake-submissions \
--header "authorization: Bearer $TOKEN" \
--header 'content-type: application/json' \
--data '{"contractVersion":"vision-crm-lead-form.v1","source":"curl test","name":"Test Person","email":"test@example.com","answers":[{"label":"Budget","value":"$5k"}],"test":true,"idempotencyKey":"curl-test-0000000000000001"}'