HatchedDocs
Guides

Send events

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

Events are the only way the outside world changes a buddy. Everything the rule engine does starts with a POST /events.

Shape

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
});
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"
  }'
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()

The SDK serialises camelCase field names to snake_case on the wire (userIduser_id, occurredAtoccurred_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:

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

Hatched stores eventIds 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:

await hatched.events.sendBatch([
  { eventId: 'e1', userId, type: 'lesson_completed', properties: { ... } },
  { eventId: 'e2', userId, type: 'quiz_passed',      properties: { ... } },
]);
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": {} }
    ]
  }'
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()

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:

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.

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