Skip to content

Website form to CRM

The browser posts the form to your server. Your server adds the key and calls NexaCloser. The key never reaches the browser.

Browser form ──POST──▶ your server route ──POST /v1/contacts──▶ NexaCloser
(holds the key)

In Settings → API → New key: an Organization key with access Contacts: create & update only. Make a test key too, for development. See Access levels.

Variable Value
NEXACLOSER_API_KEY The key (nxa_… live, nxa_test_… while testing)
NEXACLOSER_API_BASE_URL https://api.nexacloser.com/v1

Set them in your host’s environment settings. Never commit them, never print them in logs.

A Node.js example (Express style; the same logic works in any framework or language):

// POST /api/contact — called by the website form. Runs on the server only.
const BASE = process.env.NEXACLOSER_API_BASE_URL || 'https://api.nexacloser.com/v1'
const KEY = process.env.NEXACLOSER_API_KEY
// The consent wording shown next to the checkbox, and its version.
const CONSENT_TEXT = 'I agree to receive emails and text messages from Example Clinic. Reply STOP to opt out.'
const CONSENT_VERSION = '2026-11-01'
export async function contactRoute(req, res) {
const f = req.body || {}
if (!f.email && !f.phone) return res.status(400).json({ ok: false })
const body = {
email: f.email || undefined,
phone: f.phone || undefined,
full_name: f.name || undefined,
source: 'Website contact form',
tags: ['website'],
custom_fields: { visit_type: f.visit_type, contact_time: f.contact_time },
attribution: {
utm_source: f.utm_source, utm_medium: f.utm_medium, utm_campaign: f.utm_campaign,
landing_url: f.page_url, referrer: f.referrer,
},
consent: {
granted: f.marketing_opt_in === true,
text: CONSENT_TEXT,
version: CONSENT_VERSION,
at: new Date().toISOString(),
ip: req.ip, // the visitor's IP (behind a proxy: app.set('trust proxy', 1))
user_agent: req.get('user-agent'),
page_url: f.page_url,
},
}
const r = await fetch(`${BASE}/contacts`, {
method: 'POST',
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
})
if (r.status === 201 || r.status === 200) return res.json({ ok: true })
if (r.status === 422) {
const err = await r.json()
console.warn('NexaCloser rejected the form', err.details) // field names and codes only
return res.status(422).json({ ok: false })
}
// 429 / 5xx: keep the lead and retry later (a queue or a cron), never drop it.
console.error('NexaCloser unavailable', r.status)
return res.status(502).json({ ok: false })
}

Notes:

  • JSON.stringify drops undefined values, so empty form fields are simply not sent.
  • consent.granted is true only when the visitor actually ticked the box. false records nothing, which is right: an unticked box is not an opt-out. Leave consent out if the form has no opt-in at all.
  • Do not log the request body: it holds personal data.
  • Use a test key until the form works end to end, then switch the variable to the live key.
  1. With the test key, submit the form. Your route gets 200 with "test": true; fix any 422 it reports.
  2. With the live key, submit it once with your own details. The contact appears under Contacts with tag website, source channel Website contact form, its custom fields and the consent record.
  3. Submit again with the same email: the same contact is updated, not duplicated.