Skip to content

Rewards till API and terminal keys

This page is for whoever connects a till, a POS system or a kiosk to CaptiFi Rewards: a till provider, an in-house developer, or a market hall that wants each trader's stand to honour the venue's rewards. It covers how to create a terminal and its API key on either dashboard, the till API itself (authentication, limits, each endpoint, idempotency and error codes), what the dashboards show afterwards, and the older WiFi voucher endpoints that some tills already call.

Plan

Rewards is included on the Growth plan and above. See Plans & Pricing.

Where the till API stands

The till API is live at https://app.captifi.io/api/v1/rewards-pos. A till authenticates with the key each terminal is given when it is created, and can check a code before honouring it, redeem a reward or a shared code against a bill, reverse a sale it has just made, look a member up, award points for a sale with no reward, and check a shared code such as SUMMER20. There are six endpoints, all POST with JSON bodies:

You want toEndpoint
Check what a scanned or typed code is and whether it can be used herePOST /validate
Redeem a reward or shared code against a billPOST /redeem
Reverse a sale made in the last 30 minutesPOST /redemptions/{reference}/void
Find a member by email or phonePOST /members/find
Award points for a sale with no rewardPOST /points/earn
Check a shared code for this venue and this billPOST /offers/validate

Every success is JSON with the result under data and meta.terminal naming the terminal that made the call:

json
{
  "data": { },
  "meta": {
    "terminal": {
      "id": 12,
      "name": "Front till",
      "kind": "pos",
      "venue": { "site_id": 17110, "site_name": "The Harbour Inn" }
    }
  }
}

Every refusal is {"error": {"code": "...", "message": "...", ...}} with a stable code, the HTTP status listed in the error table and, where useful, extra fields such as the reference of the redemption that got there first. A body that fails field validation is 422 with {"message": "...", "errors": {"field": ["..."]}}. Responses are JSON whatever Accept header the till sends, and an unknown path under the prefix is 404 with a message.

Amounts are whole minor units of the venue's currency (pence, cents), sent and returned as integers. Every currency is an ISO 4217 code, accepted in upper or lower case and returned in upper case.

Without an integration, a till honours rewards the way bar staff do, through the Redeem screen in either dashboard. The next section lists what that gives you.

What a till can do without an integration

Everything below happens on Rewards → Redeem (my.captifi.io or app.captifi.io). The person using it needs the Scan and check in at the door or till permission, for awarding points too. The step-by-step version for staff is Redeeming at the Till.

You want toWhat to do
Check a code before honouring itScan the guest's QR code or type the eight-character code. The card shows the offer, its value, the venue, the expiry and any minimum spend. If the reward cannot be used here and now, the card says why: already redeemed (with when, where, how and by whom), expired, cancelled, locked after wrong PINs, not valid at this venue, priced in another currency, or below the minimum spend
Redeem a reward against a billType the bill total in the venue's currency and tap Redeem. The green screen shows the value to take off the bill and a reference such as RDM-7F3K9Q
Find a memberType their member number, email address or phone number. The card shows the member's first name, last initial, member number and points balance, and every reward they have available. Their email address and phone number are never shown on this screen
Award points for a sale with no rewardLook the member up, type the bill total and the receipt number under Award points for this sale, and tap Award points. The same receipt number keyed a second time earns nothing more
Undo a mistaken redemptionOn the classic dashboard, the green screen offers Made a mistake? Void it within 30 minutes; on my.captifi.io, Made a mistake? Void this redemption while the green screen is showing. Either needs Manage rewards and offers and a reason. Later than that, a manager voids from Rewards → Redemptions

Terminals and the till API key

A terminal is anything that redeems: a bar, a trader's stall, a kiosk or a till. Each one has a six-digit PIN that guests type on their own phone, a till API key, and its own line on the settlement report. The PIN, its weekly rotation and the email that carries it are covered under Terminals on the Rewards page. This section is about the key.

Creating and managing terminals needs the Manage rewards and offers permission, and your programme must be set up first.

Creating a terminal on my.captifi.io

  1. Go to Rewards → Terminals and click New terminal

  2. Choose the Venue. Only a venue that is in your programme and billed to your account is offered

  3. Type a Name of up to 60 characters, such as "Front till" or the trader's stall, and pick the Kind: Bar, Trader, Kiosk or Till (POS). The kind is a label for your lists and reports; every kind gets a key and a PIN, and every kind may call every endpoint of the till API

  4. Fill in Settlement party if someone other than the venue is paid back for the discounts this terminal honours, Contact email if the weekly PIN should be emailed to someone, and choose the PIN rotation (Weekly or Manual). Both text fields are optional

  5. Click Create terminal

  6. A panel headed Terminal created shows the Till API key and the Terminal PIN, each with a copy button. Copy both now. The panel does not close on a tap outside it or on Escape; click I have copied them when you have. Neither value is shown again

Creating a terminal on the classic dashboard (app.captifi.io)

  1. Go to Rewards → Terminals and click New terminal

  2. Fill in the same form: Venue, Name, Kind (Bar, Trader or stall, Kiosk or Till (POS)), Settlement party (optional), Contact email (optional) and PIN rotation

  3. Click Create. You land on the Terminals list with a banner at the top, Terminal created, showing the Till API key and the Terminal PIN once. The banner is gone on the next page load, so copy both before you go anywhere else

The key

A key looks like this:

