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.

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.

Form types — contact forms, quizzes, appointment requests

One endpoint takes every kind of form. Say which kind with a formType field — a hidden input is fine. Leave it out and the submission is a contact form, exactly as before.

formTypeFor
contactA contact or enquiry form (the default)
appointment_request“Request an appointment” / consultation forms
pain_quizA TMJ / jaw pain self-assessment
sleep_quizA sleep, snoring or sleepiness self-assessment
quizAny other quiz or screening
otherAnything else

Spelling is forgiving — Pain Quiz, pain-quiz and TMJ quiz all mean pain_quiz. A value we don't know is still filed, as other, and the response carries a warnings entry saying so. A lead is never refused over a label.

Can't add a hidden field? Some form builders can't. Ask the practice for one key per form and to set that key's Form type; everything posted with it is then filed as that type. A formType in the body still wins.

Quizzes

Send the score and result if your quiz works them out — score (a number, or "18/30"), maxScore, result (e.g. "Moderate") — and an optional formName. Every other field is kept as one of the quiz's answers, the field name as the question, so a plain form needs no special shape:

await fetch("https://tmjjourney.com/api/intake/v1/leads", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-TMJ-Intake-Key": "pk_live_xxxxxxxxxxxx" },
  body: JSON.stringify({
    formType: "pain_quiz",
    formName: "Website TMJ pain quiz",
    firstName: "Jane", lastName: "Doe",
    email: "jane@example.com", phone: "480-555-0101",
    "How often does your jaw hurt?": "Daily",
    "Does your jaw click or pop?": "Yes",
    score: 18, maxScore: 30, result: "Moderate",
  }),
})

Or send the questions as a list, with points per answer if you have them:

{
  "formType": "sleep_quiz",
  "email": "jane@example.com",
  "quiz": {
    "name": "Epworth (website)",
    "score": 14, "maxScore": 24, "result": "Excessive daytime sleepiness",
    "questions": [
      { "question": "Sitting and reading", "answer": "High chance", "points": 3 }
    ]
  }
}

On a quiz the response lists the fields it filed as answers in answerFields instead of unmapped — check it once to make sure your phone field isn't among them. Up to 100 answers are kept.

Quiz answers and scores are shown only to the practice's staff. They are never added to the lead's notes and never shown in the marketing portal — a symptom questionnaire is health information.

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. The window is per form type, so a quiz followed by a contact form is two leads. 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, formType, unmapped }, plus answerFields on a quiz and warnings when something needs fixing
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.