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
(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:
audienceis optional. Omit it and the server applies your one configured audience as the implicit default. - Two or more audiences:
audienceis required. Omit it and the request fails with400 missing_audience. Send a value that isn't one of your configured audiences and it fails with400 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_assignedHatched 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.
}