text
captifi_rt_7f3k9qpxm2ab_0v8n2rq5tk1zjm4w6yh9pd3s7xbc0fge

It is captifi_rt_, a 12-character lookup segment, an underscore and a 32-character secret, in lower-case letters and digits. CaptiFi keeps only a one-way hash of the key, so it cannot be read back from our records and neither dashboard can show it again. The terminal list shows the lookup segment, a run of asterisks and the last four characters, which is enough to tell two keys apart. The classic dashboard's list also has a Key last used column, which reads Never until something authenticates with the key; the API moves it at most once a minute, so a busy till does not update it on every call.

New API key (the key icon on my.captifi.io) replaces the key straight away and shows the new one once. Anything using the old key stops working the moment you confirm, so if a till is live, have somewhere to paste the new key before you click. Each replacement is logged.

Deactivate stops the key authenticating and the PIN resolving. The terminal stays in your list and its past redemptions stay on the settlement report. On the classic dashboard the row then offers Reactivate, and the Active switch on the edit form does the same; on my.captifi.io a deactivated terminal is marked Deactivated and has no actions, so use the classic dashboard to bring one back.

Offers and the till channel

Each offer's How it can be redeemed list has a tick for the till channel, labelled Till API on my.captifi.io and Till integration on the classic dashboard. It is on by default and decides whether that offer can be honoured through the till API, separately from staff scans, typed codes and trader PINs. With the tick off, /validate lists channel_disabled among its reasons and /redeem answers 409 REWARD_CHANNEL_DISABLED. In the API, the webhooks and the Redemptions filter (Till API, or Till on the classic dashboard) this channel is pos_api.

Currencies

Each venue in your programme settles in its own currency, one of GBP, USD, EUR, CAD, AUD or NZD, set on Rewards → Setup. Bill totals on the Redeem screen are typed in the venue's currency, and a till sends them as basket_total_minor with the venue's currency; a bill in any other currency is refused with 422 SPEND_CURRENCY_MISMATCH naming the venue's currency. A fixed-amount, free-item or custom reward is priced in the currency of its offer and is refused at a venue trading in another one, with 409 REWARD_CURRENCY_MISMATCH and the message "This reward is priced in a different currency from this venue"; a percentage off is worked out on the bill, so it is honoured at any venue in the programme and settles in that venue's currency. See Venues and currency.

Authentication

Send the terminal's key on every request, as a bearer token or, for a till that cannot set the Authorization header, as X-API-KEY:

text
Authorization: Bearer captifi_rt_7f3k9qpxm2ab_0v8n2rq5tk1zjm4w6yh9pd3s7xbc0fge
X-API-KEY: captifi_rt_7f3k9qpxm2ab_0v8n2rq5tk1zjm4w6yh9pd3s7xbc0fge

The bearer token is read first and X-API-KEY only when there is no bearer token, so a wrong Authorization header beside a right X-API-KEY is refused. Send one or the other.

A missing, malformed, unknown, mismatched or deactivated key is one and the same answer, so nothing can be learned about which part was wrong:

json
{ "error": { "code": "INVALID_TERMINAL_KEY", "message": "The terminal key is not valid." } }

That answer is 401. A key that authenticates can still be turned away by the account it belongs to, with 403: plan_upgrade_required (sent as a flat error string with a meta block naming the plan) when the account is below the Growth plan, REWARDS_DISABLED when Rewards is switched off under Integrations → Dashboard features, and DEMO_ACCOUNT_LOCKED on the shared demo account. These are the same three answers the dashboards give.

Every kind of terminal may call every endpoint. A key is never written to our logs; a refused call is recorded with a one-way fingerprint of it, which is enough to see that the same wrong key keeps being sent and useless for recovering it.

Rate limits

Two buckets apply to every request, and either can answer 429:

RequestOwn bucketShared per-address bucket
A live terminal120 a minute per terminal600 a minute for every live terminal behind the same address
A wrong, deactivated or unknown key120 a minute per key60 a minute for every refused or key-less request from the address
No key at allCounted on the addressThe same 60 a minute

The two shared buckets are separate on purpose. A venue's tills usually share one public address with its guest WiFi, so a guest sending wrong keys from their phone spends the refusal bucket and never the one the bar's tills run on. The 600 a minute is sized for several tills behind one router.

Over either limit the answer is 429 with a Retry-After header giving the seconds to wait:

json
{ "error": { "code": "RATE_LIMITED", "message": "Too many requests from this terminal. Wait a minute and try again." } }

A till should wait Retry-After seconds and send the same request again. For a redeem, send it with the same Idempotency-Key, so a request that did get through before the 429 is replayed and never doubled (see Idempotency and retries).

Endpoints

All six are POST under https://app.captifi.io/api/v1/rewards-pos. Each accepts an optional site_id; the terminal already knows its venue, so the field exists only as a safety check and must match (see Venue rules). Fields not listed are ignored.

POST /api/v1/rewards-pos/validate

What a scanned or typed thing is, what it is worth against this bill and every reason it could not be used here, without consuming anything. Call it when the code is scanned, before the sale is closed.

