openapi: 3.1.0 info: title: Cardy Public Loyalty API version: 1.0.0 description: | The stable integration surface for POS systems and e-commerce platforms. Five intents: identify, enroll, earn, redeem, reverse — plus redemption reservations for online checkout flows. Amounts are integer minor units (KWD: 12.500 KWD = 12500). All POST endpoints are idempotent on (source, external_ref, intent); source is derived from your API key, never sent by you. Behavioral details: docs/03-PUBLIC-API.md. servers: - url: https://api.cardy.example.com/v1 description: Production - url: https://sandbox.cardy.example.com/v1 description: Sandbox (use cardy_pk_test_* keys) security: - bearerAuth: [] tags: - name: Customers - name: Transactions - name: Redemptions paths: /customers/identify: post: tags: [Customers] operationId: identifyCustomer summary: Resolve a customer and their cards/balances by any identifier requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/IdentifyRequest' responses: '401': { $ref: '#/components/responses/Unauthorized' } '200': description: Customer found content: application/json: schema: $ref: '#/components/schemas/CustomerProfile' '404': { $ref: '#/components/responses/NotFound' } '400': { $ref: '#/components/responses/ValidationError' } /customers/enroll: post: tags: [Customers] operationId: enrollCustomer summary: Create a customer and issue a wallet card parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EnrollRequest' responses: '401': { $ref: '#/components/responses/Unauthorized' } '200': description: | Idempotent replay of an enrollment that already issued a card (`duplicate: true`). Nothing was created by this call. content: application/json: schema: $ref: '#/components/schemas/EnrollResult' '201': description: Enrolled (verified per tenant OTP policy) content: application/json: schema: $ref: '#/components/schemas/EnrollResult' '202': description: | OTP verification required (otpPolicy=strict — today, every tenant). A repeat of the same `external_ref` returns the enrollment already in flight; it sends a replacement code only after a cooling-off interval and within a per-enrollment send budget, so a partner's retry loop cannot text one phone repeatedly. There is no separate resend endpoint for that reason. content: application/json: schema: $ref: '#/components/schemas/EnrollPending' '409': description: | This phone or email already belongs to a customer. `ALREADY_ENROLLED`, with that customer beside the error so a till can switch straight to their card rather than asking the cashier to look them up again. content: application/json: schema: $ref: '#/components/schemas/AlreadyEnrolled' '400': { $ref: '#/components/responses/ValidationError' } /customers/enroll/verify: post: tags: [Customers] operationId: verifyEnrollment summary: Complete a strict-OTP enrollment requestBody: required: true content: application/json: schema: type: object required: [enrollment_id, otp] properties: enrollment_id: { type: string } otp: { type: string } responses: '401': { $ref: '#/components/responses/Unauthorized' } '200': description: 'Idempotent replay of a completed enrollment (`duplicate: true`)' content: application/json: schema: $ref: '#/components/schemas/EnrollResult' '201': description: Enrollment completed — customer created and card issued content: application/json: schema: $ref: '#/components/schemas/EnrollResult' '422': description: | `OTP_INVALID`. `details[0]` says which: `otp_incorrect`, `otp_expired`, `otp_attempts_exhausted`, or `enrollment_not_found`. content: application/json: schema: { $ref: '#/components/schemas/Error' } /customers/{customer_id}/transactions: get: tags: [Customers] operationId: listCustomerTransactions summary: Paginated transaction history for a customer parameters: - name: customer_id in: path required: true schema: { type: string } - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '401': { $ref: '#/components/responses/Unauthorized' } '200': description: Transactions page content: application/json: schema: $ref: '#/components/schemas/TransactionPage' /transactions/earn: post: tags: [Transactions] operationId: earn summary: Record a purchase / grant loyalty value (accept-then-commit) parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EarnRequest' responses: '401': { $ref: '#/components/responses/Unauthorized' } '202': description: Accepted for processing content: application/json: schema: $ref: '#/components/schemas/TransactionAccepted' '200': description: Idempotent replay of a completed transaction content: application/json: schema: $ref: '#/components/schemas/Transaction' '400': { $ref: '#/components/responses/ValidationError' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/BusinessError' } /transactions/redeem: post: tags: [Transactions] operationId: redeem summary: Redeem loyalty value (synchronous — cashier/customer is waiting) parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RedeemRequest' responses: '401': { $ref: '#/components/responses/Unauthorized' } '200': description: Redeemed; new balances included content: application/json: schema: $ref: '#/components/schemas/Transaction' '400': { $ref: '#/components/responses/ValidationError' } '422': { $ref: '#/components/responses/BusinessError' } '404': { $ref: '#/components/responses/NotFound' } '409': description: This coupon has already been redeemed content: application/json: schema: { $ref: '#/components/schemas/Error' } '504': description: Upstream timeout — safe to retry with the same external_ref content: application/json: schema: { $ref: '#/components/schemas/Error' } /transactions/reverse: post: tags: [Transactions] operationId: reverseByRef summary: Reverse a prior transaction by your external_ref (refund/void) parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ReverseByRefRequest' responses: '401': { $ref: '#/components/responses/Unauthorized' } '202': description: Reversal accepted content: application/json: schema: $ref: '#/components/schemas/ReversalAccepted' '200': description: Idempotent replay of a reversal already accepted content: application/json: schema: $ref: '#/components/schemas/ReversalAccepted' '400': description: > Not reversible as asked — the original never succeeded, or a partial value was supplied (partial reversals are not supported). content: application/json: schema: { $ref: '#/components/schemas/Error' } '404': { $ref: '#/components/responses/NotFound' } '422': description: > This transaction has no reversal — a claimed reward or a redeemed coupon consumes a one-shot entitlement with no way back. content: application/json: schema: { $ref: '#/components/schemas/Error' } '409': description: Already reversed content: application/json: schema: { $ref: '#/components/schemas/Error' } /transactions/{transaction_id}/reverse: post: tags: [Transactions] operationId: reverseById summary: Reverse a prior transaction by its Cardy transaction id parameters: - name: transaction_id in: path required: true schema: { type: string } - $ref: '#/components/parameters/IdempotencyKey' requestBody: content: application/json: schema: $ref: '#/components/schemas/ReversePartialBody' responses: '401': { $ref: '#/components/responses/Unauthorized' } '202': description: Reversal accepted content: application/json: schema: $ref: '#/components/schemas/ReversalAccepted' '200': description: Idempotent replay of a reversal already accepted content: application/json: schema: $ref: '#/components/schemas/ReversalAccepted' '400': description: > Not reversible as asked — the original never succeeded, or a partial value was supplied (partial reversals are not supported). content: application/json: schema: { $ref: '#/components/schemas/Error' } '404': { $ref: '#/components/responses/NotFound' } '422': description: > This transaction has no reversal — a claimed reward or a redeemed coupon consumes a one-shot entitlement with no way back. content: application/json: schema: { $ref: '#/components/schemas/Error' } '409': description: Already reversed content: application/json: schema: { $ref: '#/components/schemas/Error' } /transactions/{transaction_id}: get: tags: [Transactions] operationId: getTransaction summary: Poll a transaction's status/result parameters: - name: transaction_id in: path required: true schema: { type: string } responses: '401': { $ref: '#/components/responses/Unauthorized' } '200': description: Transaction content: application/json: schema: $ref: '#/components/schemas/Transaction' '404': { $ref: '#/components/responses/NotFound' } /transactions: get: tags: [Transactions] operationId: findTransactions summary: Look up transactions by your external_ref parameters: - name: external_ref in: query required: true schema: { type: string } responses: '401': { $ref: '#/components/responses/Unauthorized' } '200': description: Matching transactions (0..n — earn and redeem may share a ref) content: application/json: schema: type: object properties: data: type: array items: { $ref: '#/components/schemas/Transaction' } /redemptions/reserve: post: tags: [Redemptions] operationId: reserveRedemption summary: Hold points against an online checkout (no balance moves yet) parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ReserveRequest' responses: '401': { $ref: '#/components/responses/Unauthorized' } '201': description: Reserved content: application/json: schema: $ref: '#/components/schemas/Reservation' '200': description: Idempotent replay — this cart already holds these points content: application/json: schema: $ref: '#/components/schemas/Reservation' '400': { $ref: '#/components/responses/ValidationError' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/BusinessError' } /redemptions/{reservation_id}/commit: post: tags: [Redemptions] operationId: commitRedemption summary: Commit a reservation once the order is paid parameters: - name: reservation_id in: path required: true schema: { type: string } - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: type: object required: [external_ref] properties: external_ref: type: string description: The paid order's id — becomes the REDEEM transaction's external_ref responses: '401': { $ref: '#/components/responses/Unauthorized' } '200': description: Committed — redemption transaction created content: application/json: schema: $ref: '#/components/schemas/Transaction' '400': { $ref: '#/components/responses/ValidationError' } '404': { $ref: '#/components/responses/NotFound' } '409': description: The reservation was released and cannot be committed content: application/json: schema: { $ref: '#/components/schemas/Error' } '410': description: > Reservation expired. It existed and its time ran out — reserve again rather than retrying this one. content: application/json: schema: { $ref: '#/components/schemas/Error' } '422': { $ref: '#/components/responses/BusinessError' } /redemptions/{reservation_id}/release: post: tags: [Redemptions] operationId: releaseRedemption summary: Release a reservation (cart abandoned / customer changed mind) parameters: - name: reservation_id in: path required: true schema: { type: string } responses: '401': { $ref: '#/components/responses/Unauthorized' } '200': description: Released (idempotent — releasing a released reservation is a no-op) content: application/json: schema: $ref: '#/components/schemas/Reservation' '404': { $ref: '#/components/responses/NotFound' } '409': description: The reservation was already committed content: application/json: schema: { $ref: '#/components/schemas/Error' } components: securitySchemes: bearerAuth: type: http scheme: bearer description: 'Partner API key: cardy_pk_live_* or cardy_pk_test_*' parameters: IdempotencyKey: name: Idempotency-Key in: header required: false schema: { type: string, maxLength: 128 } description: Optional; the natural key (source, external_ref, intent) is enforced regardless. Cursor: name: cursor in: query schema: { type: string } Limit: name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 25 } responses: Unauthorized: description: | Missing, unknown, revoked or expired key — or a disallowed address. The code does not say which: probing for a valid key learns nothing, while a real partner sees the detail in their dashboard. content: application/json: schema: { $ref: '#/components/schemas/Error' } NotFound: description: Customer/transaction not found content: application/json: schema: { $ref: '#/components/schemas/Error' } ValidationError: description: Malformed request content: application/json: schema: { $ref: '#/components/schemas/Error' } BusinessError: description: Business rule violation (CARD_NOT_ELIGIBLE, INSUFFICIENT_BALANCE, ...) content: application/json: schema: { $ref: '#/components/schemas/Error' } schemas: Error: type: object required: [error] properties: error: type: object required: [code, message, retryable, request_id] properties: code: type: string enum: [ UNAUTHENTICATED, CUSTOMER_NOT_FOUND, CARD_NOT_ELIGIBLE, INSUFFICIENT_BALANCE, COUPON_ALREADY_REDEEMED, RESERVATION_EXPIRED, VALIDATION_ERROR, TENANT_SUSPENDED, RATE_LIMITED, UPSTREAM_TIMEOUT, TEMPLATE_MECHANICS_UNKNOWN, ALREADY_REVERSED, ALREADY_ENROLLED, OTP_INVALID, DUPLICATE_TRANSACTION, INTERNAL, ] message: { type: string } retryable: { type: boolean } request_id: { type: string } details: type: array items: {} Identifier: type: object description: > Exactly one of these must be present. Two would be ambiguous — if they belonged to different customers, any choice could award points to the wrong person — so the request is refused rather than resolved. oneOf: - required: [loyalty_id] - required: [phone] - required: [email] - required: [external_id] - required: [customer_id] properties: loyalty_id: { type: string, description: 'Card number from QR/barcode scan' } phone: { type: string, description: 'Any parseable format; normalized to E.164' } email: { type: string, format: email } external_id: { type: string, description: 'Your own user id' } customer_id: { type: string, description: 'Cardy customer id from a prior identify' } IdentifyRequest: allOf: - $ref: '#/components/schemas/Identifier' CustomerProfile: type: object # Every field is always present; the nullable ones carry null rather than # disappearing, so a client can read them without guarding. required: [customer_id, name, phone, email, verified, balances_as_of, cards] properties: customer_id: { type: string } name: { type: string, nullable: true } phone: { type: string, nullable: true } email: { type: string, nullable: true } verified: { type: boolean } balances_as_of: { type: string, format: date-time } cards: type: array items: type: object properties: card_id: { type: string } template_name: { type: string } card_type: type: integer description: 0=Stamp 1=Cashback 2=Subscription 3=Coupon 4=Discount 5=Certificate 6=Membership 7=Reward balances: type: object properties: points: { type: number, nullable: true } cashback_minor: { type: integer, nullable: true } stamps: { type: integer, nullable: true } visits: { type: integer, nullable: true } rewards_available: type: integer nullable: true description: How many rewards this card can claim right now currency: { type: string, nullable: true } available_rewards: type: array description: > Rewards this card can claim, when it offers a choice. Pass one reward_id to POST /transactions/redeem with reward: true. Empty for cards that earn whole rewards (see rewards_available). items: type: object properties: reward_id: { type: string } name: { type: string } allowed_operations: type: array description: What this card supports right now — drive your UI from this. items: type: string enum: [ earn_amount, earn_points, earn_stamp, earn_visit, redeem_amount, redeem_points, redeem_stamp, redeem_visit, redeem_reward, redeem_coupon, ] EnrollRequest: type: object required: [external_ref] # The wallet card is delivered to one of these; without either we would # create a customer who can never be handed their card. Under the strict # OTP policy it must be the phone — see docs/03. anyOf: - required: [phone] - required: [email] properties: external_ref: { type: string, description: 'Your registration/user reference (idempotency)' } phone: type: string description: | Any parseable format; normalized to E.164. Required under the strict OTP policy — it is where the verification code is sent. email: { type: string, format: email } name: { type: string } template_id: type: string description: | Omit to use the tenant default. A template this store does not have is `VALIDATION_ERROR` / `template_not_found`; a tenant with no default configured answers `template_id_required`. channel: type: string enum: [pos, ecommerce] branch_ref: { type: string } EnrollResult: type: object required: [status, customer_id, card_id, wallet_install_url, verified, duplicate] properties: status: type: string enum: [enrolled] description: Discriminates this from EnrollPending on the same endpoint customer_id: { type: string } card_id: { type: string } wallet_install_url: type: string format: uri description: The link that installs the card into the customer's wallet verified: { type: boolean } duplicate: { type: boolean, description: 'true if idempotent replay' } AlreadyEnrolled: allOf: - $ref: '#/components/schemas/Error' - type: object required: [customer] properties: customer: { $ref: '#/components/schemas/CustomerProfile' } EnrollPending: type: object required: [enrollment_id, status, otp_expires_at] properties: enrollment_id: { type: string } status: { type: string, enum: [awaiting_otp] } otp_expires_at: { type: string, format: date-time } EarnRequest: type: object required: [identifier, external_ref] # At least one value dimension. `amount_minor` is the ordinary Level-1 # field; the rest are explicit grants. anyOf: - required: [amount_minor] - required: [points] - required: [stamps] - required: [visits] properties: identifier: { $ref: '#/components/schemas/Identifier' } external_ref: { type: string, description: 'Receipt/order id — the idempotency anchor' } card_id: { type: string, description: 'Required only when the customer has multiple eligible cards', } amount_minor: type: integer description: Purchase amount in minor units — the normal Level-1 field currency: type: string default: KWD minLength: 3 maxLength: 3 description: ISO 4217, upper-cased. KWD has three decimals — 12.500 is 12500. points: { type: number, description: 'Explicit grant — bypasses amount-based earning' } stamps: { type: integer } visits: { type: integer } branch_ref: { type: string } employee_ref: { type: string } device_ref: { type: string } occurred_at: type: string format: date-time description: For offline-batched POS transactions; bounded by tenant offlineEarnWindowHours metadata: { type: object, additionalProperties: true } RedeemRequest: type: object required: [identifier, external_ref] # At least one of the six things a redemption can be. `coupon_code` and # `reward` are the two that are not quantities (docs/03). anyOf: - required: [points] - required: [amount_minor] - required: [stamps] - required: [visits] - required: [coupon_code] - required: [reward] properties: identifier: { $ref: '#/components/schemas/Identifier' } external_ref: { type: string } card_id: { type: string } amount_minor: { type: integer, description: 'Cashback to apply' } currency: type: string default: KWD minLength: 3 maxLength: 3 description: ISO 4217, upper-cased. KWD has three decimals — 12.500 is 12500. points: { type: number } stamps: { type: integer } visits: { type: integer } coupon_code: type: string description: > The coupon the customer presented. A coupon is a card, so this is its card_id as returned by identify. reward: type: boolean enum: [true] description: > Claim the earned reward on a Reward or Stamp card. Only `true` means anything — omit the field rather than sending `false`. reward_id: type: string description: > Which reward to claim, from available_rewards on the identify response. Required only when the card offers more than one. branch_ref: { type: string } employee_ref: { type: string } ReverseByRefRequest: type: object required: [external_ref, intent] properties: external_ref: { type: string, description: "The original transaction's external_ref" } intent: type: string enum: [earn, redeem] description: Which side of the original receipt to reverse reversal_ref: { type: string, description: 'Your refund/void reference (idempotency of the reversal itself)', } amount_minor: { type: integer, description: 'Partial reversal amount; omit for full' } points: { type: number } reason: { type: string } ReversePartialBody: type: object properties: reversal_ref: { type: string } amount_minor: { type: integer } points: { type: number } reason: { type: string } ReserveRequest: type: object required: [identifier, external_ref] anyOf: - required: [points] - required: [amount_minor] properties: identifier: { $ref: '#/components/schemas/Identifier' } external_ref: { type: string, description: 'Checkout/cart id' } card_id: { type: string } points: { type: number } amount_minor: { type: integer } currency: type: string default: KWD minLength: 3 maxLength: 3 description: ISO 4217, upper-cased. KWD has three decimals — 12.500 is 12500. ttl_minutes: { type: integer, description: 'Override tenant default; capped at 120' } Reservation: type: object required: [ reservation_id, state, card_id, points, amount_minor, discount_value_minor, currency, expires_at, duplicate, ] properties: reservation_id: { type: string } state: { type: string, enum: [reserved, committed, released, expired] } card_id: { type: string } points: { type: number, nullable: true } amount_minor: { type: integer, nullable: true } discount_value_minor: type: integer nullable: true description: > The checkout discount this reservation is worth, per the tenant's conversion config. Null when no rate is configured — what a point is worth in money is a commercial decision and is never defaulted. currency: { type: string, nullable: true } expires_at: { type: string, format: date-time } duplicate: { type: boolean } TransactionAccepted: type: object required: [transaction_id, status, duplicate] properties: transaction_id: { type: string } status: { type: string, enum: [pending] } duplicate: { type: boolean } ReversalAccepted: allOf: - $ref: '#/components/schemas/TransactionAccepted' - type: object required: [reverses_transaction_id] properties: reverses_transaction_id: type: string description: The transaction this reversal undoes Transaction: type: object # `duplicate` is absent from the read endpoints — it describes what a write # call did, not what the row is — so it is deliberately not required here. required: [ transaction_id, intent, status, external_ref, customer_id, card_id, amount_minor, currency, points, stamps, visits, new_balances, error, occurred_at, completed_at, ] properties: transaction_id: { type: string } intent: { type: string, enum: [earn, redeem, reverse, enroll, adjust] } status: { type: string, enum: [pending, processing, success, failed, reversed] } duplicate: { type: boolean } external_ref: { type: string } customer_id: { type: string, nullable: true } card_id: { type: string, nullable: true } amount_minor: { type: integer, nullable: true } currency: { type: string, nullable: true } points: { type: number, nullable: true } stamps: { type: integer, nullable: true } visits: { type: integer, nullable: true } new_balances: type: object nullable: true description: Present on synchronous success (redeem) and on completed polls error: # Null on every transaction that has not failed — which is most of # them. `nullable` cannot sit beside a `$ref` in OpenAPI 3.0, hence # the single-entry `oneOf`. nullable: true oneOf: - $ref: '#/components/schemas/Error/properties/error' occurred_at: { type: string, format: date-time } completed_at: { type: string, format: date-time, nullable: true } TransactionPage: type: object required: [data, next_cursor] properties: data: type: array items: { $ref: '#/components/schemas/Transaction' } next_cursor: { type: string, nullable: true }