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

  1. Your website's server holds a Vision API client (client ID and secret).
  2. It exchanges them for a short-lived CRM token at https://app.runonvision.com/oauth/token.
  3. It posts each submission to CRM: POST https://crm.runonvision.com/api/crm/v1/intake-submissions.
  4. 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.

One-time setup

  1. 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.
  2. Store VISION_CLIENT_ID and VISION_CLIENT_SECRET as server environment variables. The secret is shown once; if you lose it, replace it from the same page.
  3. 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 label and value pairs, 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

StatusMeaningWhat to do
201Received for review; the body is a receipt with publicReference (IN-...)Show your thank-you
200Same key, same payload as before: the earlier receipt with replayed: trueTreat as success
204Honeypot filled on a valid body; nothing storedTreat as success
400Invalid body; fieldIssues names the fields (pipelineKey / pipeline_not_found)Fix the payload; do not retry unchanged
401Token missing, expired or wrongGet a new token and retry once
403The key may not submit (scope not granted, or CRM not yet told about the key)Check the key in Settings → Developer
409The idempotency key was already used for a different payloadUse a new key per submission
413Body over 64 KiBTrim the answers
429Over 300 submissions in 5 minutes from this client; see Retry-AfterRetry after the delay
503CRM briefly unavailableRetry 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"}'