> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hamla.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Events API

> Field-by-field reference for the endpoints that send events to Hamla — track, batch, and the browser door — plus what an event does once it is stored.

An **event** is a fact your system reports to Hamla: something happened, to a specific person, at a specific time. Once stored it can start a campaign, define an audience, end a sequence, and move the contact through their journey.

For the guided version, read [Hamla Events](/how-to/hamla-events). This page is the contract.

## The doors

Three carry events. The fourth carries the person they happened to.

| Door                           | Use it for                            | Key                  | Can start a campaign |
| ------------------------------ | ------------------------------------- | -------------------- | -------------------- |
| `POST /api/sdk/track`          | one live event from your server       | **secret, required** | Yes                  |
| `POST /api/sdk/track/batch`    | up to 500 past events (backfill)      | **secret, required** | **No — never**       |
| `hamla.track()` in the browser | a deliberate action inside your page  | none                 | Yes                  |
| `POST /api/sdk/identify`       | who someone is, and what to call them | optional             | —                    |

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:

```
Authorization: Bearer sk_live_xxx
```

Create one in **Settings → API keys**. Older `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.

```bash theme={null}
curl -X POST https://app.hamla.io/api/sdk/track \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "subscription_started",
    "identity": { "email": "omar@raqmi.co", "platformCustomerId": "user_812" },
    "properties": { "plan": "pro", "seats": 3 },
    "value": 29,
    "currency": "JOD",
    "occurredAt": "2026-08-23T09:41:00Z",
    "idempotencyKey": "invoice_9911",
    "traits": { "plan": "pro", "subscription_status": "active" },
    "tags": ["paying"]
  }'
```

### Fields

| Field            | Type             |              | Notes                                                                                                                                 |
| ---------------- | ---------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `event`          | string           | **required** | The moment, in your own vocabulary. Trimmed, 1–64 characters. Matched exactly by triggers and segments.                               |
| `identity`       | object           | **required** | At least one of `email`, `phone`, `platformCustomerId`. See below.                                                                    |
| `occurredAt`     | string \| number | optional     | ISO 8601 or epoch milliseconds. Defaults to now. The past is allowed; more than 5 minutes into the future is rejected.                |
| `type`           | string           | optional     | What the event MEANS, from the seven below. Omit it and Hamla infers.                                                                 |
| `value`          | number           | optional     | Finite. Counts toward the contact's revenue — see **What an event means**.                                                            |
| `currency`       | string           | optional     | 1–8 characters, e.g. `JOD`, `SAR`, `USD`.                                                                                             |
| `properties`     | object           | optional     | Any JSON detail worth keeping with the event. Max 32,768 bytes serialized. Stored nested, never spread into the payload root.         |
| `tags`           | string\[]        | optional     | Merge-added to the contact — never replaces existing tags. Max 50 tags, each 1–64 characters.                                         |
| `traits`         | object           | optional     | Current-state values written to custom fields. Values may be string, number, boolean or null. Unknown keys are created automatically. |
| `idempotencyKey` | string           | optional     | 1–200 characters. Re-sending with the same key is a recorded no-op.                                                                   |
| `businessId`     | string           | optional     | Cross-check only. Must match the key's business.                                                                                      |
| `visitorId`      | string           | optional     | Links the event to a known browser session, when your server happens to have the cookie. Max 128 characters.                          |

### `identity`

| Field                | Type             | Notes                 |
| -------------------- | ---------------- | --------------------- |
| `email`              | string           | 3–320 characters.     |
| `phone`              | string \| number | Any format you store. |
| `platformCustomerId` | string \| number | Your own user id.     |

**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

```json theme={null}
{
  "success": true,
  "message": "Event recorded",
  "data": {
    "contactId": "cm4x...",
    "event": "subscription_started",
    "isNew": false,
    "deduped": false,
    "interpreted": { "type": "purchase", "countedRevenue": 29 },
    "warnings": ["custom field \"businesses_count\" needs a number — skipped"]
  }
}
```

| Field         | Meaning                                                                                                                                    |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `contactId`   | The contact this event landed on.                                                                                                          |
| `isNew`       | `true` when this call created the contact.                                                                                                 |
| `interpreted` | What Hamla decided the event means, and how much of `value` it counted.                                                                    |
| `deduped`     | `true` when `idempotencyKey` matched an event already recorded. Tags and traits are still applied, so a retry converges to the same state. |
| `warnings`    | Present only when something advisory happened — usually a trait that did not match its field's type.                                       |

### Errors

| Status | When                                                                                           |
| ------ | ---------------------------------------------------------------------------------------------- |
| `400`  | Malformed JSON, schema violation, no identity field, bad `occurredAt`, oversized `properties`. |
| `401`  | No `Authorization` header, or the key is invalid or revoked. The two answer differently.       |
| `403`  | Publishable key, `businessId` mismatch, or the business is not active.                         |
| `404`  | Business not found.                                                                            |
| `429`  | Rate limit exceeded. Retry after the window in the response headers.                           |

## POST /api/sdk/track/batch

Records up to 500 events that already happened. Same event vocabulary, same field rules, same secret key.

```bash theme={null}
curl -X POST https://app.hamla.io/api/sdk/track/batch \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      { "event": "purchase", "identity": { "email": "omar@raqmi.co" },
        "value": 120, "currency": "JOD",
        "occurredAt": "2025-11-02T10:00:00Z", "idempotencyKey": "order_5512" },
      { "event": "purchase", "identity": { "email": "lina@example.com" },
        "value": 80, "currency": "JOD",
        "occurredAt": "2025-12-14T18:20:00Z", "idempotencyKey": "order_5610" }
    ]
  }'
