Interface Control Document (ICD): Partner Systems (Point of Sale) - EPC
| Abbreviation | Description |
|---|---|
| EPC | Emirates Platinum Card — the Platinum programme and its API |
| Partner | A merchant organisation contracted to offer benefits to Platinum members |
| Partner System | A partner-operated system that calls this API: a cash register (POS) at a counter, or the partner's POS server or middleware |
| POS | Point of Sale — a cash register or till at a shop, restaurant or counter |
| Store number | The partner's own code for a store, branch or venue. Mapped once in the Platinum backoffice to the partner location it represents |
| API key | A static secret issued to a partner and sent in the X-API-Key header on every call. One key serves all of the partner's stores and registers |
| QR Value | The exact string a partner system reads from the member's Platinum QR code, or that a cashier types from the member's card. It is opaque to the partner system |
| Offer | A benefit a partner publishes to members: a percentage discount, a special price, or a special deal |
| posId | The partner-facing code of an offer, configured per offer in the Platinum backoffice so that the POS can key the promotion it applies |
| Token | A single-use value returned by POS Login that identifies the verified member for the Redeem Offer call that follows. Bound to the member, the store and the partner; expires |
| Transaction id | The partner's own identifier of the sale (check, receipt or order) on its POS. Identifies the sale in Redeem Offer and Void Transaction |
| Redemption | The record that a member used an offer in a specific sale at a store |
| Void | Reversal of a redemption after a voided sale or refunded order |
| Fils | The minor unit of AED. All amounts in this interface are integers in fils (1 AED = 100 fils) |
| UAT | User Acceptance Testing environment |
| API | Application Programming Interface |
Platinum partners today record member benefits through the Partner Portal: a member of staff signs in, scans the member's QR code and enters the transaction. This interface lets a partner's own cash register do the same job directly, in three steps: it scans the member's QR code and receives the member id together with the offers the member can use at this store, it reports the sale on which the offer was redeemed, and it voids the redemption if the sale is later reversed. The eligibility, availability and discount rules remain on the Platinum side; the partner system does not implement them, and no member personal data other than the member id is returned to it.
| In scope | Out of scope |
|---|---|
Over-the-counter offers: discount, special_price, special_deal |
Leisure venue check-in (lifestyle_hub offers). Household entitlements and per-person pricing remain in the Partner Portal |
| Point of sale: a cash register at a shop, restaurant or counter | Offline operation. POS Login is a live call; the partner system must be online |
| Void of a redemption after a voided sale or refunded order | Partial refunds. A redemption stands unless the whole sale is reversed |
| One Platinum offer per transaction | Several Platinum offers on one transaction |
| Sales on which a Platinum offer was applied | Sales without a Platinum offer. Platinum is not an order-capture system |
| Membership confirmation, the member id and the member's eligible offers | Other member data. No name, photo, tier or contact detail is returned |
| Retry-safe recording through the partner's transaction id | Read endpoints for reconciliation or lookup |
| Sr No. | Non-Functional Requirement | Value |
|---|---|---|
| 1 | PHI or Credit Card data? | No credit card data and no patient health information. Card payment details are never sent to Platinum; unknown fields are dropped before anything is stored |
| 2 | Authentication Pattern | Static API key per partner, sent in the X-API-Key header on every call. The key is shown once at creation, stored hashed, and revocable at any time. Two keys may be active during rotation |
| 3 | Authorisation | The key identifies the partner. storeNumber must be one of the partner's mapped stores. The status of the key, the partner and the store is checked on every request, so deactivation in the backoffice takes effect on the next call |
| 4 | Secure Channel / Encryption in Transit | TLS 1.2 or higher (HTTPS only) |
| 5 | Input validation | Yes. All fields validated against the JSON schema in the accompanying OpenAPI specification |
| 6 | Internet exposed? IP whitelisting? | Yes, exposed via CloudFront. Because one static key serves the whole partner, an IP allow-list (WAF) is strongly recommended and is required for production keys unless waived at onboarding |
| 7 | Rate limiting | Per key, and additionally per key, store and terminal for POS Login (see 3.2). Exceeding a limit returns 429 with code 1006 |
| 8 | Logging and audit | Every call is logged with the partner, store, terminal and request id. Every redemption and void is written to the Platinum audit trail |
| 9 | Data exposure | POS Login returns the member id and the offers the member can use. No name, photo, tier, contact details, employment data or membership history are returned |
| 10 | Secret handling | The API key is displayed once at creation or rotation and stored hashed. It is never recoverable; a lost key is replaced by rotation |
| 11 | Duplicate protection | A sale is recorded once per partner and transaction id. A repeated Redeem Offer for the same sale is answered with the original result and never recorded twice |
| Item | Value |
|---|---|
| Rate limit: POS Login | 600 per minute per key, and 30 per minute per key, store and terminal |
| Rate limit: Redeem Offer and Void Transaction | 600 per minute per key |
| Sizing | Limits are indicative defaults and are sized per partner at onboarding against expected transaction volumes |
| Recommended client timeout | 10 seconds per call |
| Request body size | 16 KB maximum |
| Token lifetime | Returned as tokenExpiresAt on every POS Login response. A per-partner setting; default 4 hours, so a member scanned when the check is opened can still be recorded when the check is closed |
| Void window | Returned as voidableUntil on every redemption. Indicative: 30 days after redeemedAt |
| Component | Value |
|---|---|
| Source | Partner System (POS terminal or POS server) |
| Source Protocol | HTTPS, JSON. Three POST methods |
| Source Authentication | Static API key in the X-API-Key header |
| Target | EPC (Emirates Platinum Card) API |
| Target Protocol | HTTPS |
| Target Data Format | JSON, UTF-8 |
| Direction | Inbound to EPC. Every call is initiated by the partner system; EPC does not call the partner |
| Base path | /partners/pos/v1 |
┌────────────────────┐ ┌────────────────────┐
│ │ 1. POST /posLogin │ │
│ Partner System │ ── { qrValue, storeNumber, ▶ │ │
│ (cash register) │ terminalId } │ │
│ │ ◀── token, memberId, eligible offers ── │ EPC API │
│ member presents │ │ │
│ QR ──▶ scanner │ 2. POST /redeemOffer │ eligibility rules │
│ │ ── { token, offerId, transactionId, ▶ │ availability │
│ cashier applies │ totalAmount, discountAmount } │ discount check │
│ the offer │ ◀── redemption record ───────────────── │ redemption record │
│ │ │ audit trail │
│ sale voided or │ 3. POST /voidTransaction │ member notice │
│ refunded │ ── { transactionId, reason } ▶ │ │
│ │ ◀── voided record ───────────────────── │ │
└────────────────────┘ └────────────────────┘
X-API-Key header on every call
Flow:
X-API-Key on every call. The key is shown once and stored hashed. A second key can be issued so the partner migrates before the old key is revoked. Revocation is immediatestoreNumber is the partner's own store code, mapped in the backoffice. A store number that is not mapped for the partner that owns the key is rejectedtransactionId. A repeated Redeem Offer for the same sale with the same details is answered with the original record; with different details it is rejected. Two identical requests arriving together cannot both be recordedX-Request-Id header. Quote it when raising a support requestAPI Monitoring
Exception Handling
| HTTP Status | Scenario | Partner Action |
|---|---|---|
| 200 / 201 | Success | Continue. A 200 with duplicate: true means this sale, or this void, was already recorded; treat as success |
| 400 | Validation error, unsupported QR value, invalid amounts, offer not active or not at this store | Do not retry as-is. Correct the request. For an offer that is no longer usable, remove it and complete the sale at full price |
| 401 | API key missing, unknown or revoked | Stop. Check the key configuration; contact the programme team |
| 403 | Partner inactive, or member's membership not active | Partner inactive: stop and contact the programme team. Member not active: inform the member; proceed at full price |
| 404 | Member not found, store number not mapped, offer not found, token not found, redemption not found | Member: inform the member and proceed at full price. Store: configuration issue, contact the programme team. Token: scan again |
| 409 | Availability exhausted; sale already recorded with different details; token already used or issued for another store; void window expired; void from another store | Availability: fall back to full price. Sale already recorded: check details.redemptionId. Token: scan again. Others: do not retry |
| 410 | Token expired | Scan the member again |
| 422 | Member not eligible for the offer at confirmation | Offer one of the offers in details.offers, or proceed at full price |
| 429 | Rate limit exceeded | Back off and retry after the Retry-After header |
| 500 | Server error | Retry the identical request with back-off (1 s, 2 s, 4 s) |
| 503 | Service unavailable | Retry the identical request later |
Retry rules: retry only on 429, 500, 503 and network timeouts, always by re-sending the identical request (same transactionId, same details), at most three times with exponential back-off. Never retry other 4xx responses without changing the request. A Redeem Offer re-sent after a restart is still safe: EPC checks the transactionId first and answers with the existing record when the sale was already recorded (see 4.3.4).
Actors: Cashier, Member, Partner System
Trigger: Member presents their Platinum QR code (or reads out the code printed on their card)
Flow:
POST /partners/pos/v1/posLogin with its storeNumber and terminalId. It may include the transactionId of the open check for correlation404; a member whose membership is not active returns 403token bound to the member, the store and the partner, valid until tokenExpiresAtposId, the type-specific values and the category labelsAn empty offers list means the member was identified but has nothing to use at this store today. Only offers returned by POS Login can be redeemed; an offer that was not returned is rejected on Redeem Offer.
Actors: Cashier, Partner System
Trigger: Sale is tendered with the offer applied
Flow:
POST /partners/pos/v1/redeemOffer with the token, the offerId, its own transactionId, the sale total, the discount given for this offer and the time of saletransactionId with the same details, it returns that record with duplicate: true; with different details it returns 409 code 25044. If only voided redemptions exist, recording proceedsmemberId was sent it must match the token's memberredemptionId, the recorded amounts and voidableUntilredemptionId against the sale is optional; voids work by transactionIdAvailability running out mid-transaction: the offer list shown at POS Login may be minutes old. If the last unit was taken in between, step 6 fails and EPC returns 409 with code 25024. The partner system must remove the discount, inform the customer, and complete the sale at full price. Nothing is recorded on the Platinum side and the token remains usable.
Duplicate submission: if the same sale is sent again with the same details, for example after a timeout, EPC returns 200 with the original redemption and duplicate: true. Nothing is recorded twice and availability is not consumed twice. Two identical requests that arrive at the same time are resolved the same way: one is recorded, the other returns it.
One offer per transaction: a transactionId carries one completed redemption, and the token is consumed by it. Recording a second Platinum offer on the same sale is not supported in this version.
Failed calls: a Redeem Offer rejected with a 4xx other than the token errors leaves the token usable, so the cashier can correct the request and resend.
Actors: Cashier, Partner System
Trigger: The sale is voided at the till, or refunded in full
Flow:
POST /partners/pos/v1/voidTransaction with the transactionId of the sale (or the redemptionId), its storeNumber, terminalId and a reasonvoidableUntil has not passedvoided, releases the availability unit it consumed, updates the member's history and savings, and notifies the member200 with the record and duplicate: true. If the transaction id has only voided redemptions, the latest one is returned with duplicate: trueA void is a status change on the redemption record. No money moves on the Platinum side. A partial refund is not a void; the redemption stands. No token is needed: the member has left, and the sale is identified by its transaction id. After a void, the same transactionId may be recorded again through a fresh POS Login and Redeem Offer, for example when the wrong offer was applied.
Actors: Partner System
Trigger: A call timed out or returned 5xx and the outcome is unknown
Flow:
200 with duplicate: true if the sale was already recorded, or records it now and answers 201. For Void Transaction, EPC answers 200 with duplicate: true if the void was already recorded. For POS Login, EPC issues a new token; earlier tokens stay valid until they expire12500 means AED 125.00. All amounts are in AED; no currency field is sent. Decimal numbers are rejected2026-09-09T14:05:33+04:00. transactionTime is accepted from 24 hours before to 5 minutes after server time. The server's redeemedAt is authoritativetransactionId identifies a sale. A repeated Redeem Offer for the same sale with the same details is answered with the original result and duplicate: true; the same sale is never recorded twice. A repeated Void Transaction behaves the same waytransactionId is the partner's own check, receipt or order number: 1 to 64 characters from A-Z a-z 0-9 . _ -. Among completed redemptions it is unique per partner. Partners with several POS systems whose numbering may overlap must prefix itredemptionId, offerId, token) are opaque strings of up to 64 characters. Do not derive meaning from themmemberId is the only member field returned. It is a stable identifier and may be stored against the sale for reconciliationnull is accepted as equivalent to omissionPOST /partners/pos/v1/posLogin
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
| qrValue | String | Yes | The value read from the member's QR code, or typed from their card, exactly as read | "2155581665" |
| storeNumber | String | Yes | The partner's own store code, as mapped in the Platinum backoffice | "DXB-MALL-01" |
| terminalId | String | Yes | Register or terminal identifier, up to 64 characters | "2" |
| posUser | String | Optional | Cashier or operator code, up to 64 characters. Do not send personal names | "9999" |
| transactionId | String | Optional | The partner's own identifier of the open check. Echoed for correlation only | "5142" |
| Field | Type | Description |
|---|---|---|
| token | String | Single-use value to send on Redeem Offer. Bound to this member, this store and this partner |
| tokenExpiresAt | String (date-time) | When the token can no longer be used |
| generatedAt | String (date-time) | Server time of this response |
| memberId | String | The member's Platinum id. The only member field returned |
| store | Object | { storeNumber, name } — the store as mapped in the backoffice |
| transactionId | String | As sent, when sent |
| offers | Array of Object | Offers this member can use at this store now. Empty when none |
| offers[].offerId | String | Identifier to send on Redeem Offer |
| offers[].posId | String | Partner-facing offer code configured in the backoffice, for keying the promotion in the POS. null when none is configured |
| offers[].name | String | Offer name |
| offers[].shortDescription | String | Short description, up to 500 characters |
| offers[].offerType | String | "discount", "special_price" or "special_deal" (see 6.1) |
| offers[].percentage | Number | Discount percentage. discount offers only |
| offers[].price | Integer | Special price in fils. special_price offers only |
| offers[].label | String | Short label of the benefit, for example "Free dessert". special_deal offers, when configured |
| offers[].startDate | String (date-time) | Start of the offer's redemption period |
| offers[].endDate | String (date-time) | End of the redemption period. Absent when open-ended |
| offers[].status | String | Always "active" in this list |
| offers[].categories | Array of String | Category names the offer is published under |
| offers[].subcategories | Array of String | Subcategory names, when configured |
POST /partners/pos/v1/redeemOffer
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
| token | String | Yes | From the POS Login response | "tk_01J7ZTOK4M5N6P7Q8R9S0T1V2W" |
| memberId | String | Optional | Cross-check: must match the member the token was issued for | "2155581665" |
| offerId | String | Yes | The offer applied. Must be one returned by POS Login for this member and store | "01J7ZOFR5N6P7Q8R9S0T1V2W3X" |
| transactionId | String | Yes | The partner's own identifier of this sale | "5142" |
| storeNumber | String | Yes | The partner's own store code, as mapped in the Platinum backoffice. Must be the store the token was issued for | "DXB-MALL-01" |
| terminalId | String | Yes | Register or terminal identifier, up to 64 characters | "2" |
| posUser | String | Optional | Cashier or operator code, up to 64 characters. Do not send personal names | "9999" |
| totalAmount | Integer | Yes | Total of the sale before the Platinum discount, in fils | 12500 |
| discountAmount | Integer | Yes | Discount given for this Platinum offer, in fils. 0 for a special deal with no monetary discount |
1875 |
| transactionTime | String (date-time) | Yes | Time of sale on the partner system, with UTC offset | "2026-09-09T14:05:33+04:00" |
| receiptId | String | Optional | Receipt number when it differs from transactionId, up to 64 characters |
"R-000123" |
| notes | String | Optional | Free text, up to 500 characters | "Table 12" |
Returned by Redeem Offer (201, or 200 with duplicate: true) and by Void Transaction (200).
| Field | Type | Description |
|---|---|---|
| redemptionId | String | Platinum's identifier for the redemption |
| status | String | "completed" or "voided" |
| duplicate | Boolean | Present and true when the call repeated an earlier recording of the same sale, or an earlier void, and nothing new was recorded |
| transactionId | String | As submitted |
| memberId | String | The member's Platinum id |
| store | Object | { storeNumber, name } |
| offer | Object | { offerId, posId, name, offerType } as at the time of redemption |
| totalAmount | Integer | As submitted, in fils |
| discountAmount | Integer | As submitted, in fils — the discount the member received for this offer |
| transactionTime | String (date-time) | As submitted |
| redeemedAt | String (date-time) | When Platinum recorded the redemption |
| voidableUntil | String (date-time) | Last moment a void is accepted |
| voidedAt | String (date-time) | Present when status is "voided" |
| terminalId | String | As submitted |
| posUser | String | As submitted |
| receiptId | String | As submitted |
| notes | String | As submitted |
POST /partners/pos/v1/voidTransaction
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
| transactionId | String | One of two | The partner's transaction id of the sale to void | "5142" |
| redemptionId | String | One of two | Alternatively, Platinum's redemption id. Exactly one of transactionId and redemptionId must be sent |
"01J7ZRED3L4M5N6P7Q8R9S0T1V" |
| storeNumber | String | Yes | The partner's own store code. Must be the store the redemption was recorded at | "DXB-MALL-01" |
| terminalId | String | Yes | Register or terminal identifier, up to 64 characters | "2" |
| posUser | String | Optional | Cashier or operator code, up to 64 characters. Do not send personal names | "9999" |
| reason | String | Yes | "cancellation" (sale voided at the till), "refund" (sale refunded), "error" (recorded by mistake), "other" |
"refund" |
| note | String | Optional | Free text, up to 500 characters | "Customer returned goods" |
| voidReference | String | Optional | Partner's refund or void reference, up to 64 characters | "RF-000045" |
X-API-Key: <partner api key>
Content-Type: application/json
Accept-Language: en-us
X-Request-Id: <optional client-generated id, echoed back>
Request:
POST /partners/pos/v1/posLogin HTTP/1.1
Host: uat.api.platinum.retter.io
X-API-Key: pk_uat_8f3a…redacted…
Content-Type: application/json
{
"qrValue": "2155581665",
"storeNumber": "DXB-MALL-01",
"terminalId": "2",
"posUser": "9999",
"transactionId": "5142"
}
Response (200 OK) — member identified, two offers usable:
{
"token": "tk_01J7ZTOK4M5N6P7Q8R9S0T1V2W",
"tokenExpiresAt": "2026-09-09T18:05:33+04:00",
"generatedAt": "2026-09-09T14:05:33+04:00",
"memberId": "2155581665",
"store": { "storeNumber": "DXB-MALL-01", "name": "Dubai Mall — Ground Floor" },
"transactionId": "5142",
"offers": [
{
"offerId": "01J7ZOFR5N6P7Q8R9S0T1V2W3X",
"posId": "PLT-DINE15",
"name": "15% off dine-in",
"shortDescription": "15% off the food bill for Platinum members, dine-in only.",
"offerType": "discount",
"percentage": 15,
"startDate": "2026-09-01T00:00:00+04:00",
"endDate": "2026-12-31T23:59:59+04:00",
"status": "active",
"categories": ["Dining"],
"subcategories": ["Restaurants"]
},
{
"offerId": "01J7ZOFR9X8W7V6U5T4S3R2Q1P",
"posId": null,
"name": "Set lunch at AED 49",
"shortDescription": "Two-course set lunch for AED 49, weekdays 12:00 to 15:00.",
"offerType": "special_price",
"price": 4900,
"startDate": "2026-09-01T00:00:00+04:00",
"status": "active",
"categories": ["Dining"]
}
]
}
Response (200 OK) — member identified, nothing to use here today:
{
"token": "tk_01J7ZTOK7P8Q9R0S1T2V3W4X5Y",
"tokenExpiresAt": "2026-09-09T18:06:10+04:00",
"generatedAt": "2026-09-09T14:06:10+04:00",
"memberId": "2155581672",
"store": { "storeNumber": "DXB-MALL-01", "name": "Dubai Mall — Ground Floor" },
"offers": []
}
Response (404 Not Found) — unknown QR value:
{
"code": 25010,
"moduleId": "PartnerPos",
"message": "Member not found"
}
Response (403 Forbidden) — membership not active:
{
"code": 25013,
"moduleId": "PartnerPos",
"message": "Membership is not active"
}
Request:
POST /partners/pos/v1/redeemOffer HTTP/1.1
Host: uat.api.platinum.retter.io
X-API-Key: pk_uat_8f3a…redacted…
Content-Type: application/json
{
"token": "tk_01J7ZTOK4M5N6P7Q8R9S0T1V2W",
"memberId": "2155581665",
"offerId": "01J7ZOFR5N6P7Q8R9S0T1V2W3X",
"transactionId": "5142",
"storeNumber": "DXB-MALL-01",
"terminalId": "2",
"posUser": "9999",
"totalAmount": 12500,
"discountAmount": 1875,
"transactionTime": "2026-09-09T14:35:12+04:00",
"receiptId": "R-000123"
}
Response (201 Created):
{
"redemptionId": "01J7ZRED3L4M5N6P7Q8R9S0T1V",
"status": "completed",
"transactionId": "5142",
"memberId": "2155581665",
"store": { "storeNumber": "DXB-MALL-01", "name": "Dubai Mall — Ground Floor" },
"offer": { "offerId": "01J7ZOFR5N6P7Q8R9S0T1V2W3X", "posId": "PLT-DINE15", "name": "15% off dine-in", "offerType": "discount" },
"totalAmount": 12500,
"discountAmount": 1875,
"transactionTime": "2026-09-09T14:35:12+04:00",
"redeemedAt": "2026-09-09T14:35:13+04:00",
"voidableUntil": "2026-10-09T14:35:13+04:00",
"terminalId": "2",
"posUser": "9999",
"receiptId": "R-000123"
}
Response (200 OK) — the same sale was sent again after a timeout:
{
"redemptionId": "01J7ZRED3L4M5N6P7Q8R9S0T1V",
"status": "completed",
"duplicate": true,
"transactionId": "5142",
"…": "… (remaining fields identical to the original 201 response)"
}
Response (409 Conflict) — the sale was already recorded with different details:
{
"code": 25044,
"moduleId": "PartnerPos",
"message": "This transaction has already been recorded with different details",
"details": {
"redemptionId": "01J7ZRED3L4M5N6P7Q8R9S0T1V",
"status": "completed",
"redeemedAt": "2026-09-09T14:35:13+04:00"
}
}
Response (409 Conflict) — availability exhausted between POS Login and Redeem Offer:
{
"code": 25024,
"moduleId": "PartnerPos",
"message": "Offer availability has been exhausted"
}
Response (400 Bad Request) — amounts inconsistent:
{
"code": 25041,
"moduleId": "PartnerPos",
"message": "Transaction amounts are inconsistent",
"details": {
"reason": "discount_exceeds_total"
}
}
Response (422 Unprocessable Entity) — member no longer eligible at confirmation:
{
"code": 25025,
"moduleId": "PartnerPos",
"message": "Member is not eligible for this offer",
"details": {
"offers": [
{ "offerId": "01J7ZOFR9X8W7V6U5T4S3R2Q1P", "posId": null, "name": "Set lunch at AED 49", "offerType": "special_price", "price": 4900 }
]
}
}
Request:
POST /partners/pos/v1/voidTransaction HTTP/1.1
Host: uat.api.platinum.retter.io
X-API-Key: pk_uat_8f3a…redacted…
Content-Type: application/json
{
"transactionId": "5142",
"storeNumber": "DXB-MALL-01",
"terminalId": "2",
"posUser": "9999",
"reason": "refund",
"note": "Customer returned goods",
"voidReference": "RF-000045"
}
Response (200 OK):
{
"redemptionId": "01J7ZRED3L4M5N6P7Q8R9S0T1V",
"status": "voided",
"transactionId": "5142",
"memberId": "2155581665",
"store": { "storeNumber": "DXB-MALL-01", "name": "Dubai Mall — Ground Floor" },
"offer": { "offerId": "01J7ZOFR5N6P7Q8R9S0T1V2W3X", "posId": "PLT-DINE15", "name": "15% off dine-in", "offerType": "discount" },
"totalAmount": 12500,
"discountAmount": 1875,
"transactionTime": "2026-09-09T14:35:12+04:00",
"redeemedAt": "2026-09-09T14:35:13+04:00",
"voidableUntil": "2026-10-09T14:35:13+04:00",
"voidedAt": "2026-09-11T10:12:07+04:00",
"terminalId": "2",
"posUser": "9999",
"receiptId": "R-000123"
}
Response (200 OK) — void sent again: the same record with duplicate: true.
All errors share one envelope. code is a stable numeric identifier to branch on; message is human-readable and may change; details is present only where documented. The shape is the same as the loyalty POS integration the partner team already knows, with moduleId in place of classId.
{
"code": 25024,
"moduleId": "PartnerPos",
"message": "Offer availability has been exhausted",
"details": { }
}
Validation and rate-limit errors are returned with moduleId "Common".
| Code | Name | HTTP | When | Partner Action |
|---|---|---|---|---|
| 1004 | VALIDATION_ERROR | 400 | Request body fails schema validation, including neither or both of transactionId and redemptionId on a void. details.errors lists fields |
Fix the request |
| 1006 | TOO_MANY_REQUESTS | 429 | Rate limit exceeded for this key, or for this key, store and terminal | Wait for Retry-After, then re-send the same request |
| 25000 | DEFAULT | 500 | Unexpected server error | Re-send the same request with back-off |
| 25001 | INVALID_API_KEY | 401 | X-API-Key missing or unknown |
Check configuration; contact programme team after repeated failures |
| 25002 | API_KEY_REVOKED | 401 | Key has been revoked | Stop. Switch to the replacement key |
| 25005 | PARTNER_INACTIVE | 403 | Partner has been deactivated | Stop. Contact programme team |
| 25007 | STORE_NOT_FOUND | 404 | storeNumber is not mapped for this partner |
Check configuration; contact programme team |
| 25008 | STORE_INACTIVE | 400 | Store has been deactivated | Contact programme team |
| 25010 | MEMBER_NOT_FOUND | 404 | QR value does not resolve to a member | Inform the customer; proceed at full price |
| 25011 | QR_VALUE_UNSUPPORTED | 400 | QR value is in a format this interface does not accept (for example a non-Platinum code) | Ask the member to present their Platinum QR code |
| 25012 | QR_VALUE_INVALID | 400 | QR value is recognised but no longer valid | Ask the member to refresh their QR code and scan again |
| 25013 | MEMBER_NOT_ACTIVE | 403 | Member exists but the membership is not active | Inform the member; proceed at full price |
| 25020 | OFFER_NOT_FOUND | 404 | offerId does not exist or does not belong to this partner |
Use an offer returned by POS Login |
| 25021 | OFFER_NOT_ACTIVE | 400 | Offer is inactive, expired or not yet started. details.reason is inactive, expired or not_started |
Remove the discount; proceed at full price |
| 25022 | OFFER_NOT_AT_STORE | 400 | Offer is not available at this store | Use an offer returned by POS Login for this store |
| 25023 | OFFER_TYPE_NOT_REDEEMABLE | 400 | Offer is a leisure venue check-in offer, which this interface does not handle | Use the Partner Portal |
| 25024 | OFFER_QUOTA_EXCEEDED | 409 | Availability exhausted at confirmation | Remove the discount; complete the sale at full price |
| 25025 | MEMBER_NOT_ELIGIBLE | 422 | Member does not meet the offer's eligibility rules, or the offer was not in this member's POS Login list. details.offers lists offers the member can use |
Offer one of those or proceed at full price |
| 25030 | TOKEN_NOT_FOUND | 404 | token unknown, or issued to another partner |
Scan the member again |
| 25031 | TOKEN_EXPIRED | 410 | Token is past tokenExpiresAt |
Scan the member again |
| 25032 | TOKEN_ALREADY_USED | 409 | Token was already consumed by a Redeem Offer | Scan the member again for a new sale |
| 25033 | TOKEN_STORE_MISMATCH | 409 | storeNumber differs from the store the token was issued for |
Scan the member again at this store |
| 25034 | TOKEN_MEMBER_MISMATCH | 409 | memberId sent does not match the token's member |
Check the POS state; scan again |
| 25041 | DISCOUNT_INVALID | 400 | Amounts are inconsistent or impossible for the offer. details.reason explains (see 6.1) |
Correct the amounts |
| 25043 | TRANSACTION_TIME_OUT_OF_RANGE | 400 | transactionTime is more than 24 hours in the past or more than 5 minutes in the future |
Check the partner system clock |
| 25044 | TRANSACTION_ALREADY_REDEEMED | 409 | A completed redemption exists for this transactionId with different details. details.redemptionId, details.status, details.redeemedAt identify it |
Investigate; void it first if the recording was wrong |
| 25050 | REDEMPTION_NOT_FOUND | 404 | No completed or voided redemption for this transactionId or redemptionId at this partner |
Nothing to void |
| 25051 | VOID_WINDOW_EXPIRED | 409 | voidableUntil has passed |
Contact programme team for a manual reversal |
| 25052 | VOID_STORE_MISMATCH | 409 | storeNumber differs from the store the redemption was recorded at |
Void from the store that made the sale |
| Method | Codes |
|---|---|
| POS Login | 1004, 1006, 25000, 25001, 25002, 25005, 25007, 25008, 25010, 25011, 25012, 25013 |
| Redeem Offer | 1004, 1006, 25000, 25001, 25002, 25005, 25007, 25008, 25020, 25021, 25022, 25023, 25024, 25025, 25030, 25031, 25032, 25033, 25034, 25041, 25043, 25044 |
| Void Transaction | 1004, 1006, 25000, 25001, 25002, 25005, 25007, 25008, 25050, 25051, 25052 |
{
"code": 1004,
"moduleId": "Common",
"message": "Validation failed",
"details": {
"errors": {
"totalAmount": ["Expected integer, received number"]
}
}
}
{
"code": 25001,
"moduleId": "PartnerPos",
"message": "API key is missing or invalid"
}
{
"code": 25041,
"moduleId": "PartnerPos",
"message": "Transaction amounts are inconsistent",
"details": {
"reason": "discount_implausible"
}
}
details.reason is one of negative_amount, discount_exceeds_total, discount_implausible.
{
"code": 25031,
"moduleId": "PartnerPos",
"message": "Token has expired"
}
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json
{
"code": 1006,
"moduleId": "Common",
"message": "Too many requests. Please try again later."
}
| Environment | Base URL |
|---|---|
| Staging | https://staging.api.platinum.retter.io/partners/pos/v1 |
| UAT | https://uat.api.platinum.retter.io/partners/pos/v1 |
| Production | Issued at go-live |
| Method | Path | Purpose |
|---|---|---|
| POST | /posLogin |
Identify the member from the QR value; return the member id, a single-use token and the offers usable at this store |
| POST | /redeemOffer |
Record the redemption of an offer on a sale (retry-safe by transaction id) |
| POST | /voidTransaction |
Void the redemption of a sale |
All three methods require the X-API-Key header. The machine-readable contract is the OpenAPI specification openapi-partner-pos.draft.yaml that accompanies this document. Where this document and the specification differ, the specification prevails for field names and types, and this document prevails for behaviour.
| Offer type | Meaning | Partner sends as `discountAmount` | Platinum's reference discount (fils) |
|---|---|---|---|
discount |
Percentage off | The discount actually applied for this offer | round(totalAmount × percentage ÷ 100). Used to reject implausible figures only; an offer applied to part of the sale may legitimately come in below it |
special_price |
A fixed member price for a defined item or set | The discount actually applied for this offer | Not computed |
special_deal |
A non-monetary benefit (for example a complimentary item) | 0 unless a monetary value was given |
Not computed |
The partner-reported discountAmount is recorded as the discount the member received. Platinum computes the reference discount for its own reporting and rejects only impossible figures.
Validation of amounts at Redeem Offer (performed before availability is reserved)
| Rule | Outcome |
|---|---|
| Any amount negative or non-integer | 400 code 25041, reason: negative_amount |
discountAmount greater than totalAmount |
400 code 25041, reason: discount_exceeds_total |
discount offer with discountAmount far above the reference (indicative: more than 1.5 × reference + 500 fils) |
400 code 25041, reason: discount_implausible |
The implausibility threshold is configuration on the Platinum side and is confirmed with the partner team.
POST /posLogin ──▶ token (single use, valid until tokenExpiresAt)
│
│ POST /redeemOffer: token consumed, availability reserved
▼
┌─────────────┐ POST /voidTransaction ┌───────────┐
│ completed │ ───────────────────────────────────────▶ │ voided │
└─────────────┘ within voidableUntil, same store, └───────────┘
│ availability released │
│ │
repeat redeemOffer repeat voidTransaction
(same transactionId, same details) → 200, duplicate: true
→ 200, duplicate: true │
▼
new posLogin + redeemOffer with
the same transactionId
→ new completed record
completed redemption counts towards the member's savings and the offer's consumed availabilityvoided redemption is retained for audit, with voidedAtvoided; recording the sale again needs a fresh POS Login and a new Redeem Offer