TMJ Journey docs  /  Developer reference

Lead Intake API

Send a lead from a form you built yourself straight into TMJ Journey. Post the body your existing contact form already produces — we work out what the fields mean.

This is for the case where a practice's website already has a contact form — Webflow, WordPress, Squarespace, hand-written HTML — and nobody wants to rebuild it. If you would rather TMJ Journey hosted the form, use the web-form embed instead (Marketing → Web forms inside the app). This page is the other option, not a replacement for it.

What happens to a submission

It becomes a lead, visible under Marketing → Leads in TMJ Journey.

It does not become a patient. A staff member converts it with one click, after checking the details and after the app has looked for an existing chart that might already be this person. That is deliberate: an endpoint the whole internet can reach must not be able to create clinical records.

Getting a key

The practice creates it in TMJ Journey under Settings → Lead capture → New key and sends it to you. It looks like pk_live_….

It is safe in page source. It is an identifier, not a secret, and the only thing it can do is file a lead — it is protected by an origin allowlist, a rate limit and optionally a captcha, rather than by being hidden. Ask the practice to set Allowed origins to the site the form runs on; left empty, any website may post with it.

The endpoint

POST https://tmjjourney.com/api/intake/v1/leads

One address, whichever practice you are integrating. The key identifies the practice, so you do not need to know their TMJ Journey host to post a lead — nothing else about the request changes.

If the practice has its own TMJ Journey domain you may post to that instead and it behaves identically. The in-app copy of this reference shows their address already filled in.

Two ways to authenticate

Browser — a publishable key

Send X-TMJ-Intake-Key. Use this one unless your form already posts to your own server.

<form id="enquiry">
  <input name="firstName" placeholder="First name" required>
  <input name="lastName"  placeholder="Last name">
  <input name="email"     placeholder="Email" type="email">
  <input name="phone"     placeholder="Phone" type="tel">
  <textarea name="message" placeholder="How can we help?"></textarea>
  <!-- Spam trap. Hide it with CSS; leave it empty. -->
  <input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
  <button type="submit">Send</button>
</form>

<script>
document.getElementById("enquiry").addEventListener("submit", async (e) => {
  e.preventDefault()
  const body = Object.fromEntries(new FormData(e.target).entries())
  const res = await fetch("https://tmjjourney.com/api/intake/v1/leads", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-TMJ-Intake-Key": "pk_live_…"
    },
    body: JSON.stringify(body)
  })
  const out = await res.json().catch(() => ({}))
  if (res.ok) {
    e.target.reset()
    alert("Thanks — we'll be in touch.")
    // A field name we did not recognise is a field nobody will read.
    // Worth seeing while you wire this up; harmless afterwards.
    if (out.unmapped?.length) console.warn("unmapped fields:", out.unmapped)
    // out.duplicate === true means we already had this enquiry and
    // out.leadId points at the original. Still a success.
  } else {
    alert(out.error || "Sorry, something went wrong.")
  }
})
</script>

The key may also travel in the body as publicKey, for form tools that cannot set a header.

Server — a service account token

If your server posts on the visitor's behalf, use a service account token instead (Settings → Service accounts). No origin check, because there is no browser to have one. This token is a secret — keep it server-side.

curl -X POST https://tmjjourney.com/api/intake/v1/leads \
  -H "Authorization: Bearer sa_…" \
  -H "Content-Type: application/json" \
  -d '{"fullName":"Jane Doe","email":"jane@example.com","phone":"480-555-0101"}'

Field names — you do not have to rename anything

Matching ignores case and punctuation, so First Name, first_name, firstName and FIRSTNAME are all the same field.

