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.