Receiving results
Outbound webhooks
Events go in; results come back out. When a flow awards a badge, changes points or redeems a reward, Flyhalf can POST that result to a URL you own — signed, retried, and replayable from the dashboard.
Subscribe
Create a subscription in Webhooks out, or over the API. You give a destination URL, the event types you care about, and a secret you'll verify signatures with. Optional custom headers are sent on every delivery.
curl -X POST https://api.flyhalf.run/v1/webhook-subscriptions \ -H "Authorization: Bearer fh_live_…" \ -d '{ "url": "https://hooks.example.com/flyhalf", "event_types": ["badge.awarded", "points.changed"], "secret": "your-verification-secret" }'
Send a real POST to your endpoint any time with
POST /v1/webhook-subscriptions/{id}/test. Use "*" in
event_types to receive everything.
URLs must be public
Webhook URLs must be public http/https endpoints. URLs that resolve to a private, loopback, link-local or metadata address are rejected at subscription time and never called — Flyhalf will not make requests into private networks on your behalf.
Event types
badge.awarded— a participant earned a badge.points.changed— a ledger entry credited or debited points.reward.redeemed— a reward was redeemed.flow.completed— a flow run finished.message.sent— a comms message was dispatched.
The payload envelope
Every delivery has the same outer shape; data varies by type.
tenant is your workspace slug — use it to route deliveries when one
receiver serves several Flyhalf workspaces.
{
"id": "whd_01J8Z…",
"type": "badge.awarded",
"tenant": "acme-loyalty",
"created_at": "2026-07-08T10:15:02+00:00",
"data": {
"participant": { "external_id": "wc_1042" },
"badge": { "key": "big_spender", "tier": "gold" },
"flow_run_id": "…"
}
}
Verify the signature
Each delivery carries an X-Flyhalf-Signature header in the form
t={timestamp},v1={hmac}. The HMAC is sha256 over
"{timestamp}.{raw_body}" keyed with your subscription secret. Reject
anything outside a 5-minute window to stop replays. Verify against the
raw body before parsing it:
$secret = getenv('FLYHALF_WEBHOOK_SECRET'); $payload = file_get_contents('php://input'); $header = $_SERVER['HTTP_X_FLYHALF_SIGNATURE'] ?? ''; if (! preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) { http_response_code(400); exit; } [, $timestamp, $given] = $m; // Outside the 5-minute replay window? Drop it. if (abs(time() - (int) $timestamp) > 300) { http_response_code(400); exit; } $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret); if (! hash_equals($expected, $given)) { http_response_code(400); exit; } // Verified. Respond 2xx fast, then process out of band. http_response_code(200);
Retries and dead-letter
Your endpoint has 10 seconds to answer. Any non-2xx status or a timeout counts as a failure. After the initial attempt, Flyhalf retries on this ladder:
- +1 minute
- +5 minutes
- +30 minutes
- +2 hours
- +12 hours
That's the initial attempt plus five retries. If the last one still fails, the delivery is marked dead and stops. Every attempt records its response code and a snippet of the body in the delivery log.
Replaying a dead delivery
The delivery log has a replay button — it re-dispatches the same delivery with its attempt count reset, so you can push a batch through again after fixing your endpoint. A subscription that's paused or removed makes its pending deliveries go dead immediately.
Respond 2xx as fast as you can and do the real work asynchronously — holding the connection open risks the 10-second timeout and an unnecessary retry. Sandbox results never leave the building: simulated deliveries are logged but never POSTed.