openapi: 3.1.0
info:
  title: StickyTier API
  version: "1.0"
  description: |
    Run loyalty, tiers and referrals on any commerce stack — WooCommerce,
    Magento, custom carts, POS. Send commerce events; read members, ledgers
    and liability; enroll, adjust and redeem.

    **Conventions**: money in minor units (paise), times ISO 8601 UTC,
    responses wrapped in `{ data, pagination? }`, errors are RFC 9457
    `application/problem+json`.
  contact:
    url: https://stickytier.com/docs/api
servers:
  - url: https://stickytier.com
    description: Production (StickyTier)
  - url: https://cvos.intentstack.tech
    description: Legacy domain — still live, redirected off for non-API traffic; retiring (see rebrand)
security:
  - bearerAuth: []
tags:
  - name: Events
    description: Commerce event ingestion — the write path for orders and customers.
  - name: Members
    description: Enrollment, profiles, balances, ledgers, referrals.
  - name: Redemptions
    description: Vouchers and direct redemptions.
  - name: Program
    description: Program configuration and finance.
  - name: Webhooks
    description: Outbound webhook endpoint management.

paths:
  /v1/ping:
    get:
      operationId: ping
      summary: Verify a key
      tags: [Program]
      responses:
        "200":
          description: Key is valid.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      pong: { type: boolean }
                      mode: { type: string, enum: [live, test] }
                      scopes: { type: array, items: { type: string } }
        "401": { $ref: "#/components/responses/Problem" }

  /v1/events:
    post:
      operationId: ingestEvents
      summary: Send events (single or batch)
      description: |
        Requires the `ingest` scope. Single event: optional `Idempotency-Key`
        header (derived from the event refs when absent). Batch: up to 100
        events; each event's `id` is its idempotency key. Replayed events
        return the original result with `replayed: true`. Processing is
        asynchronous — poll `GET /v1/events/{id}` or subscribe to webhooks.
      tags: [Events]
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/Event"
                - type: object
                  required: [events]
                  properties:
                    events:
                      type: array
                      minItems: 1
                      maxItems: 100
                      items: { $ref: "#/components/schemas/Event" }
      responses:
        "202":
          description: Accepted (batch responses report per-event outcomes).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/EventAccepted"
                      - type: array
                        items: { $ref: "#/components/schemas/BatchEventResult" }
        "200":
          description: Replayed — this idempotency key was already processed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/EventAccepted" }
        "401": { $ref: "#/components/responses/Problem" }
        "403": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/events/{id}:
    get:
      operationId: getEvent
      summary: Event processing status
      tags: [Events]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: Status and resulting ledger entries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/EventStatus" }
        "404": { $ref: "#/components/responses/Problem" }

  /v1/members:
    get:
      operationId: listMembers
      summary: List members
      tags: [Members]
      parameters:
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { in: query, name: cursor, schema: { type: string } }
        - { in: query, name: email, schema: { type: string } }
        - { in: query, name: state, schema: { type: string, enum: [prospect, registered, active, suspended, deleted] } }
      responses:
        "200":
          description: Page of members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Member" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
    post:
      operationId: enrollMember
      summary: Enroll or upsert a member
      description: Requires the `act` scope. Email anchors identity; a mobile owned by a different member is a 409.
      tags: [Members]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                phone: { type: string }
                external_ref: { type: string }
      responses:
        "201":
          description: Enrolled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Member" }
        "200":
          description: Already enrolled (possibly enriched).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Member" }
        "409": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }

  /v1/members/{id}:
    get:
      operationId: getMember
      summary: Member profile, tier and balances
      tags: [Members]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string }, description: "Public member id (M-…)" }
      responses:
        "200":
          description: The member.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Member" }
        "404": { $ref: "#/components/responses/Problem" }

  /v1/members/{id}/ledger:
    get:
      operationId: getMemberLedger
      summary: Member ledger (all statuses)
      tags: [Members]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { in: query, name: cursor, schema: { type: string } }
      responses:
        "200":
          description: Ledger entries, oldest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/LedgerEntry" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
        "404": { $ref: "#/components/responses/Problem" }

  /v1/members/{id}/referral:
    get:
      operationId: getMemberReferral
      summary: Referral code, link and stats
      tags: [Members]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: Referral details.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      code: { type: string }
                      link: { type: [string, "null"] }
                      stats:
                        type: object
                        properties:
                          pending: { type: integer }
                          qualified: { type: integer }
                          rewarded: { type: integer }
        "404": { $ref: "#/components/responses/Problem" }

  /v1/members/{id}/adjust:
    post:
      operationId: adjustMember
      summary: Manual credit or debit
      description: Requires the `act` scope. `reason_code` is mandatory; the API key is the audited actor.
      tags: [Members]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
        - { in: header, name: Idempotency-Key, required: false, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_cents, reason_code]
              properties:
                amount_cents: { type: integer, description: "Positive credit, negative debit." }
                reason_code: { type: string }
                note: { type: string }
                idempotency_key: { type: string }
      responses:
        "201":
          description: Adjustment written.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ledger_entry_id: { type: string }
                      amount_cents: { type: integer }
                      reason_code: { type: string }
                      status: { type: string }
        "404": { $ref: "#/components/responses/Problem" }
        "409": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }

  /v1/members/{id}/redemptions:
    post:
      operationId: createRedemption
      summary: Redeem points (voucher or direct)
      description: |
        Requires the `act` scope. `voucher` (default) debits the wallet and
        returns a unique code to honor at your POS/cart — then confirm or
        void it; unconfirmed vouchers auto-void after the program TTL.
        `direct` debits and finalizes in one call (requires `order_ref`).
      tags: [Redemptions]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_cents]
              properties:
                amount_cents: { type: integer, minimum: 1 }
                mode: { type: string, enum: [voucher, direct], default: voucher }
                order_ref: { type: string }
      responses:
        "201":
          description: Redemption created (value already debited).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Redemption" }
        "402": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }

  /v1/redemptions/{id}/confirm:
    post:
      operationId: confirmRedemption
      summary: Confirm a voucher (idempotent)
      tags: [Redemptions]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_ref]
              properties:
                order_ref: { type: string }
      responses:
        "200":
          description: Confirmed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Redemption" }
        "404": { $ref: "#/components/responses/Problem" }
        "409": { $ref: "#/components/responses/Problem" }

  /v1/redemptions/{id}/void:
    post:
      operationId: voidRedemption
      summary: Void a voucher and restore value (idempotent)
      tags: [Redemptions]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: Voided; value restored to the wallet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Redemption" }
        "404": { $ref: "#/components/responses/Problem" }
        "409": { $ref: "#/components/responses/Problem" }

  /v1/programs:
    get:
      operationId: getProgram
      summary: Active earning configuration
      tags: [Program]
      responses:
        "200":
          description: Rules and tiers currently in force.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Program" }

  /v1/tenant/liability:
    get:
      operationId: getLiability
      summary: Finance summary
      tags: [Program]
      responses:
        "200":
          description: Outstanding and lifetime value.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Liability" }

  /v1/webhook_endpoints:
    get:
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      tags: [Webhooks]
      responses:
        "200":
          description: Endpoints (secrets are never shown again).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
    post:
      operationId: createWebhookEndpoint
      summary: Create a webhook endpoint
      description: Requires the `webhooks` scope. The signing secret is returned ONCE.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  minItems: 1
                  items: { $ref: "#/components/schemas/WebhookEventType" }
      responses:
        "201":
          description: Created — save the secret now.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: "#/components/schemas/WebhookEndpoint"
                      - type: object
                        properties:
                          secret: { type: string }
        "422": { $ref: "#/components/responses/Problem" }

  /v1/webhook_endpoints/{id}:
    delete:
      operationId: deleteWebhookEndpoint
      summary: Delete a webhook endpoint
      tags: [Webhooks]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "204": { description: Deleted. }
        "404": { $ref: "#/components/responses/Problem" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key: sticky_live_… or sticky_test_… (test keys write to the sandbox)."

  responses:
    Problem:
      description: RFC 9457 problem.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: Rate limit exceeded (Retry-After header set).
      headers:
        Retry-After:
          schema: { type: string }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        errors: { type: array, items: { type: object } }

    Pagination:
      type: object
      properties:
        cursor:
          type: [string, "null"]
          description: Pass back as ?cursor= for the next page; null = no more.

    Event:
      oneOf:
        - $ref: "#/components/schemas/CustomerEvent"
        - $ref: "#/components/schemas/OrderEvent"
        - $ref: "#/components/schemas/OrderCancelledEvent"
        - $ref: "#/components/schemas/OrderRefundedEvent"
      discriminator:
        propertyName: type

    CustomerEvent:
      type: object
      required: [type, email]
      properties:
        type: { type: string, enum: [customer.created, customer.updated] }
        id: { type: string, description: "Idempotency key for this event (batch)." }
        email: { type: string, format: email }
        phone: { type: string }
        external_ref: { type: string }
        occurred_at: { type: string, format: date-time }

    OrderLine:
      type: object
      required: [amount_cents]
      properties:
        product_ref: { type: string }
        amount_cents: { type: integer, minimum: 0 }
        quantity: { type: integer, minimum: 1, default: 1 }
        gift_card: { type: boolean, default: false }
        tags: { type: array, items: { type: string }, description: "Category tags for tag-based rules." }

    OrderEvent:
      type: object
      required: [type, order_ref, gross_cents]
      properties:
        type: { type: string, enum: [order.placed, order.paid, order.fulfilled] }
        id: { type: string }
        order_ref: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        gross_cents: { type: integer, minimum: 0 }
        lines: { type: array, items: { $ref: "#/components/schemas/OrderLine" } }
        occurred_at: { type: string, format: date-time }

    OrderCancelledEvent:
      type: object
      required: [type, order_ref]
      properties:
        type: { type: string, enum: [order.cancelled] }
        id: { type: string }
        order_ref: { type: string }
        occurred_at: { type: string, format: date-time }

    OrderRefundedEvent:
      type: object
      required: [type, order_ref, refund_ref, refunded_cents]
      properties:
        type: { type: string, enum: [order.refunded] }
        id: { type: string }
        order_ref: { type: string }
        refund_ref: { type: string }
        refunded_cents: { type: integer, minimum: 1 }
        occurred_at: { type: string, format: date-time }

    EventAccepted:
      type: object
      properties:
        id: { type: string }
        type: { type: string }
        status: { type: string, enum: [queued, running, completed, failed] }
        replayed: { type: boolean }

    BatchEventResult:
      type: object
      properties:
        index: { type: integer }
        accepted: { type: boolean }
        id: { type: string }
        status: { type: string }
        replayed: { type: boolean }
        errors: { type: array, items: { type: object } }

    EventStatus:
      type: object
      properties:
        id: { type: string }
        type: { type: string }
        status: { type: string, enum: [queued, running, completed, failed] }
        error: { type: [string, "null"] }
        ledger_entry_ids: { type: array, items: { type: string } }
        received_at: { type: string, format: date-time }
        processed_at: { type: [string, "null"], format: date-time }

    Member:
      type: object
      properties:
        id: { type: string, description: "Public member id (M-…), stable across contact changes only if identity unchanged." }
        email: { type: string }
        phone: { type: [string, "null"] }
        state: { type: string }
        referral_code: { type: string }
        tier:
          type: [object, "null"]
          properties:
            name: { type: string }
            level: { type: integer }
            earn_multiplier: { type: number }
            benefits:
              type: object
              description: Tenant-defined benefit flags for this tier (arbitrary keys), for gating perks in your storefront.
              additionalProperties: true
        balances:
          type: object
          properties:
            available_cents: { type: integer }
            pending_cents: { type: integer }
            lifetime_earned_cents: { type: integer }
            lifetime_redeemed_cents: { type: integer }
        enrolled_at: { type: string, format: date-time }

    LedgerEntry:
      type: object
      properties:
        id: { type: string }
        type: { type: string, enum: [earn, redeem, expire, reverse, adjust] }
        status: { type: string }
        amount_cents: { type: integer }
        currency: { type: string }
        reason_code: { type: [string, "null"] }
        rule: { type: [string, "null"] }
        parent_entry_id: { type: [string, "null"] }
        release_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    Redemption:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [created, used, restored, expired, failed, pending_creation] }
        mode: { type: string }
        amount_cents: { type: integer }
        code: { type: string }
        order_ref: { type: [string, "null"] }
        ledger_entry_id: { type: string }
        expires_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    Program:
      type: object
      properties:
        program_name: { type: string }
        currency_name: { type: string }
        rules:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              type: { type: string }
              version: { type: integer }
              earn_rate_pct: { type: number }
              timing: { type: string, enum: [immediate, paid, fulfilled, delivered, return_window, manual] }
              tags: { type: [array, "null"], items: { type: string } }
              min_order_cents: { type: [integer, "null"] }
              cap_per_order_cents: { type: [integer, "null"] }
              expiry_months: { type: [integer, "null"] }
        tiers:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              level: { type: integer }
              threshold_type: { type: string }
              threshold_value: { type: integer }
              criteria:
                type: array
                description: Hybrid qualification criteria; empty means the single threshold applies.
                items:
                  type: object
                  properties:
                    metric: { type: string, enum: [spend, points, orders] }
                    threshold: { type: integer }
              criteria_mode: { type: string, enum: [all, any] }
              window: { type: string, enum: [lifetime, calendar_year, rolling_365, custom] }
              window_days: { type: [integer, "null"] }
              earn_multiplier: { type: number }
              benefits: { type: object, additionalProperties: true }

    Liability:
      type: object
      properties:
        outstanding_cents: { type: integer }
        available_cents: { type: integer }
        pending_cents: { type: integer }
        lifetime_issued_cents: { type: integer }
        lifetime_redeemed_cents: { type: integer }
        members: { type: integer }
        as_of: { type: string, format: date-time }

    WebhookEventType:
      type: string
      enum:
        - member.enrolled
        - ledger.entry.created
        - balance.updated
        - tier.changed
        - referral.qualified
        - redemption.created
        - redemption.confirmed
        - redemption.voided
        - reward.expiring_soon

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string }
        url: { type: string }
        events: { type: array, items: { $ref: "#/components/schemas/WebhookEventType" } }
        status: { type: string, enum: [active, disabled] }
        failure_count: { type: integer }
        disabled_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