We file it asNames we recognise
First namefirstName, fname, givenName, first, forename
Last namelastName, lname, surname, familyName, last
Full name (we split it)name, fullName, yourName, contactName, patientName
Phonephone, telephone, tel, mobile, cell, contactNumber, phoneNo
Emailemail, emailAddress, mail, yourEmail, contactEmail
Date of birthdob, dateOfBirth, birthDate, birthday
Addressaddress, address1, streetAddress, street / address2, apt, suite, unit
Citycity, town, locality
Statestate, province, region, county
Postcodezip, zipCode, postalCode, postcode
How they heardhowDidYouHearAboutUs, referralSource, referredBy, source
Messagemessage, comments, notes, enquiry, howCanWeHelp, reasonForVisit

Send both a combined name and a separate firstName and the separate ones win — a form that asked for both is telling us something, and splitting a name on spaces is only ever a guess.

Fields we don't recognise — read this one

Nothing is thrown away. Anything we cannot place is kept on the lead as Label: value, so the practice still sees it. The response also names them:

{ "ok": true, "leadId": "clx…", "unmapped": ["tel-472", "practice_area"] }

Check unmapped once, when you wire the form up. It is how you find out that Contact Form 7 named your phone field tel-472 — otherwise the practice simply never receives phone numbers and has no way to notice.

Two fixes: rename the field, or ask the practice to set a field map on the key — { "tel-472": "phone" } — which wins over the table above.

Rules and limits

  • A phone or an email is required. Without one there is no way to reply, so the submission is refused.
  • A name is optional. An email on its own is still a lead worth having.
  • Body limit 32 KB.
  • 20 requests per minute per IP address, and 120 per minute per key. The per-key limit is the one a busy site shares, so a server-side proxy posting on behalf of every visitor spends it faster than a form running in each visitor's browser.
  • The same person twice inside ten minutes is one enquiry. A repeat submission matching an earlier one on email or phone, through the same key, returns the original leadId with duplicate: true instead of filing a second lead. A double-click or an impatient retry is not an error, so this is a 200, not a failure. Enquire again three weeks later and that is a new lead — the window is deliberately short.
  • Values may be text, numbers, or arrays — a multi-select is joined with , .
  • Answers nested under fields, data or answers are flattened, so {"data":{"email":"…"}} works.
  • HTML tags are stripped from every value before it is stored. Send markup and you get the text back without it.

What comes back

StatusMeaning
201Filed. { ok, leadId, duplicate, unmapped }
200Already had this one. Same shape, with duplicate: true and the original leadId
400No phone and no email, invalid JSON, or a failed captcha
401Key unknown, switched off or revoked — or no credential at all
403The posting website is not on this key's allowed origins
413Body over 32 KB
429Rate limited. Retry-After says how long
503The key requires a captcha and captcha is not configured

Keeping spam out

Add a field named _gotcha, website_url or _honey, hide it with CSS, and leave it empty. Real people never fill it; bots usually do. Submissions that fill it are quietly discarded and still get a success response — a bot that gets an error learns to avoid the trap.

Submissions that look automated for other reasons — a wall of links, for instance — are quarantined rather than refused. They are filed, held out of the practice's default inbox, and still answered with a success. Nothing is turned away at the door: a spam filter that silently drops a real enquiry costs a patient, and that is the more expensive mistake. You will not be told which submissions were quarantined, for the same reason the honeypot answers with a success.

For heavier traffic the practice can switch on Require captcha on the key. Either provider works — send an hCaptcha token as h-captcha-response, or a Cloudflare Turnstile token as cf-turnstile-response. Whichever token you send picks the verifier, so you embed one widget and nothing else needs configuring. Ask the practice which one their platform has switched on.

The strongest control is still the allowed-origins list. Leave it empty and the key is accepted from anywhere it is pasted; fill it in and only your own site can post with it.

What a key can and cannot do

  • It can file a lead. That is the entire list.
  • It cannot create, read or change a patient record.
  • It cannot read anything at all — there is no GET.
  • It cannot reach any other part of TMJ Journey.
  • Revoking it stops it immediately. Leads it already filed keep working and keep pointing at it, so the practice can always see where a lead came from.

TMJ Journey is a Vault Data Servers product. If you are a practice wondering whether any of this applies to you, start at Get Started.