Rewards

Rewards & redemption

Points buy things. Publish a catalogue of rewards — vouchers, digital assets, physical prizes, third-party coupons — and let participants spend their balance to redeem them. Flyhalf validates balance and stock atomically, so concurrent redemptions can't oversell or overdraw.

The catalogue

GET /v1/rewards returns the active catalogue with presentation fields (subtitle, description, image_url), the point cost_points, live stock_remaining, and is_claimable (active, inside its window, and in stock).

List the catalogue bash
curl https://api.flyhalf.run/v1/rewards \
  -H "Authorization: Bearer fh_live_…"

For a single participant, GET /v1/participants/{external_id}/rewards returns the same catalogue plus can_afford computed against their balance, and meta.balance — their spendable points. Use it to render a storefront where each card knows whether this person can claim it right now.

Participant catalogue json
{
  "data": [
    {
      "id": 12,
      "name": "R50 airtime voucher",
      "subtitle": "Any network",
      "image_url": "https://…/rewards/1/abc.png",
      "type": "voucher_code",
      "cost_points": 500,
      "stock_remaining": 88,
      "is_claimable": true,
      "can_afford": true
    }
  ],
  "meta": { "balance": 750 }
}

Reward types

  • voucher_code — hands out a code from a pool you pre-load (see Code pools below). Stock tracks the remaining codes.
  • digital_asset — a virtual reward (gift card, licence key, download link). Load a code pool to hand out one-time codes, or deliver from config when there's no pool.
  • physical — a physical prize; the redemption starts pending and you fulfil it out of band.
  • points_multiplier — a perk that multiplies future point earning.
  • external — the code is generated on the fly by a third-party endpoint you configure (see below).

Code pools (manual fulfilment)

The manual alternative to on-the-fly external generation: pre-load a batch of codes and Flyhalf hands out one per redemption. In the dashboard, open the reward and use Import codes to bulk-load from a supplier — paste them or upload a CSV/TXT file (one code per line; first CSV column; duplicates skipped) — or Generate codes to mint a batch with an optional prefix. Available codes are the reward's stock; each redemption claims the next one and marks it redeemed. Works for voucher_code and digital_asset (virtual) rewards.

Redeem

POST /v1/redemptions spends the participant's points and returns the fulfilment. Identify the reward by reward_id or by reward (its name). Pass an idempotency_key so a retried call never double-charges — the replay returns the original redemption untouched.

Redeem a reward bash
curl -X POST https://api.flyhalf.run/v1/redemptions \
  -H "Authorization: Bearer fh_live_…" \
  -d '{
    "participant": "wc_1042",
    "reward_id": 12,
    "idempotency_key": "redeem-abc"
  }'

Success is 201 with the redemption and its fulfilment — for a code-backed reward the code is right there:

Fulfilment response json
{
  "id": 3391,
  "reward_id": 12,
  "status": "fulfilled",
  "fulfilment": {
    "type": "voucher_code",
    "code": "AIR-7F2K-9QLM"
  },
  "is_sandbox": false,
  "created_at": "2026-07-08T10:15:02+00:00"
}

A business-rule rejection is 422 with an error.type you can switch on:

  • insufficient_points — the participant can't afford it.
  • out_of_stock — no stock (or no codes) left.
  • reward_unavailable — inactive, or outside its start/end window.
  • external_fulfilment_failed — the third-party endpoint didn't deliver a code (see below).
Rejection envelope json
{
  "error": {
    "type": "insufficient_points",
    "message": "Participant has 300 points; this reward costs 500."
  }
}

External fulfilment

An external reward fetches its code from an endpoint you own at redemption time — handy when a partner mints coupon codes on demand. Configure it under Rewards → your reward → External fulfilment. The settings ride on the reward's config:

Reward config (external) json
{
  "endpoint_url": "https://coupons.partner.com/generate",
  "method": "POST",
  "headers": { "Authorization": "Bearer …" },
  "body": {
    "customer": "{{participant.external_id}}",
    "reward":   "{{reward.name}}",
    "reference": "{{reference}}"
  },
  "code_path": "data.coupon_code"
}

String values in headers and body may use these tokens, which are substituted at redemption time (plain allowlisted substitution — never evaluated):

  • {{participant.external_id}}, {{participant.email}}, {{participant.name}}
  • {{reward.name}}
  • {{reference}} — a stable reference (the idempotency key) so the partner can be idempotent too.

code_path is the dot-path to the code in the JSON response — for the shape above, data.coupon_code. Use the Test fulfilment endpoint action on the reward's edit page to fire a live request through the same guard before you go live.

The debit is reversed if the partner fails

External codes are fetched after the points debit commits, outside the transaction (no HTTP under a row lock). If the endpoint is unreachable, returns a non-2xx, or has no code at code_path, Flyhalf reverses the points debit, restores the stock unit, cancels the redemption, and returns 422 with external_fulfilment_failed — the participant never loses points for a code that never arrived.

URLs must be public

The fulfilment endpoint is called through the same SSRF egress guard as outbound webhooks: http/https only, no redirects, and any URL that resolves to a private, loopback, link-local or metadata address is rejected. Flyhalf will not make requests into private networks on your behalf.