FieldRequiredNotes
codeOne of code or urlUp to 512 characters. A typed reward code with or without its hyphen, a bare reward token, a shared offer code such as SUMMER20, a member number, an email address or a phone number
urlOne of code or urlThe reward link from a scanned QR code (/r/...) or a member link (/m/...). Treated exactly as code
basket_total_minorNoThe bill so far, 0 to 100000000. When sent, the minimum spend and the currency are judged as a redeem would judge them
currencyWith basket_total_minorThe venue's currency
site_idNoMust be the terminal's venue
bash
curl -X POST https://app.captifi.io/api/v1/rewards-pos/validate \
  -H "Authorization: Bearer captifi_rt_7f3k9qpxm2ab_0v8n2rq5tk1zjm4w6yh9pd3s7xbc0fge" \
  -H "Content-Type: application/json" \
  -d '{"code": "7F3K-9QAB", "basket_total_minor": 2000, "currency": "GBP"}'

200 for a reward that can be used here:

json
{
  "data": {
    "kind": "reward",
    "redeemable": true,
    "reasons": [],
    "allowed_site_ids": null,
    "face_value_minor": 200,
    "reward": {
      "id": 5140,
      "offer_id": 57,
      "offer_name": "Ten off",
      "offer_headline": "10% off your bill",
      "member_id": 917,
      "site_id": 17110,
      "site_name": "The Harbour Inn",
      "code": "7F3K9QAB",
      "display_code": "7F3K-9QAB",
      "kind": "percent",
      "percent": 10,
      "amount_minor": null,
      "currency": "GBP",
      "free_item_label": null,
      "min_spend_minor": 1000,
      "terms": null,
      "site_ids": null,
      "status": "issued",
      "uses_remaining": 1,
      "issued_at": "2026-09-19T12:05:40+01:00",
      "expires_at": "2026-09-26T23:59:59+01:00",
      "issued_via": "connect",
      "points_spent": null,
      "is_locked": false,
      "locked_until": null,
      "redeemed_at": null,
      "redemption_id": null,
      "redeemed_at_site": null,
      "redeemed_via": null,
      "reference": null,
      "redeemed_by_initials": null,
      "delivered_email_at": "2026-09-19T12:05:42+01:00",
      "delivered_sms_at": null,
      "viewed_at": "2026-09-19T20:13:50+01:00",
      "view_count": 2
    },
    "member": {
      "id": 917,
      "member_number": "M7K3Q9P2XA",
      "first_name": "Sam",
      "last_initial": "F",
      "points_balance": 543,
      "lifetime_points": 1210,
      "tier_key": "gold",
      "visits_count": 38,
      "last_visit_at": "2026-09-19T19:41:00+01:00"
    },
    "offer": null,
    "available_rewards": []
  },
  "meta": {
    "terminal": {
      "id": 12,
      "name": "Front till",
      "kind": "pos",
      "venue": { "site_id": 17110, "site_name": "The Harbour Inn" }
    }
  }
}
FieldNotes
kindreward (a reward code, token or link, with reward and member filled), offer (a shared code, with offer filled and reward null) or member (a member number, email, phone or member link, with member filled and available_rewards listing every unused, unexpired reward the member holds, soonest to expire first, for the till to pick from)
redeemabletrue when a redeem of the same thing with the same bill would succeed now
reasonsEmpty when redeemable, otherwise every reason in the order below
allowed_site_idsThe venues the reward or offer may be used at, or null when it is good at every venue in the programme
face_value_minorWhat the venue would give away against this bill: the fixed amount, the percentage of the bill, or the offer's estimated value for a free item or custom reward. null for kind member
rewardThe same fields as the API's rewards read. A reward that has already been used carries redeemed_at, redeemed_at_site, redeemed_via, reference and redeemed_by_initials, so the till can show where and when without a second call
memberThe member's id, number, first name, last initial, balances, tier, visit count and last visit. Never their email, phone, surname or date of birth

The reasons, and the refusal the matching /redeem would give:

ReasonMeaningRefusal on redeem
already_redeemedThe reward has been used409 REWARD_ALREADY_REDEEMED
voidedA manager cancelled the reward409 REWARD_VOIDED
expiredPast its expiry409 REWARD_EXPIRED
lockedLocked for 60 minutes after five wrong PINs on the guest's phone423 REWARD_LOCKED
wrong_venueNot valid at this venue, or this venue is not in the programme409 REWARD_WRONG_VENUE
reward_currencyA fixed-amount, free-item or custom reward priced in another currency than the venue's409 REWARD_CURRENCY_MISMATCH
channel_disabledThe offer's till channel is off409 REWARD_CHANNEL_DISABLED
basket_requiredThe reward has a minimum spend and no basket_total_minor was sent409 REWARD_MIN_SPEND with reason basket_required
min_spendThe bill is below the minimum spend409 REWARD_MIN_SPEND
currency_mismatchThe currency sent is not the venue's422 SPEND_CURRENCY_MISMATCH
programme_inactiveShared code only: the programme is not live409 OFFER_NOT_ACTIVE with reason programme_inactive
not_activeShared code only: paused, or outside its dates409 OFFER_NOT_ACTIVE
limit_reachedShared code only: its redemption cap, or the per-member cap, is reached409 OFFER_LIMIT_REACHED

A code, link or identifier that matches nothing in your account is 404 REWARD_NOT_FOUND. Another account's reward gets the same answer, so a code can never be confirmed as belonging to someone else.

POST /api/v1/rewards-pos/redeem

Redeem a reward or a shared code against the bill, exactly once. The Idempotency-Key header is required (see Idempotency and retries).

