openapi: 3.1.0
info:
  title: LoyalTribe Public API
  version: 1.0.0
  summary: Build your own loyalty widget on top of LoyalTribe.
  description: |
    # Introduction

    The **LoyalTribe Public API** lets you build a fully custom loyalty experience —
    your own widget, your own design, your own stack — on top of the LoyalTribe
    rewards platform. Everything the official widget can do is available here:

    - Show your loyalty **program** — branding, tiers, earning rules and the reward catalog
    - Authenticate **customers** securely without exposing any secrets in the browser
    - Read a customer's **points balance**, tier, history and coupons
    - **Redeem** rewards and receive discount codes / store credit in real time

    All endpoints live under:

    ```
    /api/public/v1
    ```

    Responses are JSON. All timestamps are RFC 3339 / ISO 8601 in UTC.

    # Authentication

    The API uses two credentials, issued per shop from the LoyalTribe admin
    (ask your account manager, or your platform administrator, for a key pair):

    | Credential | Looks like | Where it lives | What it can do |
    |---|---|---|---|
    | **Publishable key** | `pk_live_…` | Browser / storefront JS — safe to expose | Read public program data, exchange signed identities for customer tokens |
    | **Secret key** | `sk_live_…` | **Your server only** — never in a browser | Sign customer identities (HMAC-SHA256) |

    Every request must carry the publishable key:

    ```http
    X-API-Key: pk_live_4f6a09...
    ```

    Customer endpoints (`/customer/*`) additionally require a **customer token**:

    ```http
    Authorization: Bearer eyJhbGciOi...
    ```

    > ⚠️ **Never ship your secret key to a browser, mobile app, or repository.**
    > If a secret leaks, revoke the key in the dashboard immediately — revocation
    > is instant.

    # Customer authentication flow

    Customer identity is asserted by **your server**, the only party holding the
    secret key. The flow has three steps:

    ```
    Your server                     Storefront widget                LoyalTribe API
    ───────────                     ─────────────────                ──────────────
    1. customer logs in
       sign identity with sk_… ──▶  2. POST /auth/customer-token ──▶ verify HMAC
                                       (pk_… + signed identity)      look up customer
                                    3. Bearer <token>            ◀── short-lived JWT (1h)
                                       on every /customer/* call
    ```

    ### Step 1 — sign the identity (server-side)

    Build the canonical message and sign it with your **secret key** using
    HMAC-SHA256, hex-encoded:

    ```
    message   = "{customer_id}:{email}:{timestamp}"
    signature = hex( HMAC_SHA256( secret_key, message ) )
    ```

    - `customer_id` — the platform customer id (e.g. the Shopify customer ID). May be empty if you identify by email.
    - `email` — the customer's email. May be empty if you identify by customer_id.
    - `timestamp` — current unix time in **seconds**. Signatures expire after **5 minutes**.
    - Empty fields stay empty in the message — e.g. identifying by email only:
      `":jane@example.com:1718200000"`.

    **Node.js**

    ```js
    const crypto = require("crypto");

    function signCustomer(secretKey, customerId, email) {
      const timestamp = Math.floor(Date.now() / 1000);
      const message = `${customerId}:${email}:${timestamp}`;
      const signature = crypto
        .createHmac("sha256", secretKey)
        .update(message)
        .digest("hex");
      return { customer_id: customerId, email, timestamp, signature };
    }
    ```

    **PHP**

    ```php
    function signCustomer(string $secretKey, string $customerId, string $email): array {
      $timestamp = time();
      $message   = "{$customerId}:{$email}:{$timestamp}";
      $signature = hash_hmac('sha256', $message, $secretKey);
      return [
        'customer_id' => $customerId,
        'email'       => $email,
        'timestamp'   => $timestamp,
        'signature'   => $signature,
      ];
    }
    ```

    **Python**

    ```python
    import hmac, hashlib, time

    def sign_customer(secret_key: str, customer_id: str, email: str) -> dict:
        timestamp = int(time.time())
        message = f"{customer_id}:{email}:{timestamp}"
        signature = hmac.new(
            secret_key.encode(), message.encode(), hashlib.sha256
        ).hexdigest()
        return {
            "customer_id": customer_id,
            "email": email,
            "timestamp": timestamp,
            "signature": signature,
        }
    ```

    Render the resulting object into the storefront page for the logged-in
    customer (e.g. a `<script>window.LOYALTY_IDENTITY = {...}</script>` block,
    or an endpoint on your own backend the widget can call).

    ### Step 2 — exchange it for a customer token (browser)

    The widget posts the signed identity to
    [`POST /auth/customer-token`](#tag/customer-authentication/post/auth/customer-token)
    with the publishable key and receives a JWT valid for **1 hour**.

    ### Step 3 — call customer endpoints

    Send the token as `Authorization: Bearer <token>` on every `/customer/*`
    request. When it expires (`401 invalid_token`), exchange a fresh signed
    identity — your server signs a new one on the next page load.

    # Platform-agnostic integration (any commerce stack)

    LoyalTribe is not Shopify-only. A merchant on **any platform** — a custom
    storefront, WooCommerce, a POS — integrates with three server-to-server
    endpoints, authenticated with the **secret key**
    (`Authorization: Bearer sk_live_…`, never from a browser):

    | Step | Endpoint | When |
    |---|---|---|
    | Enroll customers | `POST /customers` | At registration / opt-in |
    | Earn points | `POST /orders` | When an order is paid |
    | Honor coupons | `POST /coupons/validate` + `POST /coupons/consume` | At your checkout |

    The flow end to end:

    1. Your backend enrolls the customer (`POST /customers`).
    2. On every paid order, your backend posts it (`POST /orders`) — the rules
       engine awards points asynchronously (typically within a second), with
       full dedup per `order_id` (safe to retry).
    3. Customers browse and redeem rewards through the same widget endpoints as
       Shopify shops. On non-Shopify platforms a redemption mints an **internal
       coupon code** instead of a platform discount.
    4. At checkout, your backend calls `POST /coupons/validate` to apply the
       discount, then `POST /coupons/consume` once the order is placed.
       Consumption is atomic — a code can never be used twice.

    Not yet available on non-Shopify platforms: store-credit rewards and
    rule-awarded vouchers (rules that *grant* points work everywhere).

    # Rate limits

    Limits are applied **per API key**:

    | Scope | Limit |
    |---|---|
    | All endpoints | 120 requests / minute |
    | `POST /auth/customer-token` | 30 requests / minute |
    | `POST /customer/redeem` | 20 requests / minute |

    Exceeding a limit returns `429` with code `rate_limited`. Back off and retry.

    # Errors

    Errors use a consistent envelope with a stable machine-readable `code`:

    ```json
    {
      "error": {
        "code": "invalid_signature",
        "message": "Signature verification failed. Sign \"{customer_id}:{email}:{timestamp}\" with your secret key using HMAC-SHA256 (hex)."
      }
    }
    ```

    | HTTP | Code | Meaning |
    |---|---|---|
    | 400 | `invalid_request` | Malformed body or missing required fields |
    | 401 | `missing_api_key` / `invalid_api_key` | No / unknown / revoked publishable key |
    | 401 | `missing_token` / `invalid_token` | No / expired customer token |
    | 401 | `signature_expired` / `invalid_signature` | Identity signature too old or wrong |
    | 403 | `merchant_mismatch` | Token was issued for a different shop's key |
    | 403 | `customer_not_enrolled` | Customer exists but hasn't joined the program |
    | 404 | `customer_not_found` | No matching loyalty customer |
    | 429 | `rate_limited` | Per-key rate limit exceeded |

    > Some endpoints reused from the core platform (redeem, redemptions,
    > dashboard) return their legacy shapes — e.g. `{"error": "..."}` as a plain
    > string. Always handle both forms.

    # Versioning

    The API is versioned in the path (`/api/public/v1`). Backwards-compatible
    additions (new fields, new endpoints) happen without notice — build your
    parser to ignore unknown fields. Breaking changes ship as `/v2`.
  contact:
    name: LoyalTribe Developer Support
    email: projects@ssdc.co
  x-logo:
    url: /assets/logo.png
    altText: LoyalTribe

servers:
  - url: https://api.staging.loyaltriberewards.com/api/public/v1
    description: Staging
  - url: https://api.loyaltriberewards.com/api/public/v1
    description: Production

tags:
  - name: Program
    description: |
      Public, read-only data about the shop's loyalty program — everything a
      widget needs to render before any customer signs in. Requires only the
      publishable key.
  - name: Customer Authentication
    description: |
      Exchange a merchant-signed customer identity for a short-lived customer
      token. See the **Customer authentication flow** section above for the
      signing recipe.
  - name: Gamification
    description: |
      Spin-the-wheel and scratch-card mechanics. Available only when the
      merchant has the corresponding feature enabled (`SPIN_WHEEL` /
      `VOUCHER_SCRATCH`) — otherwise these endpoints return
      `403 feature_disabled`-style errors. All of them require the
      publishable key **and** a customer token.

      **Implementing a wheel:** fetch the config (`GET
      /customer/gamification/wheel`) and render the `segments` in
      `sortOrder` with their `label`/`color` (the merchant may also supply
      `backgroundImageUrl`/`pointerImageUrl` art). `status.canPlay` tells
      you whether to enable the spin button — show `status.reason`
      (`cooldown`, `daily_limit`, `needs_purchase`, `already_played`)
      otherwise. On spin, the **server** picks the prize: animate the wheel
      to the returned `segment`, then show the prize from `play`
      (points, a discount code, store credit, or `no_win`).

      **Implementing scratch cards:** list the cards, render
      `coverImageUrl`, call play on scratch, reveal the prize from the
      response. A card's last reveal is included so you can keep showing
      an already-scratched prize across reloads.
  - name: Server-to-Server
    description: |
      Trusted endpoints for the merchant's backend, authenticated with the
      **secret key** (`Authorization: Bearer sk_live_…`). This is how any
      platform — custom storefront, WooCommerce, POS — enrolls customers,
      pushes paid orders into the points engine, and honors internally minted
      coupon codes at checkout.
  - name: Customer
    description: |
      Endpoints acting on behalf of one authenticated customer. All of them
      require the publishable key **and** a `Bearer` customer token.

security:
  - ApiKeyAuth: []

paths:
  /program:
    get:
      operationId: getProgram
      tags: [Program]
      summary: Get program configuration
      description: |
        Branding, copy, FAQ and currency for the shop's loyalty program.
        Use it to render the widget shell — title, intro text, accent color —
        before the customer authenticates.
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/program \
              -H "X-API-Key: pk_live_YOUR_KEY"
        - lang: JavaScript
          source: |
            const res = await fetch(`${BASE_URL}/program`, {
              headers: { "X-API-Key": "pk_live_YOUR_KEY" },
            });
            const { program } = await res.json();
      responses:
        "200":
          description: Program configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  program:
                    $ref: "#/components/schemas/Program"
              example:
                program:
                  shopDomain: "my-store.myshopify.com"
                  currency: "IDR"
                  widgetTitle: "My Store Rewards"
                  widgetIntroduction: "Earn points on every purchase."
                  joinLoyaltyLabel: "Join now"
                  joinLoyaltyDescription: "Become a member and start earning."
                  colorAccent: "#e91e63"
                  faq:
                    - question: "How do I earn points?"
                      answer: "You earn points automatically on every paid order."
                  autoEnrollLoyalty: true
                  storeCreditEnabled: false
        "401": { $ref: "#/components/responses/UnauthorizedKey" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /program/tiers:
    get:
      operationId: listProgramTiers
      tags: [Program]
      summary: List tiers
      description: Active membership tiers, ordered from lowest to highest spend threshold.
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/program/tiers \
              -H "X-API-Key: pk_live_YOUR_KEY"
      responses:
        "200":
          description: Tier list
          content:
            application/json:
              schema:
                type: object
                properties:
                  tiers:
                    type: array
                    items:
                      $ref: "#/components/schemas/Tier"
              example:
                tiers:
                  - id: 1
                    name: "Bronze"
                    minSpend: 0
                    pointMultiplier: 1
                    benefits: "Earn 1x points"
                    sortOrder: 0
                  - id: 2
                    name: "Gold"
                    minSpend: 5000000
                    pointMultiplier: 2
                    benefits: "Earn 2x points, birthday gift"
                    sortOrder: 1
        "401": { $ref: "#/components/responses/UnauthorizedKey" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /program/rewards:
    get:
      operationId: listProgramRewards
      tags: [Program]
      summary: List reward catalog
      description: |
        Active rewards customers can redeem points for, cheapest first.

        `rewardType` values:

        | Type | Meaning |
        |---|---|
        | `discount_coupon` | A discount code (see `discountType`, `discountValue`, `discountSubType`) |
        | `store_credit` | Fixed store credit (`storeCreditAmount`) |
        | `points_conversion` | Flexible: customer chooses how many points to convert (`pointsPerDollar`, `minRedeemPoints`, `maxRedeemPoints`) |

        If `tierIds` is non-empty, the reward is restricted to customers in
        those tiers.
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/program/rewards \
              -H "X-API-Key: pk_live_YOUR_KEY"
      responses:
        "200":
          description: Reward catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  rewards:
                    type: array
                    items:
                      $ref: "#/components/schemas/Reward"
              example:
                rewards:
                  - id: 12
                    name: "Rp 50.000 off"
                    description: "Get Rp 50.000 off your next order."
                    voucherDescription: "Valid for 30 days. One use per customer."
                    rewardType: "discount_coupon"
                    pointsCost: 500
                    tierIds: []
                    discountType: "amount"
                    discountValue: 50000
                    discountSubType: "order"
                    minPurchaseAmount: 200000
                    startsAt: null
                    endsAt: null
        "401": { $ref: "#/components/responses/UnauthorizedKey" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /program/rules:
    get:
      operationId: listProgramRules
      tags: [Program]
      summary: List earning rules
      description: |
        Active earning rules — how customers earn points. Use it to render a
        "Ways to earn" section. Common `type` values: `pointsPerSpend`,
        `firstPurchaseBonus`, `birthdayBonus`, `anniversaryBonus`,
        `referralBonus`, `reviewPoints`, `completeProfileBonus`,
        `newsletterSignupBonus`.
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/program/rules \
              -H "X-API-Key: pk_live_YOUR_KEY"
      responses:
        "200":
          description: Earning rules
          content:
            application/json:
              schema:
                type: object
                properties:
                  rules:
                    type: array
                    items:
                      $ref: "#/components/schemas/Rule"
              example:
                rules:
                  - id: 3
                    name: "Points per purchase"
                    type: "pointsPerSpend"
                    rewardType: "points"
                    pointsPerSpend: 1
                    bonusPoints: null
                    pointMultiplier: null
                    minPurchase: 10000
                    startDate: null
                    endDate: null
                    pointLifetimeDays: 365
        "401": { $ref: "#/components/responses/UnauthorizedKey" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/customer-token:
    post:
      operationId: issueCustomerToken
      tags: [Customer Authentication]
      summary: Exchange a signed identity for a customer token
      description: |
        Exchanges a **merchant-signed customer identity** for a short-lived
        customer JWT (1 hour). The signature must be produced **on your
        server** with your secret key — see the *Customer authentication flow*
        guide at the top of this reference.

        ```
        message   = "{customer_id}:{email}:{timestamp}"   // empty string for omitted fields
        signature = hex( HMAC_SHA256( secret_key, message ) )
        ```

        Signatures are valid for **5 minutes** after `timestamp`.
      x-codeSamples:
        - lang: cURL
          source: |
            # signature generated server-side — see the signing guide
            curl -X POST https://api.staging.loyaltriberewards.com/api/public/v1/auth/customer-token \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                "customer_id": "7234567890123",
                "email": "jane@example.com",
                "timestamp": 1718200000,
                "signature": "3f1c4e0a9b..."
              }'
        - lang: JavaScript
          source: |
            // window.LOYALTY_IDENTITY was rendered by YOUR server (it holds
            // customer_id, email, timestamp, signature — never the secret key)
            const res = await fetch(`${BASE_URL}/auth/customer-token`, {
              method: "POST",
              headers: {
                "X-API-Key": "pk_live_YOUR_KEY",
                "Content-Type": "application/json",
              },
              body: JSON.stringify(window.LOYALTY_IDENTITY),
            });
            const { token, expiresAt } = await res.json();
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [timestamp, signature]
              properties:
                customer_id:
                  type: string
                  description: Platform customer id (e.g. Shopify customer ID). Provide this, email, or both.
                  example: "7234567890123"
                email:
                  type: string
                  format: email
                  description: Customer email. Provide this, customer_id, or both.
                  example: "jane@example.com"
                timestamp:
                  type: integer
                  description: Unix time in seconds when the identity was signed. Valid for 5 minutes.
                  example: 1718200000
                signature:
                  type: string
                  description: Hex-encoded HMAC-SHA256 of `"{customer_id}:{email}:{timestamp}"` using your secret key.
                  example: "3f1c4e0a9b2d8c7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e"
      responses:
        "200":
          description: Customer token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: JWT — send as `Authorization Bearer` on `/customer/*` endpoints.
                  tokenType:
                    type: string
                    example: "Bearer"
                  expiresAt:
                    type: string
                    format: date-time
                  expiresIn:
                    type: integer
                    description: Seconds until expiry.
                    example: 3600
                  customer:
                    type: object
                    properties:
                      id: { type: string, example: "7234567890123" }
                      email: { type: string, example: "jane@example.com" }
                      firstName: { type: string, example: "Jane" }
                      lastName: { type: string, example: "Doe" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Invalid or expired signature, or invalid API key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error:
                  code: "invalid_signature"
                  message: "Signature verification failed. Sign \"{customer_id}:{email}:{timestamp}\" with your secret key using HMAC-SHA256 (hex)."
        "403":
          description: Customer not enrolled in the loyalty program
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error:
                  code: "customer_not_enrolled"
                  message: "This customer exists but is not enrolled in the loyalty program."
        "404":
          description: No matching customer
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error:
                  code: "customer_not_found"
                  message: "No loyalty customer matches this identity for your shop."
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/me:
    get:
      operationId: getCustomerMe
      tags: [Customer]
      summary: Get the authenticated customer
      description: |
        Profile, live points balance, tier and referral code in one call —
        typically the first request after the token exchange.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/customer/me \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN"
      responses:
        "200":
          description: The authenticated customer
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer:
                    $ref: "#/components/schemas/Customer"
              example:
                customer:
                  id: "7234567890123"
                  email: "jane@example.com"
                  firstName: "Jane"
                  lastName: "Doe"
                  phone: "+628123456789"
                  birthday: "1995-04-12"
                  gender: "female"
                  enrolled: true
                  points: 1250
                  tier:
                    id: 2
                    name: "Gold"
                    pointMultiplier: 2
                    benefits: "Earn 2x points, birthday gift"
                  referralCode: "JANE-X7K2"
                  memberSince: "2024-11-02T08:15:00Z"
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/balance:
    get:
      operationId: getCustomerBalance
      tags: [Customer]
      summary: Get points balance
      description: Lightweight balance check — current available points and the shop currency.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/customer/balance \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN"
      responses:
        "200":
          description: Current balance
          content:
            application/json:
              schema:
                type: object
                properties:
                  totalPoints: { type: integer, example: 1250 }
                  currency: { type: string, example: "IDR" }
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/dashboard:
    get:
      operationId: getCustomerDashboard
      tags: [Customer]
      summary: Get dashboard summary
      description: |
        Aggregate view used by the official widget's home screen: total points,
        tier, the 10 most recent history entries, and points expiring in the
        next 30 days.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/customer/dashboard \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN"
      responses:
        "200":
          description: Dashboard summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  totalPoints: { type: integer, example: 1250 }
                  tier:
                    type: [object, "null"]
                    properties:
                      id: { type: integer }
                      name: { type: string }
                      pointMultiplier: { type: number }
                      benefits: { type: string }
                  histories:
                    type: array
                    description: 10 most recent point-history entries.
                    items:
                      $ref: "#/components/schemas/HistoryEntry"
                  expiringPoints:
                    type: integer
                    description: Points expiring within 30 days.
                    example: 200
                  expiryDate:
                    type: [string, "null"]
                    description: Earliest upcoming expiry date (YYYY-MM-DD), or null.
                    example: "2026-07-01"
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/history:
    get:
      operationId: listCustomerHistory
      tags: [Customer]
      summary: List point history
      description: Full point history for the customer, newest first, paginated.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      parameters:
        - name: page
          in: query
          description: 1-based page number.
          schema: { type: integer, default: 1, minimum: 1 }
        - name: limit
          in: query
          description: Items per page (max 100).
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://api.staging.loyaltriberewards.com/api/public/v1/customer/history?page=1&limit=20" \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN"
      responses:
        "200":
          description: Paginated point history
          content:
            application/json:
              schema:
                type: object
                properties:
                  history:
                    type: array
                    items:
                      $ref: "#/components/schemas/HistoryEntry"
                  pagination:
                    type: object
                    properties:
                      page: { type: integer, example: 1 }
                      limit: { type: integer, example: 20 }
                      total: { type: integer, example: 57 }
                      totalPages: { type: integer, example: 3 }
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/redemptions:
    get:
      operationId: listCustomerRedemptions
      tags: [Customer]
      summary: List active coupons & redemptions
      description: |
        The customer's unused coupons: point redemptions plus vouchers awarded
        by earning rules (birthday, referral, …). Each item carries enough
        context to render a coupon card — name, type, discount code, targeted
        products/collections.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/customer/redemptions \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN"
      responses:
        "200":
          description: Unused coupons and redemptions
          content:
            application/json:
              schema:
                type: object
                properties:
                  redemptions:
                    type: array
                    items:
                      $ref: "#/components/schemas/Redemption"
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/redeem:
    post:
      operationId: redeemReward
      tags: [Customer]
      summary: Redeem a reward
      description: |
        Spends the customer's points on a reward from the catalog
        ([`GET /program/rewards`](#tag/program/get/program/rewards)).

        - For `points_conversion` rewards, pass `pointsToRedeem` (between the
          reward's `minRedeemPoints` and `maxRedeemPoints`).
        - For product discounts with `applyMode: "customer_picks"`, pass
          `selectedProductId` — the product the customer chose.

        Redemption is atomic and double-spend-safe: concurrent requests for the
        same customer are serialized server-side.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://api.staging.loyaltriberewards.com/api/public/v1/customer/redeem \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"rewardId": 12}'
        - lang: JavaScript
          source: |
            const res = await fetch(`${BASE_URL}/customer/redeem`, {
              method: "POST",
              headers: {
                "X-API-Key": "pk_live_YOUR_KEY",
                "Authorization": `Bearer ${customerToken}`,
                "Content-Type": "application/json",
              },
              body: JSON.stringify({ rewardId: 12 }),
            });
            const result = await res.json();
            if (result.discountCode) showCoupon(result.discountCode);
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rewardId]
              properties:
                rewardId:
                  type: integer
                  description: ID of the reward to redeem.
                  example: 12
                pointsToRedeem:
                  type: integer
                  description: Required for `points_conversion` rewards — how many points to convert.
                  example: 1000
                selectedProductId:
                  type: string
                  description: Required when the reward's `applyMode` is `customer_picks` — the chosen product GID.
                  example: "gid://shopify/Product/8123456789"
      responses:
        "200":
          description: Reward redeemed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  message: { type: string, example: "Your discount code is ready!" }
                  pointsUsed: { type: integer, example: 500 }
                  rewardName: { type: string, example: "Rp 50.000 off" }
                  discountCode:
                    type: string
                    description: Present for discount-coupon rewards.
                    example: "LT-X7K2-9QPM"
                  isAutoApplied:
                    type: boolean
                    description: True when the discount applies automatically at checkout (no code entry needed).
                  storeCredit:
                    type: object
                    description: Present for store-credit rewards.
                    properties:
                      amount: { type: number, example: 50000 }
                      currencyCode: { type: string, example: "IDR" }
                      balanceAfter: { type: number, example: 150000 }
                      transactionId: { type: string }
        "400":
          description: 'Insufficient points, invalid reward, or missing fields (legacy `{"error": "…"}` shape)'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "insufficient points" }
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "404":
          description: Reward not found or not active
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "reward not found" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/gamification/wheel:
    get:
      operationId: getCustomerWheel
      tags: [Gamification]
      summary: Get the spin wheel
      description: |
        The merchant's active wheel configuration plus this customer's play
        eligibility. `wheel` is `null` when no active wheel exists. Segments
        the customer is not eligible for (tier/segment-restricted prizes) are
        filtered out server-side.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/customer/gamification/wheel \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN"
      responses:
        "200":
          description: Wheel config + eligibility
          content:
            application/json:
              schema:
                type: object
                properties:
                  wheel:
                    type: [object, "null"]
                    properties:
                      id: { type: integer }
                      name: { type: string }
                      description: { type: string }
                      triggerType: { type: string }
                      playLimitPerDay: { type: integer }
                      cooldownHours: { type: integer }
                      spinButtonLabel: { type: string }
                      backgroundImageUrl: { type: string, description: Optional merchant-designed wheel face. }
                      pointerImageUrl: { type: string }
                      segments:
                        type: array
                        items: { $ref: "#/components/schemas/WheelSegment" }
                  status: { $ref: "#/components/schemas/PlayStatus" }
              example:
                wheel:
                  id: 3
                  name: "Lucky Spin"
                  triggerType: "manual"
                  playLimitPerDay: 1
                  cooldownHours: 24
                  segments:
                    - id: 11
                      label: "100 Points"
                      color: "#f59e0b"
                      sortOrder: 0
                      rewardName: "100 bonus points"
                      rewardType: "bonus_points"
                    - id: 12
                      label: "Try again"
                      color: "#64748b"
                      sortOrder: 1
                      rewardType: "no_win"
                status:
                  canPlay: true
                  playsRemaining: 1
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "403":
          description: SPIN_WHEEL feature not enabled for this shop
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/gamification/wheel/spin:
    post:
      operationId: spinWheel
      tags: [Gamification]
      summary: Spin the wheel
      description: |
        Performs one spin. The server picks the winning segment and issues the
        prize atomically (points are credited / the coupon is created before
        the response returns). Animate your wheel to land on the returned
        `segment`, then reveal the prize from `play`.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://api.staging.loyaltriberewards.com/api/public/v1/customer/gamification/wheel/spin \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN"
      responses:
        "200":
          description: Spin result
          content:
            application/json:
              schema:
                type: object
                properties:
                  segment: { $ref: "#/components/schemas/WheelSegment" }
                  play: { $ref: "#/components/schemas/GamificationPlay" }
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "403":
          description: Feature disabled, or the customer can't play right now (cooldown / daily limit)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/gamification/scratch/cards:
    get:
      operationId: listScratchCards
      tags: [Gamification]
      summary: List scratch cards
      description: |
        Active scratch cards for this customer, each with its play
        eligibility and (when already played) the last revealed prize.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.staging.loyaltriberewards.com/api/public/v1/customer/gamification/scratch/cards \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN"
      responses:
        "200":
          description: Card list
          content:
            application/json:
              schema:
                type: object
                properties:
                  cards:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                        description: { type: string }
                        mode: { type: string, description: Prize selection mode (random / fixed). }
                        coverImageUrl: { type: string, description: The unscratched foil art. }
                        revealImageUrl: { type: string }
                        winMessage: { type: string }
                        winTone: { type: string }
                        status: { $ref: "#/components/schemas/PlayStatus" }
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "403":
          description: VOUCHER_SCRATCH feature not enabled for this shop
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customer/gamification/scratch/cards/{cardId}/play:
    post:
      operationId: playScratchCard
      tags: [Gamification]
      summary: Play a scratch card
      description: |
        Plays (scratches) the card. The server picks and issues the prize
        atomically; reveal it from the returned `play`.
      security:
        - ApiKeyAuth: []
          CustomerToken: []
      parameters:
        - name: cardId
          in: path
          required: true
          schema: { type: integer }
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://api.staging.loyaltriberewards.com/api/public/v1/customer/gamification/scratch/cards/5/play \
              -H "X-API-Key: pk_live_YOUR_KEY" \
              -H "Authorization: Bearer CUSTOMER_TOKEN"
      responses:
        "200":
          description: Play result
          content:
            application/json:
              schema:
                type: object
                properties:
                  play: { $ref: "#/components/schemas/GamificationPlay" }
        "401": { $ref: "#/components/responses/UnauthorizedToken" }
        "403":
          description: Feature disabled, or the customer can't play right now
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customers:
    post:
      operationId: createCustomer
      tags: [Server-to-Server]
      summary: Enroll a customer
      description: |
        Creates (or updates) a loyalty customer and enrolls them in the
        program. Idempotent per `customer_id` — re-posting updates the profile.
        Enrollment triggers the same machinery as every other channel: member
        limits, welcome email, referral code generation, registration-bonus
        rules and tier evaluation.
      security:
        - SecretKeyAuth: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://api.staging.loyaltriberewards.com/api/public/v1/customers \
              -H "Authorization: Bearer sk_live_YOUR_SECRET" \
              -H "Content-Type: application/json" \
              -d '{
                "customer_id": "cust-1042",
                "email": "jane@example.com",
                "first_name": "Jane",
                "last_name": "Doe"
              }'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_id]
              properties:
                customer_id:
                  type: string
                  description: Your platform's customer identifier. Stable — it keys all loyalty data.
                  example: "cust-1042"
                email: { type: string, format: email }
                first_name: { type: string }
                last_name: { type: string }
                phone: { type: string }
                enrolled:
                  type: boolean
                  default: true
                  description: Set false to register without enrolling.
                referral_code:
                  type: string
                  description: A referrer's code, if the customer signed up through a referral link.
      responses:
        "201":
          description: Customer enrolled
          content:
            application/json:
              schema:
                type: object
                properties:
                  customer:
                    type: object
                    properties:
                      id: { type: string, example: "cust-1042" }
                      email: { type: string }
                      firstName: { type: string }
                      lastName: { type: string }
                      enrolled: { type: boolean }
                      referralCode: { type: [string, "null"], example: "JANE-X7K2" }
        "401": { $ref: "#/components/responses/UnauthorizedSecret" }
        "422":
          description: Enrollment rejected (e.g. package member limit reached)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /orders:
    post:
      operationId: ingestOrder
      tags: [Server-to-Server]
      summary: Submit a paid order
      description: |
        Feeds a paid order into the loyalty engine — the same queue and rules
        engine Shopify webhooks use. Points appear in the customer's history
        within seconds.

        **Idempotent per `order_id`**: re-submissions of the same order are
        detected and skipped, so retrying on timeouts is safe.

        The customer must already be enrolled (`POST /customers`).
      security:
        - SecretKeyAuth: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://api.staging.loyaltriberewards.com/api/public/v1/orders \
              -H "Authorization: Bearer sk_live_YOUR_SECRET" \
              -H "Content-Type: application/json" \
              -d '{
                "order_id": "INV-1042",
                "order_number": "#1042",
                "customer_id": "cust-1042",
                "total_amount": 150000,
                "currency": "IDR",
                "discount_codes": ["750KBB56"]
              }'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_id, customer_id, total_amount]
              properties:
                order_id:
                  type: string
                  description: Your order identifier (numeric, UUID or code — all fine). Dedup key.
                  example: "INV-1042"
                order_number:
                  type: string
                  description: Human-facing order label, shown in point history and notifications.
                  example: "#1042"
                customer_id: { type: string, example: "cust-1042" }
                total_amount:
                  type: number
                  description: Order grand total in major currency units. Must be > 0.
                  example: 150000
                currency: { type: string, example: "IDR" }
                financial_status:
                  type: string
                  default: "paid"
                  description: Only "paid" orders are accepted — submit after payment capture.
                processed_at:
                  type: string
                  format: date-time
                  description: When the order was paid. Defaults to now.
                line_items:
                  type: array
                  description: Cart contents — enables product-specific earning rules.
                  items:
                    type: object
                    properties:
                      product_id: { type: string }
                      variant_id: { type: string }
                      title: { type: string }
                      quantity: { type: integer }
                      unit_price: { type: number }
                      image_url: { type: string }
                discount_codes:
                  type: array
                  items: { type: string }
                  description: Coupon codes used on this order — marks matching loyalty coupons as consumed.
      responses:
        "202":
          description: Order queued for points processing
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "queued" }
                  orderId: { type: string, example: "INV-1042" }
                  message: { type: string }
        "401": { $ref: "#/components/responses/UnauthorizedSecret" }
        "404":
          description: Customer not enrolled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error:
                  code: "customer_not_enrolled"
                  message: "No enrolled loyalty customer with this customer_id — enroll them first via POST /customers."
        "422":
          description: Order not paid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: Queue temporarily unavailable — retry shortly
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /coupons/validate:
    post:
      operationId: validateCoupon
      tags: [Server-to-Server]
      summary: Validate a coupon code
      description: |
        Checks an internally minted coupon code at your checkout: whose it is,
        what discount it grants, and whether it is still unused. Read-only —
        call `POST /coupons/consume` after the order is placed.
      security:
        - SecretKeyAuth: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://api.staging.loyaltriberewards.com/api/public/v1/coupons/validate \
              -H "Authorization: Bearer sk_live_YOUR_SECRET" \
              -H "Content-Type: application/json" \
              -d '{"code": "750KBB56"}'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, example: "750KBB56" }
      responses:
        "200":
          description: Coupon details
          content:
            application/json:
              schema:
                type: object
                properties:
                  coupon:
                    $ref: "#/components/schemas/Coupon"
              example:
                coupon:
                  code: "750KBB56"
                  valid: true
                  used: false
                  rewardName: "Rp 20.000 off"
                  rewardType: "discount_coupon"
                  discountType: "amount"
                  discountValue: 20000
                  discountSubType: "order"
                  customerId: "cust-1042"
                  pointsUsed: 100
                  redeemedAt: "2026-06-12T15:58:00Z"
        "401": { $ref: "#/components/responses/UnauthorizedSecret" }
        "404":
          description: Unknown code
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /coupons/consume:
    post:
      operationId: consumeCoupon
      tags: [Server-to-Server]
      summary: Consume a coupon code
      description: |
        Atomically marks a coupon as used after the order is placed. A code
        can never be consumed twice — the second attempt returns `409
        coupon_already_used`, even across concurrent requests.
      security:
        - SecretKeyAuth: []
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST https://api.staging.loyaltriberewards.com/api/public/v1/coupons/consume \
              -H "Authorization: Bearer sk_live_YOUR_SECRET" \
              -H "Content-Type: application/json" \
              -d '{"code": "750KBB56", "order_id": "INV-1042"}'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, example: "750KBB56" }
                order_id:
                  type: string
                  description: Optional — the order this coupon was applied to.
      responses:
        "200":
          description: Consumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "consumed" }
                  code: { type: string }
                  consumedAt: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/UnauthorizedSecret" }
        "404":
          description: Unknown code
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Already consumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { $ref: "#/components/schemas/Error/properties/error" }
                  coupon: { $ref: "#/components/schemas/Coupon" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Your **publishable key** (`pk_live_…`). Identifies your shop. Required
        on every request, safe to use in browsers.
    CustomerToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Short-lived **customer token** from `POST /auth/customer-token`.
        Required on all `/customer/*` endpoints.
    SecretKeyAuth:
      type: http
      scheme: bearer
      description: |
        Your **secret key** (`sk_live_…`) sent as a Bearer token. Server-to-
        server endpoints only — never call these from a browser.

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Stable machine-readable error code.
              example: "invalid_api_key"
            message:
              type: string
              example: "This API key is unknown or has been revoked."

    WheelSegment:
      type: object
      properties:
        id: { type: integer }
        label: { type: string, example: "100 Points" }
        color: { type: string, example: "#f59e0b" }
        sortOrder: { type: integer }
        winMessage: { type: string }
        winTone: { type: string }
        rewardName: { type: string, description: Present when the slot carries a prize. }
        rewardType:
          type: string
          description: bonus_points, discount_coupon, store_credit, points_conversion — or no_win for empty slots.
          example: "bonus_points"

    PlayStatus:
      type: object
      properties:
        canPlay: { type: boolean }
        playsRemaining: { type: integer, description: -1 = unlimited. }
        nextPlayAt: { type: [string, "null"], format: date-time }
        reason:
          type: string
          description: Why the customer can't play — cooldown, daily_limit, wheel_inactive, needs_purchase, already_played. Empty when canPlay.

    GamificationPlay:
      type: object
      description: |
        The recorded play with a denormalized snapshot of the prize. Inspect
        `rewardType` to render the outcome — bonus points are already
        credited, coupons appear in the customer's redemptions, store credit
        is already in their wallet.
      properties:
        id: { type: integer }
        mechanic: { type: string, example: "wheel" }
        playedAt: { type: string, format: date-time }
        rewardType: { type: string, example: "bonus_points" }
        rewardLabel: { type: string, example: "100 bonus points" }
        rewardPoints: { type: [integer, "null"], description: For bonus_points prizes. }
        rewardAmount: { type: [number, "null"], description: For store_credit prizes. }

    Coupon:
      type: object
      properties:
        code: { type: string, example: "750KBB56" }
        valid: { type: boolean, description: Present on validate — true when unused. }
        used: { type: boolean }
        usedAt: { type: string, format: date-time }
        rewardName: { type: string }
        rewardType: { type: string, example: "discount_coupon" }
        discountType: { type: [string, "null"], example: "amount" }
        discountValue: { type: [number, "null"], example: 20000 }
        discountSubType: { type: string, example: "order" }
        minPurchaseAmount: { type: [number, "null"] }
        targetProductIds:
          type: [array, "null"]
          items: { type: string }
        endsAt: { type: [string, "null"], format: date-time }
        customerId: { type: string, example: "cust-1042" }
        pointsUsed: { type: integer, example: 100 }
        redeemedAt: { type: string, format: date-time }

    Program:
      type: object
      properties:
        shopDomain: { type: string, example: "my-store.myshopify.com" }
        currency: { type: string, example: "IDR" }
        widgetTitle: { type: [string, "null"], example: "My Store Rewards" }
        widgetIntroduction: { type: [string, "null"] }
        joinLoyaltyLabel: { type: [string, "null"] }
        joinLoyaltyDescription: { type: [string, "null"] }
        colorAccent:
          type: [string, "null"]
          description: Hex accent color configured by the merchant.
          example: "#e91e63"
        faq:
          type: [array, "null"]
          items:
            type: object
            properties:
              question: { type: string }
              answer: { type: string }
        autoEnrollLoyalty:
          type: boolean
          description: Whether customers are auto-enrolled on login/registration.
        storeCreditEnabled: { type: boolean }

    Tier:
      type: object
      properties:
        id: { type: integer, example: 2 }
        name: { type: string, example: "Gold" }
        minSpend:
          type: number
          description: Total spend (shop currency) required to reach this tier.
          example: 5000000
        pointMultiplier: { type: number, example: 2 }
        benefits: { type: string, example: "Earn 2x points, birthday gift" }
        sortOrder: { type: integer, example: 1 }

    Reward:
      type: object
      properties:
        id: { type: integer, example: 12 }
        name: { type: string, example: "Rp 50.000 off" }
        description: { type: string }
        voucherDescription:
          type: string
          description: Usage terms shown on the coupon after redemption.
        rewardType:
          type: string
          enum: [discount_coupon, store_credit, points_conversion]
        pointsCost: { type: integer, example: 500 }
        tierIds:
          type: [array, "null"]
          description: If non-empty, only customers in these tiers may redeem.
          items: { type: string }
        discountType:
          type: [string, "null"]
          enum: [amount, percentage, null]
        discountValue: { type: [number, "null"], example: 50000 }
        discountSubType:
          type: string
          description: order, product, collection, bogo or shipping.
          example: "order"
        applyMode:
          type: [string, "null"]
          description: For product discounts — `each`, `one`, or `customer_picks` (customer chooses a product at redemption).
        minPurchaseAmount: { type: [number, "null"] }
        targetProductIds:
          type: [array, "null"]
          items: { type: string }
        targetCollectionIds:
          type: [array, "null"]
          items: { type: string }
        buyQuantity: { type: [integer, "null"], description: BOGO buy quantity. }
        getQuantity: { type: [integer, "null"], description: BOGO get quantity. }
        shippingPriceMaximum: { type: [number, "null"] }
        storeCreditAmount: { type: [number, "null"] }
        pointsPerDollar:
          type: [integer, "null"]
          description: For points_conversion — points per 1 unit of currency.
        minRedeemPoints: { type: [integer, "null"] }
        maxRedeemPoints: { type: [integer, "null"], description: 0 = unlimited. }
        startsAt: { type: [string, "null"], format: date-time }
        endsAt: { type: [string, "null"], format: date-time }

    Rule:
      type: object
      properties:
        id: { type: integer, example: 3 }
        name: { type: string, example: "Points per purchase" }
        type:
          type: string
          description: Rule type, e.g. pointsPerSpend, firstPurchaseBonus, birthdayBonus, referralBonus, reviewPoints.
          example: "pointsPerSpend"
        rewardType:
          type: [string, "null"]
          description: What the rule awards — points, voucher or store_credit.
          example: "points"
        pointsPerSpend: { type: [integer, "null"], example: 1 }
        bonusPoints: { type: [integer, "null"] }
        pointMultiplier: { type: [number, "null"] }
        minPurchase: { type: [integer, "null"] }
        startDate: { type: [string, "null"], example: null }
        endDate: { type: [string, "null"] }
        pointLifetimeDays:
          type: [integer, "null"]
          description: Days until earned points expire.
          example: 365

    Customer:
      type: object
      properties:
        id:
          type: string
          description: Platform customer id (e.g. Shopify customer ID).
          example: "7234567890123"
        email: { type: string, example: "jane@example.com" }
        firstName: { type: string, example: "Jane" }
        lastName: { type: string, example: "Doe" }
        phone: { type: string, example: "+628123456789" }
        birthday: { type: [string, "null"], example: "1995-04-12" }
        gender: { type: [string, "null"], example: "female" }
        enrolled: { type: boolean, example: true }
        points:
          type: integer
          description: Currently available (unexpired, unredeemed) points.
          example: 1250
        tier:
          type: [object, "null"]
          properties:
            id: { type: integer }
            name: { type: string }
            pointMultiplier: { type: number }
            benefits: { type: string }
        referralCode: { type: [string, "null"], example: "JANE-X7K2" }
        memberSince: { type: string, format: date-time }

    HistoryEntry:
      type: object
      properties:
        id: { type: integer, example: 481 }
        orderId: { type: [string, "null"], example: "5839201837465" }
        orderName: { type: [string, "null"], example: "#1042" }
        pointsEarned: { type: integer, example: 150 }
        pointsRedeemed: { type: integer, example: 0 }
        pointsAvailable:
          type: integer
          description: Points still available from this batch.
          example: 150
        ruleName: { type: [string, "null"], example: "Points per purchase" }
        orderTotal: { type: [number, "null"], example: 150000 }
        reason: { type: [string, "null"], example: null }
        earnedAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        isExpired: { type: boolean, example: false }

    Redemption:
      type: object
      properties:
        id: { type: integer, example: 87 }
        rewardName: { type: string, example: "Rp 50.000 off" }
        rewardType: { type: string, example: "discount_coupon" }
        voucherDescription: { type: string, example: "Valid for 30 days." }
        pointsUsed: { type: integer, example: 500 }
        redeemedAt: { type: string, format: date-time }
        discountCode: { type: [string, "null"], example: "LT-X7K2-9QPM" }
        source:
          type: string
          description: redemption (spent points) or rule_voucher (awarded by an earning rule).
          example: "redemption"
        targetProducts:
          type: [array, "null"]
          description: Products the discount targets (when product-scoped).
          items:
            type: object
            properties:
              id: { type: string }
              title: { type: string }
              handle: { type: string }
              imageUrl: { type: string }
        targetCollections:
          type: [array, "null"]
          items:
            type: object
            properties:
              id: { type: string }
              title: { type: string }
        selectedProduct:
          type: [object, "null"]
          description: The product the customer picked (customer_picks rewards).
          properties:
            id: { type: string }
            title: { type: string }
            handle: { type: string }
            imageUrl: { type: string }

  responses:
    BadRequest:
      description: Malformed request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: "invalid_request"
              message: "Provide customer_id, email, or both — at least one is required."
    UnauthorizedKey:
      description: Missing, unknown or revoked API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: "invalid_api_key"
              message: "This API key is unknown or has been revoked."
    UnauthorizedSecret:
      description: Missing, invalid or revoked secret key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: "invalid_secret_key"
              message: "This endpoint requires the secret key (sk_…), not the publishable key."
    UnauthorizedToken:
      description: Missing or expired customer token
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: "invalid_token"
              message: "The customer token is invalid or expired. Exchange a fresh signed identity via POST /auth/customer-token."
    RateLimited:
      description: Per-key rate limit exceeded
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: "rate_limited"
              message: "Too many requests for this API key. Slow down and retry shortly."
