# Events API

Send your product's events to Mara. One endpoint, two operations: `identify` tells Mara who a contact is, `track` tells her what happened. Any active journey whose trigger matches an incoming event fires automatically, per contact.

This page is the complete spec, written for humans and for AI coding agents. The raw markdown lives at [hiremara.com/docs/events-api.md](https://hiremara.com/docs/events-api.md). The fastest path: open Settings, then Integrations, then Events API in your dashboard and copy the ready-made prompt into Claude Code, Cursor, or whatever agent writes your code. It links back here.

If your "events" are billing events from Stripe or Polar, do not send them through this API. Point your provider's webhooks at Mara instead, so payloads arrive signature-verified and normalized. See [Webhooks](/docs/webhooks).

## Endpoint

```
POST https://hiremara.com/api/tenants/{tenantId}/events
Content-Type: application/json
Authorization: Bearer {your ingestion key}
```

Your exact endpoint URL, with your tenant id filled in, is shown in Settings under Integrations, Events API.

## Authentication

Auth is a per-tenant bearer key with the prefix `mara_ing_`. Generate it on the same settings row. The raw key is shown exactly once at generation; Mara stores only a hash. Generating a new key immediately revokes the old one.

This is a server-to-server key. Keep it in an environment variable on your backend. Never ship it to a browser or a mobile client.

## Request body

The body is a single operation, or a batch of up to 100:

```json
{
  "batch": [
    {
      "type": "identify",
      "userId": "u_42",
      "email": "ada@example.com",
      "name": "Ada",
      "traits": { "plan": "starter" }
    },
    { "type": "track", "event": "user.signed_up", "userId": "u_42", "messageId": "evt_001" }
  ]
}
```

### identify

Upserts the contact. Pure projection: it never writes an event and never fires a journey.

| Field        | Type         | Required                                        | Notes                                                          |
| ------------ | ------------ | ----------------------------------------------- | -------------------------------------------------------------- |
| `type`       | `"identify"` | yes                                             |                                                                |
| `userId`     | string       | at least one of `userId`, `externalId`, `email` | Your stable user id.                                           |
| `externalId` | string       |                                                 | Same meaning as `userId`. If both are sent, `externalId` wins. |
| `email`      | string       |                                                 | Must be a valid email address.                                 |
| `name`       | string       | no                                              | Display name.                                                  |
| `traits`     | object       | no                                              | Custom attributes, flat scalars only. See sanitization below.  |

### track

Records an event against a contact and fires any matching journeys.

| Field                             | Type      | Required          | Notes                                                                               |
| --------------------------------- | --------- | ----------------- | ----------------------------------------------------------------------------------- |
| `type`                            | `"track"` | yes               |                                                                                     |
| `event`                           | string    | yes               | The event type, 1 to 120 characters. See naming below.                              |
| `userId` / `externalId` / `email` | string    | send at least one | Same identity rules as identify. A track with no identity is skipped, not an error. |
| `properties`                      | object    | no                | Event details, flat scalars only. Same sanitization as traits.                      |
| `timestamp`                       | string    | no                | ISO 8601. Defaults to arrival time. Invalid values fall back to arrival time.       |
| `messageId`                       | string    | no                | Your idempotency key for this event. Strongly recommended.                          |

### Identity resolution

`externalId` (or `userId`) is the contact's primary identity; `email` matches or creates the contact when no external id is sent. Send the same `userId` from signup onward and Mara keeps one contact per user, even if the email changes later. Contacts that arrive with an email get lifecycle consent recorded automatically, so journey sends are not blocked downstream.

### Traits and properties sanitization

`traits` and `properties` become contact attributes and event details that Mara's agents can reference in copy, so they are filtered at the door:

- Flat objects only. Nested objects and arrays are dropped.
- Values must be strings, finite numbers, or booleans. Strings are trimmed and truncated at 1024 characters.
- Keys: up to 64 characters from letters, digits, `_`, `.`, `:`, `-`. At most 50 keys survive per object.
- Keys that look like secrets or sensitive PII (password, token, api_key, ssn, card numbers, and similar) are dropped outright. Do not send secrets.

## Idempotency and retries

Set `messageId` on every track op: a UUID, or the id of the row in your own event log. Retrying a request with the same `messageId` is safe; the duplicate op returns `"status": "duplicate"` and writes nothing. Identify ops are upserts and naturally safe to retry.

## Rate limits

600 requests per hour per tenant, token bucket, burst up to 600. A batch of 100 operations counts as one request, so batching gives you up to 60,000 events per hour. Over the limit you get a 429 with a `Retry-After` header in seconds. Back off and retry after that.

## Responses

Success is a 200 with one result per operation, in order:

```json
{
  "ok": true,
  "results": [
    { "index": 0, "type": "identify", "status": "ok", "contactId": "9a1f..." },
    { "index": 1, "type": "track", "status": "ok", "contactId": "9a1f..." }
  ]
}
```

Per-op `status` values: `ok`, `skipped_no_identity` (the op carried no usable identity), `duplicate` (the `messageId` was already processed).

Errors return `{ "ok": false, "code": "...", "message": "..." }`:

| HTTP | `code`             | Meaning                                                                   |
| ---- | ------------------ | ------------------------------------------------------------------------- |
| 400  | `bad_request`      | Body is not valid JSON or fails the schema.                               |
| 401  | `unauthorized`     | Missing or wrong bearer key.                                              |
| 412  | `no_ingestion_key` | No key has been generated for this tenant yet.                            |
| 429  | `rate_limited`     | Over the hourly cap. Honor `Retry-After`.                                 |
| 500  | `infra`            | Something broke on Mara's side. Safe to retry with the same `messageId`s. |

## How events fire journeys

Event types are free-form. Two ways they connect to journeys:

1. **The signup event.** On the same settings row, tell Mara which event means "a new user signed up" (for example `user.signed_up`). Saving it seeds a draft welcome journey that fires on that event; you approve the draft from your dashboard to make it live.
2. **Any other event.** Ask Mara in chat. "When a user fires `project.created`, start the activation journey" is enough; she drafts the journey with an on-event trigger and you approve it.

Naming guidance: lowercase, dot-namespaced, object.action. `user.signed_up`, `project.created`, `report.exported`. Allowed characters are letters, digits, spaces, `.`, `_`, `:`, `-`, `/`, up to 120 characters. Keep types low-cardinality: no user ids or timestamps inside the type string. Details belong in `properties`.

## Quickstart

Test with curl (the endpoint URL and key come from your settings page):

```bash
curl -X POST "https://hiremara.com/api/tenants/{tenantId}/events" \
  -H "Authorization: Bearer $MARA_INGESTION_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "batch": [
      { "type": "identify", "userId": "u_42", "email": "ada@example.com", "name": "Ada" },
      { "type": "track", "event": "user.signed_up", "userId": "u_42", "messageId": "test-001" }
    ]
  }'
```

A minimal Node helper:

```js
const MARA_EVENTS_URL = process.env.MARA_EVENTS_URL; // from your settings page
const MARA_INGESTION_KEY = process.env.MARA_INGESTION_KEY;

export async function maraSend(ops) {
  const res = await fetch(MARA_EVENTS_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${MARA_INGESTION_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ batch: ops }),
  });
  if (!res.ok) throw new Error(`Mara events API ${res.status}: ${await res.text()}`);
  return res.json();
}

// At signup:
await maraSend([
  { type: 'identify', userId: user.id, email: user.email, name: user.name },
  { type: 'track', event: 'user.signed_up', userId: user.id, messageId: `signup-${user.id}` },
]);
```

Call Mara off the request path (a queue, a background job, or at minimum fire-and-forget with a catch). An email platform should never be able to slow your product down.

## Verify it landed

Three checks, in order:

1. The HTTP response: 200 with `"status": "ok"` per op.
2. Settings, Integrations: the ingestion health strip shows per-connector contact counts and last activity.
3. Ask Mara in chat: "did you get my test event?"

## For AI coding agents

If you are an agent implementing this integration, the checklist:

1. Put the endpoint URL and ingestion key in env vars (`MARA_EVENTS_URL`, `MARA_INGESTION_KEY`). Never hardcode the key; never expose it client-side.
2. Add a small server-side helper that POSTs `{ batch: [...] }` with the bearer header. Batch when you can, up to 100 ops.
3. On signup: send one `identify` (userId, email, name, useful traits) plus one `track` with the agreed signup event type.
4. Instrument the 3 to 5 lifecycle moments that matter (activation milestone, key feature used, plan limit reached). Stable dot-namespaced event types, details in `properties`.
5. Set a deterministic `messageId` per event so retries are safe. Retry on 429 (honor `Retry-After`) and on 5xx; do not retry 4xx.
6. Keep Mara off the hot path: queue or fire-and-forget. A Mara outage must not break signup.
7. Verify with the curl above, then confirm in the response that every op returned `"status": "ok"`.
