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.

Create a subscription bash
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.

Delivery body json
{
  "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:

Verify — PHP php
$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.