```

`events` takes 1–500 items. Each item accepts every `track` field except `visitorId` — replayed history carries no browser session.

### What backfill deliberately does not do

|                             |                                                                                                                          |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **No campaign fires.**      | Imported events carry no trigger marker at all. A thousand year-old purchases must not send a thousand messages tonight. |
| **No segment-entry blast.** | Segment recomputes from an import run in bulk: membership moves, entry triggers stay silent.                             |
| **No web attribution.**     | History replayed from a database is not a web arrival, so visitor state is untouched.                                    |

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.

```json theme={null}
{
  "success": true,
  "message": "Batch recorded with failures",
  "data": {
    "received": 500,
    "recorded": 497,
    "deduped": 2,
    "failed": 1,
    "failures": [{ "index": 314, "error": "occurredAt cannot be in the future" }],
    "warnings": ["events[12]: custom field \"plan\" needs a number — skipped"]
  }
}
```

`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 to `track`: identity and profile, no event.

```bash theme={null}
curl -X POST https://app.hamla.io/api/sdk/identify \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "identity": { "email": "omar@raqmi.co" },
    "profile": { "name": "Omar Al Saleh", "country": "JO" },
    "method": "signup"
  }'
```

Resolve-or-create: an unknown person becomes a contact, a known one is enriched. Safe to call on every login — profile fields are gap-filled, never overwritten.

### Fields

| Field        | Type   |              | Notes                                                                                                                                                                                                              |
| ------------ | ------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `identity`   | object | **required** | Same three fields, same rules, as `track`'s above.                                                                                                                                                                 |
| `profile`    | object | optional     | Name and location. See below.                                                                                                                                                                                      |
| `traits`     | object | optional     | Current-state values → custom fields. **Secret key only** — an unkeyed caller's traits are ignored with a `warnings` entry, because an anonymous claim about someone's plan is not a fact.                         |
| `method`     | string | optional     | Your label for the door they came through: `signup`, `checkout`, `google`. Max 64 characters, recorded on the signal. It is what tells "nobody signed up today" apart from "the checkout identify stopped firing". |
| `visitorId`  | string | optional     | The Hamla visitor cookie, linking their anonymous browsing — including the ad click that brought them — to this person. The browser SDK sends it for you; a server can read the cookie and pass it.                |
| `businessId` | string | optional     | Cross-check only. Must match the key's business.                                                                                                                                                                   |

### `profile`

