Appearance
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 to | Endpoint |
|---|---|
| Check what a scanned or typed code is and whether it can be used here | POST /validate |
| Redeem a reward or shared code against a bill | POST /redeem |
| Reverse a sale made in the last 30 minutes | POST /redemptions/{reference}/void |
| Find a member by email or phone | POST /members/find |
| Award points for a sale with no reward | POST /points/earn |
| Check a shared code for this venue and this bill | POST /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 to | What to do |
|---|---|
| Check a code before honouring it | Scan 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 bill | Type 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 member | Type 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 reward | Look 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 redemption | On 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
Go to Rewards → Terminals and click New terminal
Choose the Venue. Only a venue that is in your programme and billed to your account is offered
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
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
Click Create terminal
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)
Go to Rewards → Terminals and click New terminal
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
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_0v8n2rq5tk1zjm4w6yh9pd3s7xbc0fgeIt 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_0v8n2rq5tk1zjm4w6yh9pd3s7xbc0fgeThe 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:
| Request | Own bucket | Shared per-address bucket |
|---|---|---|
| A live terminal | 120 a minute per terminal | 600 a minute for every live terminal behind the same address |
| A wrong, deactivated or unknown key | 120 a minute per key | 60 a minute for every refused or key-less request from the address |
| No key at all | Counted on the address | The 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.
| Field | Required | Notes |
|---|---|---|
code | One of code or url | Up 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 |
url | One of code or url | The reward link from a scanned QR code (/r/...) or a member link (/m/...). Treated exactly as code |
basket_total_minor | No | The bill so far, 0 to 100000000. When sent, the minimum spend and the currency are judged as a redeem would judge them |
currency | With basket_total_minor | The venue's currency |
site_id | No | Must 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" }
}
}
}| Field | Notes |
|---|---|
kind | reward (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) |
redeemable | true when a redeem of the same thing with the same bill would succeed now |
reasons | Empty when redeemable, otherwise every reason in the order below |
allowed_site_ids | The venues the reward or offer may be used at, or null when it is good at every venue in the programme |
face_value_minor | What 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 |
reward | The 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 |
member | The 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:
| Reason | Meaning | Refusal on redeem |
|---|---|---|
already_redeemed | The reward has been used | 409 REWARD_ALREADY_REDEEMED |
voided | A manager cancelled the reward | 409 REWARD_VOIDED |
expired | Past its expiry | 409 REWARD_EXPIRED |
locked | Locked for 60 minutes after five wrong PINs on the guest's phone | 423 REWARD_LOCKED |
wrong_venue | Not valid at this venue, or this venue is not in the programme | 409 REWARD_WRONG_VENUE |
reward_currency | A fixed-amount, free-item or custom reward priced in another currency than the venue's | 409 REWARD_CURRENCY_MISMATCH |
channel_disabled | The offer's till channel is off | 409 REWARD_CHANNEL_DISABLED |
basket_required | The reward has a minimum spend and no basket_total_minor was sent | 409 REWARD_MIN_SPEND with reason basket_required |
min_spend | The bill is below the minimum spend | 409 REWARD_MIN_SPEND |
currency_mismatch | The currency sent is not the venue's | 422 SPEND_CURRENCY_MISMATCH |
programme_inactive | Shared code only: the programme is not live | 409 OFFER_NOT_ACTIVE with reason programme_inactive |
not_active | Shared code only: paused, or outside its dates | 409 OFFER_NOT_ACTIVE |
limit_reached | Shared code only: its redemption cap, or the per-member cap, is reached | 409 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).
| Field | Required | Notes |
|---|---|---|
code | One of code or url | A reward code, token or shared offer code, as for /validate |
url | One of code or url | A reward link, as for /validate |
basket_total_minor | For a reward or code with a minimum spend | The bill, 0 to 100000000. Without it discount_applied_minor is null and no spend points are awarded |
currency | With basket_total_minor | The venue's currency |
order_reference | No | Up to 64 characters, your till's own reference for the sale. Stored as pos_reference and carried in the reward.redeemed webhook |
staff_initials | No | Up to 4 characters, shown on the redemption |
site_id | No | Must 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
}
}
}| Field | Notes |
|---|---|
data.reference | RDM- 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_minor | What the reward is worth against this bill |
data.discount_applied_minor | The 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_balance | The 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_uses | 0 once a single-use reward is redeemed |
meta.reward | The reward as /validate returns it, now redeemed |
meta.replayed | true 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.
| Field | Required | Notes |
|---|---|---|
reason | No | Up to 255 characters, shown on the redemption. Defaults to "Reversed at the till" |
site_id | No | Must 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.
| Field | Required | Notes |
|---|---|---|
email | One of email or phone | Exact match, case insensitive. jo.bloggs+1@example.com does not find jo.bloggs@example.com |
phone | One of email or phone | International format with the country code, +447700900123. A national number such as 07700 900123 is a field validation 422 |
site_id | No | Must 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.
| Field | Required | Notes |
|---|---|---|
member_number | One of member_number or identifier | The ten-character number on the member's card, up to 32 characters |
identifier | One of member_number or identifier | Up 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_minor | Yes | The bill, 1 to 100000000 |
currency | Yes | The venue's currency |
order_reference | Yes | Up 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_id | No | Must 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.
| Field | Required | Notes |
|---|---|---|
code | Yes | Up to 64 characters, matched as described under Codes and matching |
basket_total_minor | No | The bill, 0 to 100000000 |
currency | With basket_total_minor | The venue's currency |
site_id | No | Must 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:
| Repeat | Answer |
|---|---|
Same key, same code, same basket_total_minor and currency | 200 with the first answer, data and meta alike, and meta.replayed true. No second redemption |
| Same key, a different code, bill or currency | 422 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 moment | One 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_idin the body is accepted only when it is the terminal's own venue. Any other value is409 REWARD_WRONG_VENUEwithreasonterminal_venue_mismatchandterminal_site_idnaming the right one, so a till configured for the wrong bar fails loudly. Asite_idthat is not a whole number is a field validation422on the field. - A reward or shared code valid only at another of your venues is
409 REWARD_WRONG_VENUEwithvalid_venueslisting where it can be used, and/validatereports it aswrong_venuewithallowed_site_ids. - A terminal whose venue is not in the programme, or is switched off under Venues and currency, is
409 REWARD_WRONG_VENUEwithreasonvenue_not_in_programme. A terminal whose venue has been deleted is the same code withreasonterminal_has_no_venue. - A reward or shared code with a minimum spend must be redeemed with
basket_total_minorandcurrency; without them the answer is409 REWARD_MIN_SPENDwithreasonbasket_required. - A reward, member, offer or redemption belonging to another account is
404 REWARD_NOT_FOUND, never a403, 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
| Code | Status | Meaning | What the till should do |
|---|---|---|---|
INVALID_TERMINAL_KEY | 401 | No key, a malformed or unknown key, or a deactivated terminal | Stop and show a configuration error. Check the key against Rewards → Terminals, or generate a new one |
plan_upgrade_required (flat error) | 403 | The account is below the Growth plan | Stop; the account holder needs to upgrade |
REWARDS_DISABLED | 403 | Rewards is switched off for the account | Stop; switch Rewards on under Integrations → Dashboard features |
DEMO_ACCOUNT_LOCKED | 403 | The shared demo account | Stop; use a real account |
REWARD_NOT_FOUND | 404 | Nothing in the account matches the code, link, identifier or reference, or it belongs to another account, or the member has left | Ask the guest to check the code |
PROGRAMME_NOT_FOUND | 404 | The account has no programme yet | Stop; set the programme up on Rewards → Setup |
REWARD_ALREADY_REDEEMED | 409 | The reward has been used; the details name when, where, how and by whom | Give no discount |
REWARD_EXPIRED | 409 | Past expires_at | Give no discount |
REWARD_VOIDED | 409 | A manager cancelled the reward | Give no discount |
REWARD_WRONG_VENUE | 409 | Not valid at this venue, the venue is not in the programme, or a site_id that is not the terminal's | Give no discount; valid_venues says where it works. For terminal_venue_mismatch fix the till's configuration |
REWARD_CURRENCY_MISMATCH | 409 | A fixed-amount, free-item or custom reward priced in another currency than the venue's | Give no discount |
REWARD_CHANNEL_DISABLED | 409 | The offer's till channel is off | Give no discount; staff can still scan it on the Redeem screen |
REWARD_MIN_SPEND | 409 | The bill is below min_spend_minor, or (reason basket_required) no bill was sent for a reward or code that has one | Send the bill, or tell the guest the minimum |
OFFER_NOT_ACTIVE | 409 | The shared code is paused or outside its dates, or the programme is not live | Give no discount |
OFFER_LIMIT_REACHED | 409 | The shared code has reached its cap | Give no discount |
REDEMPTION_ALREADY_VOIDED | 409 | A 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_CLOSED | 409 | A void more than 30 minutes after the redemption | Ask a manager to void from Rewards → Redemptions |
IDEMPOTENCY_KEY_REQUIRED | 422 | No Idempotency-Key header on /redeem | Send one, unique per sale |
IDEMPOTENCY_KEY_REUSED | 422 | The key was already used on this terminal for a different request; reference names that redemption | Use a fresh key for a new sale; the key is burnt |
SPEND_CURRENCY_MISMATCH | 422 | The currency sent is not the venue's | Send the bill in the venue's currency |
REWARD_LOCKED | 423 | Locked for 60 minutes after five wrong PINs on the guest's phone; locked_until says when it frees | Staff can scan or type the code on the Redeem screen |
RATE_LIMITED | 429 | Over 120 a minute for this terminal or key, or the address's shared limit | Wait Retry-After seconds and resend, with the same Idempotency-Key on a redeem |
| field validation | 422 | {"message": "...", "errors": {...}}: a missing or malformed field, both email and phone on /members/find, a non-integer site_id, an Idempotency-Key over 128 characters | Fix 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"}.
| Field | Default | Notes |
|---|---|---|
duration_hours | 24 | How long each code keeps a guest online once used |
max_uses | 1 | How many times the code can be used |
count | 1 | Codes to create in one call, up to 50 |
notes | Generated via POS API | Free 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
- Rewards & Offers: the full guide, including terminals and their PINs
- Redeeming at the Till: the one-page guide for bar staff
- CaptiFi API: read your programme, members, rewards and redemptions with an account key
- Webhooks:
reward.redeemed,reward.redemption_voidedandpoints.earnedpushed to your systems - WiFi Vouchers & POS: the voucher feature the POS endpoints belong to
- Team Members: the permissions named above