openapi: 3.0.3
info:
  title: Qanary REST API
  version: "1"
  description: >
    Server-to-server API for affiliates, campaigns, referrals, commissions and
    payouts. Authenticate with a per-account API key (Settings → API keys):
    send the qnry_sk_… secret as `Authorization: Bearer <secret>` or as the
    HTTP Basic username. All money values are integer minor units + ISO-4217
    currency. Lists use cursor pagination (`starting_after` = last seen id,
    `limit` ≤ 100) and return `{data: [...], has_more: bool}`. Errors are
    `{error: {code, message}}`. Rate limit: 300 requests/minute per key.
    This document is a test artifact — conformance tests assert responses
    against these schemas (additionalProperties: false everywhere).
servers:
  - url: /api/v1
security:
  - bearerAuth: []
  - basicAuth: []
paths:
  /affiliates:
    get:
      summary: List affiliates
      parameters:
        - { name: campaign_id, in: query, schema: { type: integer } }
        - { name: status, in: query, schema: { type: string, enum: [pending, approved, rejected] } }
        - { name: email, in: query, schema: { type: string } }
        - { name: tag, in: query, schema: { type: string } }
        - { name: starting_after, in: query, schema: { type: integer } }
        - { name: limit, in: query, schema: { type: integer, maximum: 100 } }
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AffiliateList"
    post:
      summary: Create an affiliate
      description: >
        Creates a promoter and enrolls them in the given campaign (201). If the
        email already exists in the space the existing promoter is enrolled in
        the campaign and returned instead (200) — the roster is space-level, so
        a repeat email is a re-enrollment, not a conflict.
      responses:
        "200":
          description: Existing affiliate, enrolled in the campaign
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Affiliate"
        "201":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Affiliate"
  /affiliates/{id}:
    get:
      summary: Fetch an affiliate
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Affiliate"
    patch:
      summary: Update an affiliate (status approved/rejected routes through approve!/reject!)
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Affiliate"
  /affiliates/{id}/magic_link:
    post:
      summary: Mint a single-use portal SSO link (15 minutes)
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MagicLink"
  /campaigns:
    get:
      summary: List campaigns
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CampaignList"
  /campaigns/{id}:
    get:
      summary: Fetch a campaign
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Campaign"
  /referrals:
    get:
      summary: List referrals (attributed conversions)
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReferralList"
  /referrals/{id}:
    get:
      summary: Fetch a referral
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Referral"
  /commissions:
    get:
      summary: List commissions (ledger entries)
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CommissionList"
  /commissions/{id}:
    get:
      summary: Fetch a commission
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Commission"
    patch:
      summary: Approve or void a PENDING commission
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Commission"
  /payouts:
    get:
      summary: List payout batches
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PayoutList"
  /payouts/{id}:
    get:
      summary: Fetch a payout batch with items
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Payout"
  /webhook_endpoints:
    get:
      summary: List webhook endpoints
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointList"
    post:
      summary: Subscribe a REST hook (returns the signing secret ONCE)
      responses:
        "201":
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointCreated"
  /webhook_endpoints/{id}:
    delete:
      summary: Unsubscribe a REST hook
      responses:
        "204":
          description: removed
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer }
    basicAuth: { type: http, scheme: basic }
  schemas:
    WebhookEndpoint:
      type: object
      additionalProperties: false
      required: [id, url, events, status, created_at]
      properties:
        id: { type: integer }
        url: { type: string }
        events: { type: array, items: { type: string } }
        status: { type: string, enum: [active, disabled] }
        created_at: { type: string }
    WebhookEndpointCreated:
      type: object
      additionalProperties: false
      required: [id, url, events, status, created_at, secret]
      properties:
        id: { type: integer }
        url: { type: string }
        events: { type: array, items: { type: string } }
        status: { type: string, enum: [active, disabled] }
        created_at: { type: string }
        secret: { type: string }
    WebhookEndpointList:
      type: object
      additionalProperties: false
      required: [data, has_more]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/WebhookEndpoint" } }
        has_more: { type: boolean }
    Error:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
    MagicLink:
      type: object
      additionalProperties: false
      required: [url, expires_in]
      properties:
        url: { type: string }
        expires_in: { type: integer }
    Affiliate:
      type: object
      additionalProperties: false
      required: [id, campaign_ids, email, status, kind, token, tags, created_at]
      properties:
        id: { type: integer }
        campaign_ids: { type: array, items: { type: integer } }
        email: { type: string }
        name: { type: string, nullable: true }
        status: { type: string, enum: [pending, approved, rejected] }
        kind: { type: string, enum: [affiliate, customer] }
        token: { type: string }
        coupon_code: { type: string, nullable: true }
        paypal_email: { type: string, nullable: true }
        tags: { type: array, items: { type: string } }
        created_at: { type: string }
    AffiliateList:
      type: object
      additionalProperties: false
      required: [data, has_more]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Affiliate" } }
        has_more: { type: boolean }
    Campaign:
      type: object
      additionalProperties: false
      required: [id, name, slug, program_type, visibility, payout_currency, attribution_model,
                 attribution_window_days, hold_days, minimum_payout_minor, open_enrollment, created_at]
      properties:
        id: { type: integer }
        name: { type: string }
        slug: { type: string }
        program_type: { type: string, enum: [affiliate, referral] }
        visibility: { type: string, enum: [public, private] }
        payout_currency: { type: string }
        attribution_model: { type: string }
        attribution_window_days: { type: integer }
        hold_days: { type: integer }
        minimum_payout_minor: { type: integer }
        open_enrollment: { type: boolean }
        destination_url: { type: string, nullable: true }
        created_at: { type: string }
    CampaignList:
      type: object
      additionalProperties: false
      required: [data, has_more]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Campaign" } }
        has_more: { type: boolean }
    Referral:
      type: object
      additionalProperties: false
      required: [id, campaign_id, affiliate_id, customer_ref, status, is_test, occurred_at, created_at]
      properties:
        id: { type: integer }
        campaign_id: { type: integer }
        affiliate_id: { type: integer }
        customer_ref: { type: string }
        subscription_ref: { type: string, nullable: true }
        status: { type: string, enum: [pending_review, approved, rejected] }
        is_test: { type: boolean }
        occurred_at: { type: string }
        created_at: { type: string }
    ReferralList:
      type: object
      additionalProperties: false
      required: [data, has_more]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Referral" } }
        has_more: { type: boolean }
    Commission:
      type: object
      additionalProperties: false
      required: [id, campaign_id, affiliate_id, entry_type, beneficiary, amount_minor, currency,
                 payout_amount_minor, payout_currency, status, created_at]
      properties:
        id: { type: integer }
        campaign_id: { type: integer }
        affiliate_id: { type: integer }
        referral_id: { type: integer, nullable: true }
        entry_type: { type: string, enum: [earned, clawback, adjustment, payout, credit] }
        beneficiary: { type: string, enum: [affiliate, referrer, referee] }
        amount_minor: { type: integer }
        currency: { type: string }
        payout_amount_minor: { type: integer }
        payout_currency: { type: string }
        status: { type: string, enum: [pending, approved, paid, void] }
        hold_until: { type: string, nullable: true }
        description: { type: string, nullable: true }
        created_at: { type: string }
    CommissionList:
      type: object
      additionalProperties: false
      required: [data, has_more]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Commission" } }
        has_more: { type: boolean }
    PayoutItem:
      type: object
      additionalProperties: false
      required: [id, affiliate_id, amount_minor, currency, status]
      properties:
        id: { type: integer }
        affiliate_id: { type: integer }
        amount_minor: { type: integer }
        currency: { type: string }
        status: { type: string }
    Payout:
      type: object
      additionalProperties: false
      required: [id, campaign_id, rail, status, currency, total_minor, created_at, items]
      properties:
        id: { type: integer }
        campaign_id: { type: integer }
        rail: { type: string }
        status: { type: string }
        currency: { type: string }
        total_minor: { type: integer }
        created_at: { type: string }
        items: { type: array, items: { $ref: "#/components/schemas/PayoutItem" } }
    PayoutList:
      type: object
      additionalProperties: false
      required: [data, has_more]
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Payout" } }
        has_more: { type: boolean }
