# Send events

> What to send, when to send it, and how Hatched turns events into effects.

Source: https://docs.hatched.live/docs/guides/send-events

import { Tab, Tabs } from 'fumadocs-ui/components/tabs';

Events are the only way the outside world changes a buddy. Everything the
[rule engine](/docs/concepts/rule-engine) does starts with a `POST /events`.

## Shape

<Tabs items={['TypeScript', 'curl', 'Python']}>
  <Tab value="TypeScript">
    ```ts
    await hatched.events.send({
      eventId: 'evt_01HXYZ', // for idempotency
      userId: 'user_42',
      type: 'lesson_completed',
      audience: 'student', // required only if you have 2+ audiences
      properties: {
        lessonId: 'lesson_17',
        durationMs: 5 * 60 * 1000,
        score: 0.92,
      },
      occurredAt: '2026-04-22T10:30:00Z', // optional; defaults to now
    });
    ```
  </Tab>
  <Tab value="curl">
    ```bash
    curl -X POST https://api.hatched.live/api/v1/events \
      -H "Authorization: Bearer $HATCHED_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "event_id": "evt_01HXYZ",
        "user_id": "user_42",
        "type": "lesson_completed",
        "audience": "student",
        "properties": {
          "lesson_id": "lesson_17",
          "duration_ms": 300000,
          "score": 0.92
        },
        "occurred_at": "2026-04-22T10:30:00Z"
      }'
    ```
  </Tab>
  <Tab value="Python">
    ```py
    import os, requests

    response = requests.post(
        "https://api.hatched.live/api/v1/events",
        headers={
            "Authorization": f"Bearer {os.environ['HATCHED_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "event_id": "evt_01HXYZ",
            "user_id": "user_42",
            "type": "lesson_completed",
            "audience": "student",
            "properties": {
                "lesson_id": "lesson_17",
                "duration_ms": 300_000,
                "score": 0.92,
            },
            "occurred_at": "2026-04-22T10:30:00Z",
        },
        timeout=10,
    )
    response.raise_for_status()
    effects = response.json()
    ```
  </Tab>
</Tabs>

The SDK serialises camelCase field names to snake_case on the wire
(`userId` → `user_id`, `occurredAt` → `occurred_at`). When you call the
HTTP API directly — curl, Python, Go, Rust — send snake_case yourself.

## Audience

Every event belongs to an audience (the role a user plays — `student`,
`teacher`, `admin`). The field is `audience` in the SDK (camelCase) and
`audience` on the wire (already snake_case). Values are lowercase
snake_case, max 32 characters.

- **Single-audience customer:** `audience` is optional. Omit it and the
  server applies your one configured audience as the implicit default.
- **Two or more audiences:** `audience` is required. Omit it and the
  request fails with `400 missing_audience`. Send a value that isn't one
  of your configured audiences and it fails with `400 unknown_audience`.

## Pick stable event types

Event types are the string keys rules match against. Choose them once and
don't rename them — existing coin rules, badge conditions, and analytics
queries reference them. Use snake_case, present-tense verbs:

```
lesson_completed
lesson_started
daily_login
checkout_completed
quiz_passed
task_assigned
```

Hatched validates event types before reserving quota. If the type is not
registered for the resolved audience, the request fails with
`event_type_not_registered`. Applying a dashboard preset or generated plan
registers the event types referenced by that plan; custom integrations should
create the type before the first production event.

## Properties are yours

The rule engine doesn't require a fixed property shape — you define it.
Whatever you send becomes queryable via custom conditions and visible in
the event log. Stay consistent: if `durationMs` exists for
`lesson_completed`, always include it.

## Idempotency

Pass a stable `eventId` and you're safe to retry:

```ts
await hatched.events.send({
  eventId: `lesson_${lessonId}_${userId}`,
  userId,
  type: 'lesson_completed',
  properties,
});
```

Hatched stores `eventId`s per customer and returns the cached effect on
duplicate submissions without re-applying rules or charging event quota again.
Without `eventId`, retries can produce duplicate effects.

## Order doesn't matter (usually)

Events for the same buddy serialise on a row lock. You can send them in
parallel — the rule engine will process them one at a time. You don't need
a queue on your side unless you want ordering guarantees across different
users.

## Batch mode

For bulk imports, send up to 100 events in a single request:

<Tabs items={['TypeScript', 'curl', 'Python']}>
  <Tab value="TypeScript">
    ```ts
    await hatched.events.sendBatch([
      { eventId: 'e1', userId, type: 'lesson_completed', properties: { ... } },
      { eventId: 'e2', userId, type: 'quiz_passed',      properties: { ... } },
    ]);
    ```
  </Tab>
  <Tab value="curl">
    ```bash
    curl -X POST https://api.hatched.live/api/v1/events/batch \
      -H "Authorization: Bearer $HATCHED_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "events": [
          { "event_id": "e1", "user_id": "user_42", "type": "lesson_completed", "properties": {} },
          { "event_id": "e2", "user_id": "user_42", "type": "quiz_passed",      "properties": {} }
        ]
      }'
    ```
  </Tab>
  <Tab value="Python">
    ```py
    requests.post(
        "https://api.hatched.live/api/v1/events/batch",
        headers={
            "Authorization": f"Bearer {os.environ['HATCHED_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "events": [
                {"event_id": "e1", "user_id": "user_42", "type": "lesson_completed", "properties": {}},
                {"event_id": "e2", "user_id": "user_42", "type": "quiz_passed",      "properties": {}},
            ],
        },
        timeout=15,
    ).raise_for_status()
    ```
  </Tab>
</Tabs>

Each event in the batch carries its own `audience` (camelCase `audience` in
the SDK, `audience` on the wire), resolved per event with the same rules as a
single `send`: optional for single-audience customers, required once you have
two or more. The API validates the whole batch before reserving quota or
applying effects. If any event type is not registered for the audience it
resolves to, the request fails and no event quota is committed.

## Return shape

`send` resolves with the effects the rule engine applied:

```ts
const effects = await hatched.events.send({ ... });
console.log(effects);
// {
//   coins: 10,
//   badgesAwarded: ['first_lesson'],
//   badgesReady: [],
//   tokens: [],
//   evolutionReady: false,
//   streakMilestones: [],
// }
```

Use `effects.badgesAwarded` or `effects.evolutionReady` to trigger
celebratory UI on your side the same tick as the event fires.

When an event is accepted but produces no visible state change, the response
includes a debug reason. In the SDK this appears as `effects.debugReason`; in
raw HTTP it is also exposed as top-level `debug_reason`.

```ts
const effects = await hatched.events.send({ ... });

if (effects.debugReason === 'no_active_buddies_for_user') {
  // The user_id/audience has no active buddy yet.
}

if (effects.debugReason === 'no_matching_rules') {
  // The event type is registered, but the published rules do not act on it.
}
```