FieldRequiredNotes
codeOne of code or urlA reward code, token or shared offer code, as for /validate
urlOne of code or urlA reward link, as for /validate
basket_total_minorFor a reward or code with a minimum spendThe bill, 0 to 100000000. Without it discount_applied_minor is null and no spend points are awarded
currencyWith basket_total_minorThe venue's currency
order_referenceNoUp to 64 characters, your till's own reference for the sale. Stored as pos_reference and carried in the reward.redeemed webhook
staff_initialsNoUp to 4 characters, shown on the redemption
site_idNoMust be the terminal's venue
bash
curl -X POST https://app.captifi.io/api/v1/rewards-pos/redeem \
  -H "Authorization: Bearer captifi_rt_7f3k9qpxm2ab_0v8n2rq5tk1zjm4w6yh9pd3s7xbc0fge" \
  -H "Idempotency-Key: front-till-2026-09-19-1001" \
  -H "Content-Type: application/json" \
  -d '{"code": "7F3K-9QAB", "basket_total_minor": 2000, "currency": "GBP", "order_reference": "POS-1001", "staff_initials": "JB"}'

201 on a new redemption, 200 on a replay of one already made with this key:

json
{
  "data": {
    "id": 3320,
    "reference": "RDM-9P2XQ4",
    "offer_id": 57,
    "offer_name": "Ten off",
    "member_reward_id": 5140,
    "reward_code": "7F3K-9QAB",
    "member_id": 917,
    "site_id": 17110,
    "site_name": "The Harbour Inn",
    "channel": "pos_api",
    "redeemed_by_user_id": null,
    "redeemed_by_name": null,
    "terminal_id": 12,
    "terminal_name": "Front till",
    "staff_initials": "JB",
    "face_value_minor": 200,
    "currency": "GBP",
    "basket_total_minor": 2000,
    "discount_applied_minor": 200,
    "points_spent": 0,
    "points_awarded": 20,
    "pos_reference": "POS-1001",
    "reason": null,
    "status": "completed",
    "voided_at": null,
    "voided_by_user_id": null,
    "void_reason": null,
    "restored_at": null,
    "restored_by_user_id": null,
    "created_at": "2026-09-19T20:14:08+01:00"
  },
  "meta": {
    "terminal": {
      "id": 12,
      "name": "Front till",
      "kind": "pos",
      "venue": { "site_id": 17110, "site_name": "The Harbour Inn" }
    },
    "replayed": false,
    "points_awarded": 20,
    "new_balance": 563,
    "remaining_uses": 0,
    "reward": {
      "id": 5140,
      "offer_id": 57,
      "offer_name": "Ten off",
      "offer_headline": "10% off your bill",
      "member_id": 917,
      "site_id": 17110,
      "site_name": "The Harbour Inn",
      "code": "7F3K9QAB",
      "display_code": "7F3K-9QAB",
      "kind": "percent",
      "percent": 10,
      "amount_minor": null,
      "currency": "GBP",
      "free_item_label": null,
      "min_spend_minor": 1000,
      "terms": null,
      "site_ids": null,
      "status": "redeemed",
      "uses_remaining": 0,
      "issued_at": "2026-09-19T12:05:40+01:00",
      "expires_at": "2026-09-26T23:59:59+01:00",
      "issued_via": "connect",
      "points_spent": null,
      "is_locked": false,
      "locked_until": null,
      "redeemed_at": "2026-09-19T20:14:08+01:00",
      "redemption_id": 3320,
      "redeemed_at_site": "The Harbour Inn",
      "redeemed_via": "pos_api",
      "reference": "RDM-9P2XQ4",
      "redeemed_by_initials": "JB",
      "delivered_email_at": "2026-09-19T12:05:42+01:00",
      "delivered_sms_at": null,
      "viewed_at": "2026-09-19T20:13:50+01:00",
      "view_count": 2
    }
  }
}
FieldNotes
data.referenceRDM- and six characters. Print it on the receipt; it is the reference on the guest's screen, in Rewards → Redemptions and in the void path
data.face_value_minorWhat the reward is worth against this bill
data.discount_applied_minorThe amount to take off the bill: the face value, or the whole bill when the bill is smaller. null when no bill was sent
data.points_awarded, meta.points_awarded, meta.new_balanceThe spend points the member earned on this bill, at the programme's rate with the venue and tier multipliers and the daily cap applied, and their balance after them. 0 and unchanged when no bill was sent or the code was a shared code with no member
meta.remaining_uses0 once a single-use reward is redeemed
meta.rewardThe reward as /validate returns it, now redeemed
meta.replayedtrue when this answer is the stored answer to an earlier request with the same Idempotency-Key

A reward or shared code with a minimum spend must be sent with the bill. Without basket_total_minor the answer is 409 REWARD_MIN_SPEND with reason basket_required, min_spend_minor and currency, and nothing is consumed. This rule is the till channel's only: the staff Redeem screen still honours such a reward without a typed bill, because a manager scanning a phone at the bar may not have one.

