Sending events

Events

An event is one thing that happened to one participant — an order, a check-in, a referral. You send JSON; Flyhalf upserts the participant, records the event, and queues your flows. There's no schema to register first.

Two ways in

RouteAuthWhen
POST /v1/ingest/{key} Endpoint key + X-Signature HMAC Webhooks from a store or POS — no bearer key on the sender.
POST /v1/events Bearer API key, ability events:write Server-to-server from your own backend.

Both share the exact same body convention and pipeline. The only difference is how they authenticate — see Signing events for the ingest signature.

The body convention

Event body json
{
  "event_type": "order.completed",
  "participant": {
    "external_id": "wc_1042",
    "email": "sam@example.com",
    "phone": "+27821234567",
    "name": "Sam Ndlovu"
  },
  "payload": { "order_id": 5512, "total": 749.00, "items": 3 },
  "occurred_at": "2026-07-08T10:15:00Z",
  "idempotency_key": "order-5512-completed"
}
FieldRequiredNotes
event_typeYes*Dot-notation key. *Optional if the ingest endpoint has a default event type.
participant.external_idYesYour ID for the person. Everything hangs off this.
participant.email / phone / nameNoEnrichment. See upsert rules below.
payloadNoAny JSON object. Its fields are available to flow conditions.
occurred_atNoISO-8601. Defaults to now; an unparseable value falls back to now.
idempotency_keyNoDeduplicates retries. See Idempotency.

Participants upsert on ingest

You never pre-create participants. The first event that references an external_id creates one; later events match by it. PII is enrich-only: an event fills in email, phone or name that are currently empty, but never overwrites a value that's already set. To replace existing PII, use PATCH /v1/participants/{external_id}.

Event types are created on first use

Send any event_type string and Flyhalf creates the type automatically the first time it sees it. There's no registration step — order.completed and quiz.passed just start existing. Define a schema for a type later in the dashboard if you want richer flow variables.

The response

Both routes accept and queue in one shot: 202 with {"event_id","status"}. Processing — matching flows, awarding points and badges, sending messages — happens asynchronously on the ingest queue. A normal event comes back status: "received".

Quarantine instead of rejection

An event missing event_type or participant.external_id is not dropped — it's stored with status: "quarantined" and the reason, and still returns a 202 with its event_id. It just never processes. Check Ingestion → Event log if a badge you expected never fired. Fix the body and resend with a new idempotency key.

Broadcasting to a segment

Some events happen to an audience, not to one person: it starts raining over a store, a product is back in stock. POST /v1/events/broadcast sends one event to every customer in a segment, and any flow triggered by that event type runs for each of them. It needs the events:broadcast ability.

POST /v1/events/broadcast json
{
  "event_type": "weather.rain",
  "segment": "locals-rosebank",
  "payload": { "store": "Rosebank" },
  "idempotency_key": "rain-rosebank-2026-09-30"
}

The response is 202 with the segment's size in audience. Delivery is queued. A broadcast is not something the customer did, so it never moves last_activity_at. Event types that Flyhalf emits itself (participant.*, streak.*, quest.*, achievement.*, commerce.*, link.*) are reserved. Send the same idempotency_key on a retry and nobody receives it twice.

Counting by a payload field

A flow condition, badge rule, segment or challenge step can count events by the value of a top-level payload field: how many different stores a customer has ordered from, or how many times they have ordered this burger. This condition passes on the fifth order of the burger in the current event:

Flow condition json
{
  "type": "condition.aggregate",
  "event_type": "order.completed",
  "metric": "count",
  "field": "burger",
  "equals": "event.payload.burger",
  "op": "=",
  "value": 5,
  "window": { "days": 365 }
}

Use "metric": "distinct" with a field and no equals to count different values. The field can hold one value or a list; each item in a list is counted. Flyhalf starts counting a field when a published flow, badge or segment first uses it, and fills in the history once. A challenge counts from the day the customer starts it. The fields being counted are listed on the event type, under Counted details.

Time of day and day of week

Every event carries the moment it happened in your workspace's time zone, so a rule can depend on when something was done. In a condition use event.time.hour (0 to 23), event.time.weekday (1 is Monday, 7 is Sunday), event.time.weekday_name, event.time.is_weekend and event.time.date. In a trigger filter, drop the event. prefix: time.hour.