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
| Route | Auth | When |
|---|---|---|
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_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"
}
| Field | Required | Notes |
|---|---|---|
event_type | Yes* | Dot-notation key. *Optional if the ingest endpoint has a default event type. |
participant.external_id | Yes | Your ID for the person. Everything hangs off this. |
participant.email / phone / name | No | Enrichment. See upsert rules below. |
payload | No | Any JSON object. Its fields are available to flow conditions. |
occurred_at | No | ISO-8601. Defaults to now; an unparseable value falls back to now. |
idempotency_key | No | Deduplicates 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.
{
"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:
{
"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.