Refusals: 404 REWARD_NOT_FOUND; 409 REWARD_ALREADY_REDEEMED with redeemed_at, redeemed_at_site, redeemed_via, reference and redeemed_by_initials of the first redemption; 409 REWARD_EXPIRED with expired_at; 409 REWARD_VOIDED; 423 REWARD_LOCKED with locked_until; 409 REWARD_WRONG_VENUE with valid_venues; 409 REWARD_CURRENCY_MISMATCH with reward_currency and venue_currency; 409 REWARD_CHANNEL_DISABLED; 409 REWARD_MIN_SPEND with min_spend_minor and currency; 422 SPEND_CURRENCY_MISMATCH with venue_currency; 409 OFFER_NOT_ACTIVE and 409 OFFER_LIMIT_REACHED for a shared code; 422 IDEMPOTENCY_KEY_REQUIRED, 422 IDEMPOTENCY_KEY_REUSED and 409 REDEMPTION_ALREADY_VOIDED, covered under idempotency below. When two tills race for the same reward, the loser is told REWARD_ALREADY_REDEEMED with the winner's reference.

POST /api/v1/rewards-pos/redemptions/{reference}/void

Reverse a sale made in the last 30 minutes. {reference} is the RDM-XXXXXX from the redeem answer, in either case.

FieldRequiredNotes
reasonNoUp to 255 characters, shown on the redemption. Defaults to "Reversed at the till"
site_idNoMust be the terminal's venue
bash
curl -X POST https://app.captifi.io/api/v1/rewards-pos/redemptions/RDM-9P2XQ4/void \
  -H "Authorization: Bearer captifi_rt_7f3k9qpxm2ab_0v8n2rq5tk1zjm4w6yh9pd3s7xbc0fge" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Rang through twice"}'

200 with the redemption, now voided:

json
{
  "data": {
    "id": 3320,
    "reference": "RDM-9P2XQ4",
    "offer_id": 57,
    "offer_name": "Ten off",
    "member_reward_id": 5140,
    "reward_code": "7F3K-9QAB",
    "member_id": 917,
    "site_id": 17110,
    "site_name": "The Harbour Inn",
    "channel": "pos_api",
    "redeemed_by_user_id": null,
    "redeemed_by_name": null,
    "terminal_id": 12,
    "terminal_name": "Front till",
    "staff_initials": "JB",
    "face_value_minor": 200,
    "currency": "GBP",
    "basket_total_minor": 2000,
    "discount_applied_minor": 200,
    "points_spent": 0,
    "points_awarded": 20,
    "pos_reference": "POS-1001",
    "reason": null,
    "status": "voided",
    "voided_at": "2026-09-19T20:19:41+01:00",
    "voided_by_user_id": null,
    "void_reason": "Rang through twice",
    "restored_at": null,
    "restored_by_user_id": null,
    "created_at": "2026-09-19T20:14:08+01:00"
  },
  "meta": {
    "terminal": {
      "id": 12,
      "name": "Front till",
      "kind": "pos",
      "venue": { "site_id": 17110, "site_name": "The Harbour Inn" }
    }
  }
}

voided_by_user_id is null for a void from a till; terminal_id stays the terminal that made the redemption. What a void does and who may void what is under Voids.

Refusals: 404 REWARD_NOT_FOUND for a reference from another account or from another of your venues; 409 REDEMPTION_VOID_WINDOW_CLOSED with void_window_minutes (30), redeemed_at and reference when the redemption is older than 30 minutes; 409 REDEMPTION_ALREADY_VOIDED when it has been voided already.

POST /api/v1/rewards-pos/members/find

Look a member up by email address or phone number. Send exactly one of the two.

FieldRequiredNotes
emailOne of email or phoneExact match, case insensitive. jo.bloggs+1@example.com does not find jo.bloggs@example.com
phoneOne of email or phoneInternational format with the country code, +447700900123. A national number such as 07700 900123 is a field validation 422
site_idNoMust be the terminal's venue

Sending both, or neither, is a field validation 422 on both fields.

json
{ "email": "sam@example.com" }

200:

json
{
  "data": {
    "id": 917,
    "member_number": "M7K3Q9P2XA",
    "first_name": "Sam",
    "last_initial": "F",
    "points_balance": 543,
    "lifetime_points": 1210,
    "tier_key": "gold",
    "visits_count": 38,
    "last_visit_at": "2026-09-19T19:41:00+01:00"
  },
  "meta": {
    "terminal": {
      "id": 12,
      "name": "Front till",
      "kind": "pos",
      "venue": { "site_id": 17110, "site_name": "The Harbour Inn" }
    }
  }
}

The answer never carries the member's email, phone, surname, date of birth or any token. A member who has left the programme or been erased, another account's member, and an identity that matches nobody are all 404 REWARD_NOT_FOUND with the message "No member matches that email or phone." An account with no programme yet is 404 PROGRAMME_NOT_FOUND.

To find a member and see the rewards they hold in one call, send their number, email or phone to /validate instead: it answers kind member with available_rewards.

POST /api/v1/rewards-pos/points/earn

Award points for a sale with no reward. A sale that also redeems a reward earns its points through /redeem; do not send it here as well.