| Field                    | Notes                                                                                                                                                                                                      |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                   | The whole name in one string. Split on arrival: first word → `firstName`, the rest → `lastName`, so `Sara Al Otaibi` is greeted as **Sara**.                                                               |
| `firstName` / `lastName` | Send these instead when your database already keeps them apart. Sent alongside `name`, they win field by field.                                                                                            |
| `country`, `city`        | Free-form; used for location segments.                                                                                                                                                                     |
| `language`               | The language campaigns to this person should be written in.                                                                                                                                                |
| `timezone`               | IANA zone, e.g. `Asia/Amman`. Keeps a delayed campaign step out of the recipient's night. The browser SDK fills it in; a country alone cannot answer it for anyone in the US, Brazil, Canada or Australia. |

Worth sending a name for one reason: `{{contact.firstName}}` in a campaign. Without it every message opens with `Hi Friend`.

### Authentication, unusually

This is the one write endpoint that **also serves unkeyed callers**, because published browser bundles cannot send an `Authorization` 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

```json theme={null}
{
  "success": true,
  "message": "New contact created and linked",
  "data": {
    "contactId": "cm4x...",
    "isNew": true,
    "linkedVisitor": true,
    "subscriptions": { "email": true, "sms": false, "whatsapp": false, "push": false },
    "warnings": []
  }
}
```

`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.

```js theme={null}
hamla.track('download_ebook', { asset: 'guide.pdf' });
hamla.track('appointment_booked', { date: '2026-04-15', price: 100 });
```

The event name is used verbatim. Because the page's own code chose to send it, it counts as deliberate and **can** start a campaign — unlike the autocapture events travelling in the same batch.

Anything involving money or your own records belongs on the server door, where the key lives and the facts cannot be edited by a visitor.

## What an event does once stored

| Surface                               | Where                                                                      | Behavior                                                                                                                                                                 |
| ------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Campaign trigger**                  | When does the campaign start? → App event → Your app's events → Event name | Fires for every matching event from now on. Enrollment is once per contact unless **Can enter again later** is on. Names can be typed before the event has ever arrived. |
| **Segment rules**                     | Segment builder → Events                                                   | Two fields per event name — see below. The group appears once the first event exists.                                                                                    |
| **Conversion goal / end of sequence** | How the sequence ends → When an event happens                              | Whoever fires it stops receiving the rest, checked before every send. Locks after publish.                                                                               |
| **Journey stage**                     | Contact profile                                                            | Only for the recognized names below.                                                                                                                                     |
| **Timeline**                          | Contact profile                                                            | Every event, kept with its `occurredAt`.                                                                                                                                 |
| **Analytics**                         | Reports and attribution                                                    | Feeds engagement, scoring, metrics, and Meta conversion forwarding.                                                                                                      |

### Segment fields

| Path                 | Reads                  | Operators                                                                                |
| -------------------- | ---------------------- | ---------------------------------------------------------------------------------------- |
| `event.<name>`       | when it last happened  | Date operators — `exists` (ever), `not_exists` (never), `within_days`, `older_than_days` |
| `event.<name>.count` | how many times         | Number operators                                                                         |
| `event.<name>.first` | when it first happened | Date operators. Evaluated but not offered in the picker.                                 |

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:

| Stage         | Event name                                                                                                                                                              |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Awareness     | `page_view`, `inbound_message`, `lead_submitted`, `form_submitted`, `email_opened`, `manual_contact_created`, `imported`                                                |
| Consideration | `email_clicked`, `whatsapp_replied`, `product_viewed`, `add_to_cart`, `booking_started`, `pricing_page_viewed`                                                          |
| Decision      | `checkout_started`, `appointment_booked`, `subscription_started`, `trial_started`, `purchase`, `deal_won`, `booking_confirmed`, `reservation_confirmed`, `invoice_paid` |
| Retention     | `order_delivered`, `appointment_attended`, `subscription_renewed`, `repeat_purchase`, `product_reviewed`                                                                |
| Advocacy      | `referral_made`, `review_5_star`, `ugc_posted`, `affiliate_signup`                                                                                                      |

## What an event means

`event` is your word. `type` is ours, and there are seven 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.

| `type`             | What it means           | Revenue       | Counts as an order |
| ------------------ | ----------------------- | ------------- | ------------------ |
| `purchase`         | Money received          | **+ `value`** | Yes                |
| `refund`           | Money returned          | **− `value`** | No                 |
| `booking`          | Committed, not yet paid | —             | No                 |
| `lead`             | Identity captured       | —             | No                 |
| `checkout_started` | Started, not finished   | —             | No                 |
| `fulfilled`        | They received it        | —             | No                 |
| `cancelled`        | It ended                | —             | No                 |

```json theme={null}
{
  "event": "treatment_completed",
  "type": "purchase",
  "identity": { "phone": "+962790000000" },
  "value": 100, "currency": "JOD"
}
```

A clinic calls it `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.

