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 as | Names we recognise |
|---|---|
| First name | firstName, fname, givenName, first, forename |
| Last name | lastName, lname, surname, familyName, last |
| Full name (we split it) | name, fullName, yourName, contactName, patientName |
| Phone | phone, telephone, tel, mobile, cell, contactNumber, phoneNo |
email, emailAddress, mail, yourEmail, contactEmail | |
| Date of birth | dob, dateOfBirth, birthDate, birthday |
| Address | address, address1, streetAddress, street / address2, apt, suite, unit |
| City | city, town, locality |
| State | state, province, region, county |
| Postcode | zip, zipCode, postalCode, postcode |
| How they heard | howDidYouHearAboutUs, referralSource, referredBy, source |
| Message | message, 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.
formType | For |
|---|---|
contact | A contact or enquiry form (the default) |
appointment_request | “Request an appointment” / consultation forms |
pain_quiz | A TMJ / jaw pain self-assessment |
sleep_quiz | A sleep, snoring or sleepiness self-assessment |
quiz | Any other quiz or screening |
other | Anything 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
leadIdwithduplicate: trueinstead 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 a200, 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,dataoranswersare 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
| Status | Meaning |
|---|---|
201 | Filed. { ok, leadId, duplicate, formType, unmapped }, plus answerFields on a quiz and warnings when something needs fixing |
200 | Already had this one. Same shape, with duplicate: true and the original leadId |
400 | No phone and no email, invalid JSON, or a failed captcha |
401 | Key unknown, switched off or revoked — or no credential at all |
403 | The posting website is not on this key's allowed origins |
413 | Body over 32 KB |
429 | Rate limited. Retry-After says how long |
503 | The 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.