The doors
Three carry events. The fourth carries the person they happened to.
Passive autocapture — page views, scrolls, idle timers gathered by the script tag on its own — is another stream. It feeds analytics and behavioral triggers, but it never opens an event trigger. Deliberateness is never guessed from the event name.
Authentication
track and track/batch require a secret key:
hamla_live_… keys keep working.
A publishable key (pk_live_…) authenticates but is refused with 403 on these endpoints: it ships in page source, so honoring it would let any visitor invent revenue. Neither endpoint sends CORS headers or answers OPTIONS, deliberately — a page that cannot pass CORS cannot be tempted to embed a secret key.
The key names exactly one business. businessId in the body is optional; if present and it disagrees with the key, the request is refused rather than quietly rewritten.
POST /api/sdk/track
Records one event that just happened.Fields
identity
Any one is enough, and it does not have to be the email — a business that only ever collects phone numbers sends
phone alone, forever. An event with no identity at all belongs to nobody and is refused.
Send more than one and Hamla uses them together to match or create a single contact, keeping each as a way to find that person later. Anonymous browsing history joins the profile at the moment identity is first known.
Identity is recognition, not permission: a phone number lets Hamla match someone, and never doubles as consent to send them an SMS or WhatsApp message.
Response
Errors
POST /api/sdk/track/batch
Records up to 500 events that already happened. Same event vocabulary, same field rules, same secret key.events takes 1–500 items. Each item accepts every track field except visitorId and context — replayed history carries no browser session and no live request, so nothing saw a User-Agent or a country.
What backfill deliberately does not do
Everything a backfill should do still happens: contacts are resolved or created, activities land with their real
occurredAt, first/last/count statistics update (out-of-order rows are handled), traits and tags apply, and value counts toward revenue.
Response
Items fail alone — a bad row reports its index and reason, and the rest of the batch lands.warnings is capped at 50 entries so one misnamed column across 500 rows cannot dominate the response.
Give every row an idempotencyKey and the whole import is safe to re-run.
POST /api/sdk/identify
Says who someone is — and what to call them. The companion totrack: identity and profile, no event.
It shows on the person’s timeline as identified, which is never a form, a lead or a conversion. Calling it on every page a signed-in person loads is safe.
Fields
profile
Worth sending a name for one reason:
{{contact.firstName}} in a campaign. Without it every message opens with Hi Friend.
context — for signups that happen on your server
The browser SDK needs none of this: its request is the person, so Hamla reads the device and the country off it directly.
A signup that completes on your server is the opposite — the request Hamla receives comes from your data centre. Without
context that contact has no device and no country at all, and it is not a small gap: “which countries are my customers in” and “how many sign up on a phone” have no answer for anyone who signed in through an OAuth callback, a server-rendered form, or a mobile app.
Read the values off the request the person actually made — user-agent, and whichever geo header your host adds (x-vercel-ip-country, cf-ipcountry):
traits does: this is a claim about someone. From an unkeyed caller it is ignored with a warnings entry, and the request’s own headers are used instead. Send no context and Hamla records nothing rather than guessing — your hosting region is not where your customer lives.
Geo is taken as country/region/city rather than as an IP address on purpose: Hamla keeps the place, never the address.
Authentication, unusually
This is the one write endpoint that also serves unkeyed callers, because published browser bundles cannot send anAuthorization header and cannot be upgraded once a customer’s CDN has them. A key is still worth sending from a server: it scopes the write to your business and makes it attributable, and traits require one.
Response
linkedVisitor is true when this call is what tied the anonymous browser to the person — the moment their earlier visits, and the campaign that produced them, stop being anonymous. warnings is absent when everything landed.
hamla.track() — the browser door
For deliberate actions that genuinely happen in the page. No key, because none can safely ship to a browser.What an event does once stored
Segment fields
A contact with no matching event resolves to undefined for all metrics, including
count. So “fewer than 5 times, including never” is count < 5 OR not_exists — never a silent zero.
Names that move the journey stage
Your own names are recorded and fully usable; they simply do not move the stage. Borrow these when they fit:
An email click Hamla records is read by where its link goes. A click toward your business (your site, product, booking page, app store, or a chat with you) is Interested. A click on a social profile counts as engagement and leaves the stage where it is. An unsubscribe click, or a mail scanner opening every link within seconds, moves no one. An
email_clicked you send yourself always counts.
What an event means
event is your word. type is ours, and there are eleven of them.
You never have to send type — Hamla infers from the amount and from names it
already recognises. Send it and nothing is guessed.
treatment_completed, a school calls it lesson_finished, and
both are a purchase. Your name is never translated, replaced or checked
against a list — it is kept exactly as you sent it, and stays what your reports,
segments and triggers are written in.
booking is the one worth knowing about. Every other analytics vocabulary
was designed for online stores, where saying yes is paying. A confirmed
appointment and a signed contract are real commitments that move no money — so
they are a booking, not a purchase, and they will not show up in revenue or
inflate your average order value. When one of them does carry an amount, send
the amount and Hamla counts it.A free trial is not a booking: trying is not paying. trial_started is
checkout_started — started, not finished — so a trial user is Interested, and
becomes a Customer at their first payment.What your ad accounts hear
When Google Ads or Meta is connected, Hamla tells them which results their ad clicks produced, bytype and never by your event’s name: purchase (with the
amount), booking, lead, signup, trial, contact and application each
reach Google as their own conversion action and Meta as its standard event
(Purchase, Schedule, Lead, CompleteRegistration, StartTrial, Contact,
SubmitApplication). So treatment_completed sent with type: "purchase"
reaches your ad account exactly like a store’s purchase. Imported history is
never reported.
If you send nothing
Hamla decides in this order:type, if you sent it. Always wins.- A
value. Money moved, whatever you called the event. A negative amount is a refund. - A name Hamla already knows. Kept so that integrations written before
typeexisted keep behaving exactly as they did.
type says the same thing
in any vocabulary — but nothing you already send changes meaning because of
them.
Traits and custom fields
traits writes the contact’s current state; the event records what happened. Send both in one call.
The first time a trait key arrives, Hamla creates the matching custom field and infers its type: 3 becomes Number, true Boolean, "2026-08-08" Date, anything else Text. It shows up in Contacts → Contacts Management → Custom Fields and segments can filter on it immediately.
Two rules, both reported in the warnings array:
- Keys are
snake_case—businesses_count, notBusinesses-Count. - A field keeps its first type. Once
businesses_countis a Number, a later"lots"is skipped rather than corrupting what is stored.
Limits
The rate limiter fails open: if it is unreachable, requests are allowed rather than rejected.
Node SDK
npm install @gethamla/node wraps the same endpoints — nothing it can do is unavailable over plain HTTP.
track and identify are fire-and-forget and never throw — a marketing call must not break a checkout. trackNow returns a DeliveryResult when you want to know. trackBatch re-bases failure indexes onto your own array, and refuses visitorId for the same reason the endpoint does.