<Note>
  **`booking` is the one worth knowing about.** Every other analytics vocabulary
  was designed for online stores, where saying yes *is* paying. A confirmed
  appointment, a signed contract and a free trial are all 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.
</Note>

### If you send nothing

Hamla decides in this order:

1. **`type`, if you sent it.** Always wins.
2. **A `value`.** Money moved, whatever you called the event. A negative amount
   is a refund.
3. **A name Hamla already knows.** Kept so that integrations written before
   `type` existed keep behaving exactly as they did.

If none apply, the event is still recorded, still segmentable and can still
start a campaign — it just does not claim to be revenue.

These are the names in full. You never need them — `type` says the same thing
in any vocabulary — but nothing you already send changes meaning because of
them.

| `type`             | Names Hamla still recognises                                                                              |
| ------------------ | --------------------------------------------------------------------------------------------------------- |
| `purchase`         | `deal_won`, `invoice_paid`, `purchase`, `repeat_purchase`, `subscription_renewed`, `subscription_started` |
| `booking`          | `appointment_booked`, `booking_confirmed`, `reservation_confirmed`, `trial_started`                       |
| `lead`             | `form_submitted`, `hamla_form.submitted`, `hamla_link.submitted`, `lead_submitted`                        |
| `checkout_started` | `add_to_cart`, `booking_started`, `checkout_started`                                                      |
| `fulfilled`        | `appointment_attended`, `order_delivered`                                                                 |
| `cancelled`        | `appointment_cancelled`, `subscription_canceled`, `subscription_expired`                                  |

<Warning>
  **An amount is never counted silently.** If you send a `value` and Hamla cannot
  tell what it means, the response says so in `warnings` rather than storing it
  and moving on. Every response also echoes `interpreted`, so you can see the
  decision on your first request instead of in a report weeks later.
</Warning>

## 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`, not `Businesses-Count`.
* **A field keeps its first type.** Once `businesses_count` is a Number, a later `"lots"` is skipped rather than corrupting what is stored.

## Limits

|                  |                                                                                                                 |
| ---------------- | --------------------------------------------------------------------------------------------------------------- |
| Event name       | 1–64 characters                                                                                                 |
| `properties`     | 32,768 bytes serialized                                                                                         |
| `tags`           | 50 per request, 1–64 characters each                                                                            |
| `idempotencyKey` | 1–200 characters                                                                                                |
| `occurredAt`     | any past time; max 5 minutes ahead                                                                              |
| Batch size       | 500 events per request                                                                                          |
| Rate limit       | 120 requests per minute, per business — a batch request costs one unit regardless of how many events it carries |

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.

```ts theme={null}
import { Hamla } from '@gethamla/node';
const hamla = new Hamla(); // reads HAMLA_API_KEY

hamla.identify({ identity: { email: 'omar@raqmi.co' }, profile: { name: 'Omar Al Saleh' } });
hamla.track({ event: 'subscription_started', identity: { email: 'omar@raqmi.co' } });

await hamla.trackNow({ event: 'purchase', identity: { email: 'omar@raqmi.co' } }); // awaited
await hamla.trackBatch(historicalEvents);                                          // chunked replay
await hamla.flush();                                                               // serverless: drain before freeze
```

`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.
