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 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.
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. 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, unmapped } |
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.
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.