FieldRequiredNotes
member_numberOne of member_number or identifierThe ten-character number on the member's card, up to 32 characters
identifierOne of member_number or identifierUp to 512 characters: an email address, a phone number (international or in the venue's national format), a member number, a member link (/m/...) or a reward code the member holds
amount_minorYesThe bill, 1 to 100000000
currencyYesThe venue's currency
order_referenceYesUp to 64 characters from A-Z a-z 0-9 . _ : / -, your till's reference for the sale. It is what stops a sale being counted twice
site_idNoMust be the terminal's venue
json
{ "member_number": "M7K3Q9P2XA", "amount_minor": 2550, "currency": "GBP", "order_reference": "ORD-2026-0001" }

201 on a new ledger entry:

json
{
  "data": {
    "id": 88120,
    "member_id": 917,
    "site_id": 17110,
    "delta": 25,
    "balance_after": 588,
    "reason": "earn_spend",
    "reference": "earn:till:terminal:12:ORD-2026-0001",
    "spend_minor": 2550,
    "currency": "GBP",
    "note": null,
    "created_by_user_id": null,
    "terminal_id": 12,
    "created_at": "2026-09-19T20:31:12+01:00"
  },
  "meta": {
    "terminal": {
      "id": 12,
      "name": "Front till",
      "kind": "pos",
      "venue": { "site_id": 17110, "site_name": "The Harbour Inn" }
    },
    "duplicate": false,
    "points_awarded": 25,
    "points_balance": 588,
    "member": {
      "id": 917,
      "member_number": "M7K3Q9P2XA",
      "first_name": "Sam",
      "last_initial": "F",
      "points_balance": 588,
      "lifetime_points": 1235,
      "tier_key": "gold",
      "visits_count": 38,
      "last_visit_at": "2026-09-19T19:41:00+01:00"
    }
  }
}

data is the ledger entry as the API's ledger read returns it. Its reference is earn:till:terminal:{terminal id}:{order_reference}, and that reference is what makes the call safe to repeat: the same order_reference from the same terminal for the same member is 200 with meta.duplicate true, meta.points_awarded 0 and data the original entry. The same order_reference from another of your terminals, or for another member, counts as a new sale.

When the sale earned nothing (the programme's rate is zero, the member has reached the daily cap, or the programme is not live) the answer is 200 with data null, duplicate false and points_awarded 0.

Refusals: 422 SPEND_CURRENCY_MISMATCH with currency and venue_currency when the currency is not the venue's, and nothing is minted; 409 REWARD_WRONG_VENUE with reason venue_not_in_programme when the terminal's venue is not in the programme or is switched off there; 404 REWARD_NOT_FOUND with the message "No member matches that number or identifier." for an unknown, foreign, left or erased member; 404 PROGRAMME_NOT_FOUND when the account has no programme yet.

POST /api/v1/rewards-pos/offers/validate

Whether a shared code is live for this venue and this bill, and what it is worth. Shared codes are the ones set under Shared code and catalogue on an offer, such as SUMMER20; every member can use one, so there is no reward to look up.

FieldRequiredNotes
codeYesUp to 64 characters, matched as described under Codes and matching
basket_total_minorNoThe bill, 0 to 100000000
currencyWith basket_total_minorThe venue's currency
site_idNoMust be the terminal's venue
json
{ "code": "SUMMER20", "basket_total_minor": 3000, "currency": "GBP" }

200:

json
{
  "data": {
    "redeemable": true,
    "reasons": [],
    "allowed_site_ids": null,
    "offer": {
      "id": 61,
      "name": "Summer twenty",
      "headline": "20% off",
      "description": null,
      "terms": null,
      "kind": "percent",
      "percent": 20,
      "amount_minor": null,
      "currency": "GBP",
      "estimated_value_minor": 300,
      "free_item_label": null,
      "min_spend_minor": 1000,
      "public_code": "SUMMER20",
      "site_ids": null,
      "starts_at": null,
      "ends_at": null,
      "status": "active",
      "remaining_redemptions": 5
    },
    "face_value_minor": 600
  },
  "meta": {
    "terminal": {
      "id": 12,
      "name": "Front till",
      "kind": "pos",
      "venue": { "site_id": 17110, "site_name": "The Harbour Inn" }
    }
  }
}

offer carries what a till needs: what the offer gives, where and when it applies, and remaining_redemptions, which is null for an uncapped offer. reasons uses the vocabulary of /validate, including basket_required for a code with a minimum spend and no bill sent, plus programme_inactive, not_active and limit_reached. A code that is not one of your shared codes, including a reward code, is 404 REWARD_NOT_FOUND. To redeem the code, send it to /redeem in code.

Idempotency and retries

/redeem requires an Idempotency-Key header of 1 to 128 characters (surrounding spaces are trimmed). Use a value that is unique per sale on this till, such as your order number with a prefix; a redeem without the header is 422 IDEMPOTENCY_KEY_REQUIRED, and a header over 128 characters is a field validation 422.

The key is scoped to the terminal, so two tills of one account may use the same value for two sales and both go through with their own references. Within one terminal:

RepeatAnswer
Same key, same code, same basket_total_minor and currency200 with the first answer, data and meta alike, and meta.replayed true. No second redemption
Same key, a different code, bill or currency422 IDEMPOTENCY_KEY_REUSED with reference naming the redemption the key already belongs to. Nothing is redeemed
Same key, same request, but the redemption has since been voided (by this till, another till at the venue or a manager)409 REDEMPTION_ALREADY_VOIDED with reference and voided_at. Apply no discount
Two requests with one key at the same momentOne wins; the other receives the winner's answer, or the reused-key refusal if its body differed

So a till that times out waiting for a redeem answer should send the same request again with the same key. If the first one got through it gets the same answer back, and if it did not the redeem happens now. order_reference and staff_initials are not part of the comparison, so changing them on a retry is harmless.

/points/earn needs no header: it is idempotent through order_reference, per terminal and member, as described above. /validate, /members/find and /offers/validate change nothing and can be repeated freely within the rate limit.

Codes and matching

A reward code is eight characters from an alphabet that leaves out I, L, O and U, shown to guests as 7F3K-9QAB. Type it with or without the hyphen, in either case, so 7F3K-9QAB and 7f3k9qab find the same reward; the letters I and L are read as 1 and O as 0, so a code misread off a screen still resolves. A scanned reward link works in code or url as it is.

A shared offer code is stored exactly as you typed it on the offer, in upper case (SUMMER20, LOYAL-10). A till's typed code is matched against it in three forms, in order: exactly as typed (upper-cased), then with spaces and hyphens removed from both sides, then with I, L and O folded to digits. A stored LOYAL-10 is found by loyal10, loyal-10 and LOYAL 10; a stored SUMMER20 is found by SUMMER2O typed with a letter O. When more than one of your offers matches, the one that matches the code exactly as typed wins, then a match once hyphens and spaces are ignored, then the oldest offer. With LOYAL10 and 10YA110 both in the account, loyal10 resolves to LOYAL10.

The same matching applies on /validate, /redeem and /offers/validate, and on the staff Redeem screen.

Venue rules

A terminal acts for the account that owns it, at the venue it was created for. There is no venue to choose in a request, and everything is judged against that venue: whether a reward is valid here, which currency the bill must be in, which programme venue's multipliers apply, and which redemptions the till may void.

  • A site_id in the body is accepted only when it is the terminal's own venue. Any other value is 409 REWARD_WRONG_VENUE with reason terminal_venue_mismatch and terminal_site_id naming the right one, so a till configured for the wrong bar fails loudly. A site_id that is not a whole number is a field validation 422 on the field.
  • A reward or shared code valid only at another of your venues is 409 REWARD_WRONG_VENUE with valid_venues listing where it can be used, and /validate reports it as wrong_venue with allowed_site_ids.
  • A terminal whose venue is not in the programme, or is switched off under Venues and currency, is 409 REWARD_WRONG_VENUE with reason venue_not_in_programme. A terminal whose venue has been deleted is the same code with reason terminal_has_no_venue.
  • A reward or shared code with a minimum spend must be redeemed with basket_total_minor and currency; without them the answer is 409 REWARD_MIN_SPEND with reason basket_required.
  • A reward, member, offer or redemption belonging to another account is 404 REWARD_NOT_FOUND, never a 403, so nothing about another account is confirmed.

Voids

A till may reverse a redemption for 30 minutes after it was made. The scope is the venue: any active terminal at the venue can void any redemption made there in that window, whether it was made by this till, by another till, with a trader's PIN or from the staff Redeem screen. A redemption at another of your venues, or in another account, is 404 REWARD_NOT_FOUND.

A void marks the redemption Voided with the reason sent, takes back the points the sale awarded (the member's ledger gains a Reversed entry carrying the terminal) and drops the redemption out of the totals and the settlement report. The reward stays used: a void is an accounting correction and does not give the guest a second go. If the same sale is then sent to /redeem again under its original Idempotency-Key, the answer is 409 REDEMPTION_ALREADY_VOIDED. A second void of the same redemption is 409 REDEMPTION_ALREADY_VOIDED too.

After 30 minutes the till is told 409 REDEMPTION_VOID_WINDOW_CLOSED and a manager voids from Rewards → Redemptions instead, where the account owner can also restore a voided redemption. See Voiding and restoring.

Error table

CodeStatusMeaningWhat the till should do
INVALID_TERMINAL_KEY401No key, a malformed or unknown key, or a deactivated terminalStop and show a configuration error. Check the key against Rewards → Terminals, or generate a new one
plan_upgrade_required (flat error)403The account is below the Growth planStop; the account holder needs to upgrade
REWARDS_DISABLED403Rewards is switched off for the accountStop; switch Rewards on under Integrations → Dashboard features
DEMO_ACCOUNT_LOCKED403The shared demo accountStop; use a real account
REWARD_NOT_FOUND404Nothing in the account matches the code, link, identifier or reference, or it belongs to another account, or the member has leftAsk the guest to check the code
PROGRAMME_NOT_FOUND404The account has no programme yetStop; set the programme up on Rewards → Setup
REWARD_ALREADY_REDEEMED409The reward has been used; the details name when, where, how and by whomGive no discount
REWARD_EXPIRED409Past expires_atGive no discount
REWARD_VOIDED409A manager cancelled the rewardGive no discount
REWARD_WRONG_VENUE409Not valid at this venue, the venue is not in the programme, or a site_id that is not the terminal'sGive no discount; valid_venues says where it works. For terminal_venue_mismatch fix the till's configuration
REWARD_CURRENCY_MISMATCH409A fixed-amount, free-item or custom reward priced in another currency than the venue'sGive no discount
REWARD_CHANNEL_DISABLED409The offer's till channel is offGive no discount; staff can still scan it on the Redeem screen
REWARD_MIN_SPEND409The bill is below min_spend_minor, or (reason basket_required) no bill was sent for a reward or code that has oneSend the bill, or tell the guest the minimum
OFFER_NOT_ACTIVE409The shared code is paused or outside its dates, or the programme is not liveGive no discount
OFFER_LIMIT_REACHED409The shared code has reached its capGive no discount
REDEMPTION_ALREADY_VOIDED409A second void, or a redeem replay of a redemption that has since been voided (carries reference and voided_at)Apply no discount
REDEMPTION_VOID_WINDOW_CLOSED409A void more than 30 minutes after the redemptionAsk a manager to void from Rewards → Redemptions
IDEMPOTENCY_KEY_REQUIRED422No Idempotency-Key header on /redeemSend one, unique per sale
IDEMPOTENCY_KEY_REUSED422The key was already used on this terminal for a different request; reference names that redemptionUse a fresh key for a new sale; the key is burnt
SPEND_CURRENCY_MISMATCH422The currency sent is not the venue'sSend the bill in the venue's currency
REWARD_LOCKED423Locked for 60 minutes after five wrong PINs on the guest's phone; locked_until says when it freesStaff can scan or type the code on the Redeem screen
RATE_LIMITED429Over 120 a minute for this terminal or key, or the address's shared limitWait Retry-After seconds and resend, with the same Idempotency-Key on a redeem
field validation422{"message": "...", "errors": {...}}: a missing or malformed field, both email and phone on /members/find, a non-integer site_id, an Idempotency-Key over 128 charactersFix the request

What appears on the dashboards

Every redemption made through the API is one row in Rewards → Redemptions on either dashboard, with the channel Till API (Till on the classic dashboard), the terminal that made it, the reference from the answer, the guest, the offer, the value and the status. Filter by terminal to see one till's day. A void from the till marks the row Voided with the reason sent, and the row leaves the totals. A redemption made from the staff Redeem screen has no terminal; one made with a trader's PIN carries that terminal.

Points awarded by /points/earn and by /redeem appear on the member's ledger as Spend entries carrying the terminal, with your order reference inside the ledger reference; a void adds a Reversed entry. See Reading a member's ledger.

The settlement report (Rewards → Overview → Settlement on my.captifi.io, Rewards → Reports on the classic dashboard) gives one line per venue, terminal and currency, with the settlement party, the number of redemptions, the face value and the discount given, so every till has its own line. Redemptions from the staff screen appear as Staff (dashboard). The CSV needs the Export data permission. See Settlement.

If you have webhooks set up, reward.redeemed fires for each redemption with channel pos_api, the terminal_id, your order_reference as pos_reference and the money in minor units with its currency, so a back-office system can match a redemption to a till transaction. reward.redemption_voided fires when the till voids one, and points.earned fires for each earn with the earn:till:terminal:... reference and the spend behind it. The CaptiFi API reads the same redemptions (GET /rewards/redemptions?channel=pos_api) and the member ledgers.

CaptiFi records every refused call against your account and the terminal's name, with the path, the status, the error code and a one-way fingerprint of the key that was sent, never the key itself. Successful calls leave a redemption or ledger row instead.

WiFi voucher POS endpoints

The WiFi Vouchers feature has its own two POS endpoints, and they are live and unchanged. They create and check WiFi access codes for a venue in voucher mode; they have no connection to Rewards. The credential is different too: a per-venue key from My Venues → Voucher & POS Setup, in the shape captifi_pos_ followed by 32 hexadecimal characters, which that page shows in full and can regenerate or disconnect. A Rewards terminal key is not accepted here, and a voucher key is not accepted by anything in Rewards. Setup steps are under POS Integration.

Both endpoints sit under https://app.captifi.io/api/pos/voucher, take the key as Authorization: Bearer <venue POS key>, and are limited to 60 requests a minute from one address. A missing key answers 401 with an error message beginning "Missing API key"; a wrong one answers 401 with {"error": "Invalid API key"}.

POST /generate

Creates one or more codes. The venue must be in voucher mode; otherwise the answer is 422 with {"error": "Site is not in voucher mode"}.

FieldDefaultNotes
duration_hours24How long each code keeps a guest online once used
max_uses1How many times the code can be used
count1Codes to create in one call, up to 50
notesGenerated via POS APIFree text kept against the codes
bash
curl -X POST https://app.captifi.io/api/pos/voucher/generate \
  -H "Authorization: Bearer captifi_pos_0123456789abcdef0123456789abcdef" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"duration_hours": 24, "max_uses": 1}'

One code comes back on its own. expires_at is always null for codes made this way: the code does not lapse until it is used or revoked.

json
{
  "code": "7F3K9QPX",
  "duration_hours": 24,
  "max_uses": 1,
  "expires_at": null,
  "site": "The Harbour Inn"
}

With count above 1 the codes come back as a list:

json
{
  "vouchers": [
    { "code": "7F3K9QPX", "duration_hours": 24, "max_uses": 1, "expires_at": null },
    { "code": "2MRT8VWD", "duration_hours": 24, "max_uses": 1, "expires_at": null }
  ],
  "count": 2,
  "site": "The Harbour Inn"
}

POST /validate

Checks a code without using it. Send {"code": "7F3K9QPX"}; a missing code answers 422 with {"error": "Code is required"}. status is one of active, used, revoked or expired.

json
{
  "valid": true,
  "error": null,
  "voucher": {
    "code": "7F3K9QPX",
    "duration_hours": 24,
    "times_used": 0,
    "max_uses": 1,
    "status": "active"
  }
}

When valid is false, voucher is null and error is one of "Invalid access code. Please check and try again.", "This access code has been revoked.", "This access code has already been used." or "This access code has expired."

See also

CaptiFi — Guest WiFi Marketing Platform