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.