openapi: 3.1.0
info:
  title: Qrindo Public API
  version: '1.0'
  summary: Dynamic QR codes, short links, analytics, webhooks.
  description: |
    Bearer-token (API key or OAuth client_credentials) REST API for the Qrindo
    platform. Designed for partner products that need to mint dynamic links,
    render QR codes, read analytics, and subscribe to scan events.

    ## Authentication

    Every request requires `Authorization: Bearer <token>`.

    | Token kind | Format | Best for |
    |---|---|---|
    | API key | `qrf_live_…` / `qrf_test_…` | Long-lived backend integrations |
    | OAuth access token | JWT (RS256) | Compliance / rotation-heavy partners |

    ## Errors

    Errors follow [RFC 7807 problem-json](https://datatracker.ietf.org/doc/html/rfc7807).

        {
          "type": "https://qrindo.com/errors/rate_limited",
          "title": "Too many requests",
          "status": 429,
          "code": "rate_limited",
          "detail": "Rate limit exceeded: 10000 req/min per API key. Retry in 12s.",
          "instance": "01HRV..."
        }

    ## Idempotency

    Pass `Idempotency-Key: <uuid>` on any POST. Retrying with the same key + body
    replays the cached response. Retrying with a different body returns
    `409 idempotency_replay_mismatch`.

    ## Rate limits

    Per-key per-minute, with [RFC 9331](https://datatracker.ietf.org/doc/rfc9331/)
    headers (`RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`).
  contact:
    email: hello@folowise.com
    url: https://qrindo.com
  license:
    name: Proprietary

servers:
  - url: https://apiqr.folowise.com/api/v1
    description: Production

tags:
  - name: Links
    description: Create, read, update, and rotate dynamic short links.
  - name: QR
    description: Render QR images for stored links or arbitrary content.
  - name: Analytics
    description: Stats and dimension breakdowns for scans.
  - name: Events
    description: Stream raw scan events.
  - name: Identity
    description: Inspect the current API key.
  - name: Public
    description: Unauthenticated endpoints used by the marketing demo.
  - name: Webhooks
    description: Subscribe to scan and link lifecycle events.
  - name: Deeplinks
    description: Platform-aware deep links (iOS / Android / web fallback).

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: qrf_live_xxx | OAuth JWT
  schemas:
    Problem:
      type: object
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        code:
          type: string
          enum:
            - invalid_request
            - unauthenticated
            - forbidden_scope
            - forbidden_ip
            - not_found
            - conflict
            - idempotency_replay_mismatch
            - rate_limited
            - unsupported_media_type
            - payload_too_large
            - internal
        detail: { type: string }
        instance: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              message: { type: string }

    Link:
      type: object
      required: [code, short_url, target_url, active, click_count, created_at, updated_at]
      properties:
        code: { type: string, example: a3kQ9m }
        short_url: { type: string, format: uri }
        title: { type: string, nullable: true }
        target_url: { type: string, format: uri }
        active: { type: boolean }
        click_count: { type: integer }
        last_click_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    LinkSummary:
      type: object
      properties:
        code: { type: string }
        title: { type: string, nullable: true }
        target_url: { type: string, format: uri }
        active: { type: boolean }
        click_count: { type: integer }
        last_click_at: { type: string, format: date-time, nullable: true }
        updated_at: { type: string, format: date-time }

    LinkList:
      type: object
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/LinkSummary' }
        next_cursor:
          type: string
          nullable: true

    CreateLink:
      type: object
      required: [target_url]
      properties:
        target_url: { type: string, format: uri }
        code:
          type: string
          description: Optional vanity slug. 4-8 chars, [A-Za-z0-9_-].
        title: { type: string, maxLength: 255 }
        active: { type: boolean, default: true }

    UpdateLink:
      type: object
      properties:
        target_url: { type: string, format: uri }
        title: { type: string, maxLength: 255 }
        active: { type: boolean }

    Stats:
      type: object
      properties:
        period: { type: string, enum: ['7d', '30d', '90d'] }
        total_scans: { type: integer }
        unique_visitors: { type: integer }
        series:
          type: array
          items:
            type: object
            properties:
              date: { type: string }
              scans: { type: integer }
              unique: { type: integer }

    Breakdown:
      type: object
      properties:
        dim: { type: string, enum: [country, device] }
        period: { type: string }
        rows:
          type: array
          items:
            type: object
            properties:
              key: { type: string, nullable: true }
              scans: { type: integer }

    EventList:
      type: object
      properties:
        items:
          type: array
          items:
            type: object
            properties:
              time: { type: string, format: date-time }
              code: { type: string }
              country: { type: string, nullable: true }
              device: { type: string }
        next_cursor: { type: string, nullable: true }

    BatchResult:
      type: object
      properties:
        items:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              success: { type: boolean }
              data: { $ref: '#/components/schemas/Link' }
              error: { type: string, nullable: true }

    ImportResult:
      type: object
      properties:
        total: { type: integer }
        success: { type: integer }
        failed: { type: integer }
        items:
          type: array
          items:
            type: object
            properties:
              row: { type: integer }
              success: { type: boolean }
              code: { type: string, nullable: true }
              short_url: { type: string, nullable: true }
              target_url: { type: string, nullable: true }
              error: { type: string, nullable: true }

    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events: { type: array, items: { type: string } }
        active: { type: boolean }
        secret_preview: { type: string }
        created_by: { type: string }
        created_at: { type: string, format: date-time }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        event_type: { type: string }
        attempt: { type: integer }
        status: { type: string, enum: [pending, in_flight, delivered, dead] }
        response_code: { type: integer, nullable: true }
        next_retry_at: { type: string, format: date-time, nullable: true }
        delivered_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    CreateDeeplink:
      type: object
      properties:
        code: { type: string }
        title: { type: string, nullable: true }
        targets:
          type: object
          properties:
            ios_url: { type: string, nullable: true }
            ios_app_scheme: { type: string, nullable: true }
            android_url: { type: string, nullable: true }
            android_app_scheme: { type: string, nullable: true }
            web_url: { type: string }
        active: { type: boolean }

  responses:
    Unauthorized:
      description: Missing, malformed, expired, or revoked token.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: Token does not carry the required scope or IP.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: Resource not found.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    BadRequest:
      description: Validation failed.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    PayloadTooLarge:
      description: Upload exceeds the size limit.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    RateLimited:
      description: Per-key minute window exceeded.
      headers:
        Retry-After: { schema: { type: integer } }
        RateLimit-Remaining: { schema: { type: integer } }
        RateLimit-Reset: { schema: { type: integer } }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  parameters:
    CodePath:
      name: code
      in: path
      required: true
      schema: { type: string, minLength: 4, maxLength: 8 }
    Period:
      name: period
      in: query
      schema: { type: string, enum: ['7d', '30d', '90d'], default: '7d' }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string }

security:
  - BearerAuth: []

paths:
  /me:
    get:
      tags: [Identity]
      summary: Inspect current API key
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  workspace_id: { type: string }
                  api_key_id: { type: string }
                  scopes:
                    type: array
                    items: { type: string }

  /links:
    get:
      tags: [Links]
      summary: List links
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LinkList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Links]
      summary: Create a link
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateLink' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Link' }
        '400':
          description: Validation failed
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '409':
          description: Code conflict or idempotency mismatch
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /links/{code}:
    get:
      tags: [Links]
      summary: Get a link
      parameters: [{ $ref: '#/components/parameters/CodePath' }]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Link' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Links]
      summary: Update a link
      parameters: [{ $ref: '#/components/parameters/CodePath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateLink' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Link' }
    delete:
      tags: [Links]
      summary: Delete a link (soft delete)
      parameters: [{ $ref: '#/components/parameters/CodePath' }]
      responses:
        '204': { description: Deleted }
        '404': { $ref: '#/components/responses/NotFound' }

  /links/{code}/rotate:
    post:
      tags: [Links]
      summary: Rotate the destination, keep the code
      parameters: [{ $ref: '#/components/parameters/CodePath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_url]
              properties:
                target_url: { type: string, format: uri }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Link' }

  /qr/{codeAndExt}:
    get:
      tags: [QR]
      summary: Render a stored link's QR
      parameters:
        - name: codeAndExt
          in: path
          required: true
          schema: { type: string, pattern: '^[A-Za-z0-9_-]{4,8}\\.(png|svg)$' }
        - name: size
          in: query
          schema: { type: integer, minimum: 64, maximum: 2048, default: 512 }
        - name: fg
          in: query
          schema: { type: string, pattern: '^#[0-9a-fA-F]{6}$' }
        - name: bg
          in: query
          schema: { type: string, pattern: '^#[0-9a-fA-F]{6}$' }
        - name: ec
          in: query
          schema: { type: string, enum: [L, M, Q, H], default: M }
      responses:
        '200':
          description: PNG or SVG bytes
          content:
            image/png: {}
            image/svg+xml: {}

  /qr/render:
    post:
      tags: [QR]
      summary: Render arbitrary content (no link stored)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string }
                format: { type: string, enum: [png, svg], default: png }
                size: { type: integer }
                fg: { type: string }
                bg: { type: string }
                ec: { type: string, enum: [L, M, Q, H] }
      responses:
        '200':
          description: Rendered image
          content:
            image/png: {}
            image/svg+xml: {}

  /links/{code}/stats:
    get:
      tags: [Analytics]
      summary: Time-series stats
      parameters:
        - $ref: '#/components/parameters/CodePath'
        - $ref: '#/components/parameters/Period'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Stats' }

  /links/{code}/breakdown:
    get:
      tags: [Analytics]
      summary: Dimension breakdown
      parameters:
        - $ref: '#/components/parameters/CodePath'
        - $ref: '#/components/parameters/Period'
        - name: dim
          in: query
          schema: { type: string, enum: [country, device], default: country }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Breakdown' }

  /events:
    get:
      tags: [Events]
      summary: List raw scan events
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
        - name: code
          in: query
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventList' }

  /public/preview:
    post:
      tags: [Public]
      summary: 'Demo-only: ten-minute disposable short link'
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_url]
              properties:
                target_url: { type: string, format: uri }
      responses:
        '200':
          description: Disposable short link + QR
          content:
            application/json:
              schema:
                type: object
                properties:
                  code: { type: string }
                  target_url: { type: string }
                  short_url: { type: string }
                  qr_png: { type: string }
                  expires_in: { type: integer }
                  preview_id: { type: string }
        '429': { $ref: '#/components/responses/RateLimited' }

  /public/stats/{code}:
    get:
      tags: [Public]
      summary: Public scan stats for a link (no auth)
      security: []
      parameters: [{ $ref: '#/components/parameters/CodePath' }]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  code: { type: string }
                  total_scans: { type: integer }
                  series:
                    type: array
                    items:
                      type: object
                      properties:
                        date: { type: string, format: date }
                        scans: { type: integer }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /links/batch:
    post:
      tags: [Links]
      summary: Create up to 100 links in one call
      description: Returns 201 if all succeed, 207 (multi-status) on partial failure. Each item reports its own success/error.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { $ref: '#/components/schemas/CreateLink' }
      responses:
        '201': { description: All created, content: { application/json: { schema: { $ref: '#/components/schemas/BatchResult' } } } }
        '207': { description: Partial success, content: { application/json: { schema: { $ref: '#/components/schemas/BatchResult' } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
    patch:
      tags: [Links]
      summary: Bulk update links (active / tags / folder) by code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [codes]
              properties:
                codes: { type: array, items: { type: string }, minItems: 1, maxItems: 100 }
                active: { type: boolean }
                tags: { type: array, items: { type: string } }
                folder_id: { type: string, nullable: true }
      responses:
        '200': { description: OK }
        '207': { description: Partial success }
    delete:
      tags: [Links]
      summary: Bulk soft-delete links by code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [codes]
              properties:
                codes: { type: array, items: { type: string }, minItems: 1, maxItems: 100 }
      responses:
        '200': { description: OK }
        '207': { description: Partial success }

  /links/import:
    post:
      tags: [Links]
      summary: Bulk import links from CSV
      description: 'Accepts text/csv or multipart/form-data (a `file` field). CSV columns: target_url,code,title,tags (tags pipe-separated).'
      requestBody:
        required: true
        content:
          text/csv:
            schema: { type: string }
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        '201': { description: All imported, content: { application/json: { schema: { $ref: '#/components/schemas/ImportResult' } } } }
        '207': { description: Partial import, content: { application/json: { schema: { $ref: '#/components/schemas/ImportResult' } } } }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }

  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      description: The `secret` is returned once; use it to verify the Qrindo-Signature header (t=...,v1=HMAC-SHA256("{t}.{rawBody}")).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string, enum: [scan.created, link.created, link.updated, link.deleted] }
                active: { type: boolean, default: true }
      responses:
        '201':
          description: Created (secret returned once)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Webhook'
                  - type: object
                    properties:
                      secret: { type: string, description: Shown once. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '402': { description: Plan webhook limit reached }

  /webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '204': { description: Deleted }
        '404': { $ref: '#/components/responses/NotFound' }

  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      summary: Send a test delivery (webhook.test event)
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200':
          description: Queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivery_id: { type: string }
                  status: { type: string }

  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List recent delivery attempts for a webhook
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/WebhookDelivery' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /deeplinks:
    get:
      tags: [Deeplinks]
      summary: List deeplinks
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200': { description: OK }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Deeplinks]
      summary: Create a deeplink
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateDeeplink' }
      responses:
        '201': { description: Created }
        '400': { $ref: '#/components/responses/BadRequest' }

  /deeplinks/{code}:
    get:
      tags: [Deeplinks]
      summary: Get a deeplink
      parameters: [{ $ref: '#/components/parameters/CodePath' }]
      responses:
        '200': { description: OK }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Deeplinks]
      summary: Update a deeplink
      parameters: [{ $ref: '#/components/parameters/CodePath' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateDeeplink' }
      responses:
        '200': { description: OK }
    delete:
      tags: [Deeplinks]
      summary: Delete a deeplink
      parameters: [{ $ref: '#/components/parameters/CodePath' }]
      responses:
        '204': { description: Deleted }
        '404': { $ref: '#/components/responses/NotFound' }
