openapi: 3.0.3

info:
  title: QRCodeStack API
  version: 1.0.0
  description: |
    The QRCodeStack API lets you create, manage, and track dynamic QR codes
    programmatically. Use it to integrate QR generation into your CRM,
    print workflow, campaign tool, or any backend system.

    > **Code examples + OpenAPI source on GitHub:**
    > [github.com/QRCodeStack/qr-code-generator-api](https://github.com/QRCodeStack/qr-code-generator-api) —
    > copy-pasteable examples in curl, Node.js, and Python under MIT license.

    ## Getting started

    1. Sign up for a **Pro** or **Advanced** plan (the API is not available on Starter).
    2. Generate an API key from your dashboard at
       [qrcodestack.com/qr-dashboard/integrations](https://qrcodestack.com/qr-dashboard/integrations).
       Keys are shown once — save them immediately.
    3. Send the key as a Bearer token on every request:

       ```bash
       curl https://qrcodestack.com/api/v1/me \
         -H "Authorization: Bearer qrs_..."
       ```

    All responses are JSON. Timestamps are RFC 3339 in UTC.

    ## Plans and limits

    | Plan      | Per-minute | Per-month | Bulk endpoint |
    |-----------|-----------:|----------:|---------------|
    | Starter   |       n/a  |       n/a |          no   |
    | Pro       |        60  |     5 000 |          no   |
    | Advanced  |       600  |   100 000 |         yes   |

    Exceeding either window returns HTTP 429 with a `Retry-After` header.

    ## Errors

    Errors are JSON with shape `{ "success": false, "error": "<message>", "code": "<machine_code>" }`.
    Standard HTTP status codes apply: 401 unauthenticated, 403 forbidden by plan,
    404 not found, 422 validation failure, 429 rate-limited, 5xx server error.

    ## Versioning

    The current major version is `v1`. Breaking changes will be released under
    a new `/v2` prefix; non-breaking additions (new optional fields, new
    endpoints) ship under `v1` without notice.

  contact:
    name: QRCodeStack Support
    email: support@qrcodestack.com
    url: https://qrcodestack.com/support
  license:
    name: Proprietary
    url: https://qrcodestack.com/terms

externalDocs:
  description: Code examples + OpenAPI source on GitHub (MIT licensed)
  url: https://github.com/QRCodeStack/qr-code-generator-api

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

security:
  - bearerAuth: []

tags:
  - name: Account
    description: Information about the authenticated account.
  - name: QR codes
    description: Create, read, update, and delete dynamic QR codes.
  - name: Analytics
    description: Scan analytics and event logs.
  - name: Bulk
    description: Batch operations (Advanced plan only).

paths:
  /me:
    get:
      tags: [Account]
      summary: Get account info
      description: |
        Returns the account that owns this API key, the active plan, and
        current usage counters for the billing period.
      responses:
        '200':
          description: Account info
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes:
    get:
      tags: [QR codes]
      summary: List QR codes
      description: |
        Returns dynamic QR codes owned by the authenticated account.
        Results are ordered by `created_at` descending. Use the
        `cursor` returned in `pagination.next_cursor` to fetch the next page.
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: cursor
          description: Opaque cursor returned by the previous response.
          schema: { type: string }
        - in: query
          name: type
          description: Filter by QR type (e.g. `url`, `vcard`, `wifi`).
          schema: { type: string }
        - in: query
          name: search
          description: Substring match on QR name.
          schema: { type: string }
      responses:
        '200':
          description: A page of QR codes
          content:
            application/json:
              schema:
                type: object
                required: [success, data, pagination]
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/QrCode' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

    post:
      tags: [QR codes]
      summary: Create a dynamic QR code
      description: |
        Creates a dynamic QR code. The QR points to a redirect URL that
        you can update later without reprinting. The response includes the
        permanent QR image URL hosted on QRCodeStack.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QrCodeCreate' }
      responses:
        '201':
          description: QR code created
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success: { type: boolean, example: true }
                  data: { $ref: '#/components/schemas/QrCode' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }

    get:
      tags: [QR codes]
      summary: Get a QR code
      responses:
        '200':
          description: QR code
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success: { type: boolean, example: true }
                  data: { $ref: '#/components/schemas/QrCode' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

    patch:
      tags: [QR codes]
      summary: Update a QR code
      description: |
        Updates one or more fields of a dynamic QR code. The QR image itself
        is regenerated only if `render_customizations` is supplied.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QrCodeUpdate' }
      responses:
        '200':
          description: QR code updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data: { $ref: '#/components/schemas/QrCode' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

    delete:
      tags: [QR codes]
      summary: Delete a QR code
      description: |
        Soft-deletes the QR code. Existing prints with this QR will return a
        410 Gone redirect after deletion. To temporarily disable a QR
        without deletion, use PATCH with `is_active: false`.
      responses:
        '204':
          description: QR code deleted
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes/{id}/analytics:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
      - in: query
        name: from
        description: ISO date (inclusive). Defaults to 30 days ago.
        schema: { type: string, format: date }
      - in: query
        name: to
        description: ISO date (inclusive). Defaults to today.
        schema: { type: string, format: date }
    get:
      tags: [Analytics]
      summary: Get aggregated analytics for a QR code
      description: |
        Returns aggregated scan analytics over the requested date range.
        Country/city/coordinate breakdowns are only included on the
        **Advanced** plan (the "Precision geolocation tracking" feature).
      responses:
        '200':
          description: Aggregated analytics
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data: { $ref: '#/components/schemas/Analytics' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes/{id}/scans:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
      - in: query
        name: limit
        schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      - in: query
        name: cursor
        schema: { type: string }
    get:
      tags: [Analytics]
      summary: List individual scan events
      description: |
        Returns a paginated log of individual scan events for a QR code,
        newest first. Geographic fields are only populated on the Advanced
        plan.
      responses:
        '200':
          description: Scan events
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ScanEvent' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /qr-codes/bulk:
    post:
      tags: [Bulk]
      summary: Create up to 500 QR codes in one call
      description: |
        Creates many dynamic QR codes in a single request. **Available on
        the Advanced plan only.** Each item is processed independently;
        a single bad item does not fail the whole batch. The response
        lists the result for each input position.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: '#/components/schemas/QrCodeCreate' }
      responses:
        '200':
          description: Bulk creation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  created_count: { type: integer, example: 498 }
                  failed_count: { type: integer, example: 2 }
                  results:
                    type: array
                    items:
                      oneOf:
                        - type: object
                          properties:
                            index: { type: integer }
                            status: { type: string, enum: [created] }
                            data: { $ref: '#/components/schemas/QrCode' }
                        - type: object
                          properties:
                            index: { type: integer }
                            status: { type: string, enum: [failed] }
                            error: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key (e.g. qrs_...)

  schemas:
    Me:
      type: object
      properties:
        success: { type: boolean, example: true }
        data:
          type: object
          properties:
            account_id: { type: string, format: uuid }
            email: { type: string, format: email }
            plan:
              type: string
              enum: [pro, advanced]
              description: API access requires Pro or Advanced.
            usage:
              type: object
              properties:
                dynamic_qr_count: { type: integer, example: 42 }
                dynamic_qr_limit: { type: integer, example: 250 }
                api_calls_this_month: { type: integer, example: 1834 }
                api_calls_monthly_limit: { type: integer, example: 5000 }

    QrCode:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: "Spring campaign QR" }
        type:
          type: string
          enum: [url, vcard, wifi, pdf, text, email, sms, image, video, social, multilink]
        destination_url:
          type: string
          format: uri
          description: |
            The URL the QR redirects to. For non-URL types (vcard, wifi, etc.)
            this is the hosted scan page that displays the content.
        scan_url:
          type: string
          format: uri
          description: The permanent QR redirect URL — encode this in the QR image.
          example: https://qrcodestack.com/qr/8f4d23a0-...
        image_url:
          type: string
          format: uri
          description: PNG of the QR code.
          example: https://www.bucket.qrcodestack.com/generated-qrcode/url/8f4d23a0-....png
        render_customizations: { $ref: '#/components/schemas/RenderCustomizations' }
        is_active: { type: boolean, example: true }
        total_scans: { type: integer, example: 1247 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    QrCodeCreate:
      type: object
      required: [type, destination_url]
      properties:
        name:
          type: string
          maxLength: 200
          description: Optional human-readable name. Defaults to the destination_url host.
        type:
          type: string
          enum: [url, vcard, wifi, pdf, text, email, sms, image, video, social, multilink]
        destination_url:
          type: string
          format: uri
          description: For type `url`, the URL to redirect to. For other types, see the per-type fields below.
        content:
          type: object
          description: |
            Type-specific payload. For type `vcard`, supply
            `{ "first_name": "...", "last_name": "...", "phone": "...", "email": "...", "company": "..." }`.
            For type `wifi`, supply `{ "ssid": "...", "password": "...", "encryption": "WPA2" }`.
            See the per-type guide at https://qrcodestack.com/types-of-qr-codes for the full schema.
          additionalProperties: true
        render_customizations: { $ref: '#/components/schemas/RenderCustomizations' }

    QrCodeUpdate:
      type: object
      description: |
        All fields are optional. Only supplied fields are updated.
        Supplying `render_customizations` triggers a re-render of the QR image.
      properties:
        name: { type: string, maxLength: 200 }
        destination_url: { type: string, format: uri }
        content:
          type: object
          additionalProperties: true
        render_customizations: { $ref: '#/components/schemas/RenderCustomizations' }
        is_active:
          type: boolean
          description: Set to `false` to temporarily disable the QR without deleting it.

    RenderCustomizations:
      type: object
      description: |
        Visual styling for the rendered QR image. All fields are optional.
        Designed to be future-proof — additional fields will be added under
        this object rather than at the top level.
      properties:
        dot_color: { type: string, example: "#0F172A", description: "CSS hex color for the QR data dots." }
        dot_shape:
          type: string
          enum: [square, rounded, dots, classy, classy-rounded, extra-rounded]
          default: square
        corner_color: { type: string, example: "#0F172A" }
        corner_shape:
          type: string
          enum: [square, rounded, extra-rounded, dot]
          default: square
        background_color: { type: string, example: "#FFFFFF" }
        logo_url:
          type: string
          format: uri
          description: HTTPS URL of a square logo (recommended ≥256×256, ≤2 MB) to inset at the center.
        logo_size:
          type: number
          minimum: 0.1
          maximum: 0.4
          default: 0.25
          description: Logo size as a fraction of the QR canvas (0.10 = 10%).
        frame:
          type: string
          enum: [none, scan-me-bottom, scan-me-top, rounded-bottom, custom-text]
          default: none
        frame_text:
          type: string
          maxLength: 30
          description: Custom text shown in the frame when `frame=custom-text`.

    Analytics:
      type: object
      properties:
        period:
          type: object
          properties:
            from: { type: string, format: date }
            to: { type: string, format: date }
        total_scans: { type: integer, example: 1247 }
        unique_scans: { type: integer, example: 892, description: "Unique devices/IPs over the period." }
        by_day:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              scans: { type: integer }
        by_hour:
          type: array
          items:
            type: object
            properties:
              hour: { type: integer, minimum: 0, maximum: 23 }
              scans: { type: integer }
        by_device:
          type: array
          items:
            type: object
            properties:
              device: { type: string, example: "mobile" }
              scans: { type: integer }
        by_browser:
          type: array
          items:
            type: object
            properties:
              browser: { type: string, example: "Chrome" }
              scans: { type: integer }
        by_os:
          type: array
          items:
            type: object
            properties:
              os: { type: string, example: "iOS" }
              scans: { type: integer }
        by_country:
          type: array
          description: Advanced plan only. Empty on Pro.
          items:
            type: object
            properties:
              country_code: { type: string, example: "US" }
              country_name: { type: string, example: "United States" }
              scans: { type: integer }
        by_city:
          type: array
          description: Advanced plan only. Empty on Pro.
          items:
            type: object
            properties:
              city: { type: string, example: "New York" }
              country_code: { type: string, example: "US" }
              scans: { type: integer }

    ScanEvent:
      type: object
      properties:
        id: { type: string, format: uuid }
        scanned_at: { type: string, format: date-time }
        device: { type: string, example: "mobile" }
        browser: { type: string, example: "Safari" }
        os: { type: string, example: "iOS" }
        referrer: { type: string, nullable: true, example: "https://twitter.com" }
        country_code:
          type: string
          nullable: true
          example: "US"
          description: Advanced plan only.
        city:
          type: string
          nullable: true
          example: "New York"
          description: Advanced plan only.

    Pagination:
      type: object
      properties:
        limit: { type: integer, example: 25 }
        next_cursor:
          type: string
          nullable: true
          description: Pass as the `cursor` query parameter on the next request. `null` when there are no more pages.

    Error:
      type: object
      required: [success, error]
      properties:
        success: { type: boolean, example: false }
        error: { type: string, example: "API key required" }
        code:
          type: string
          example: "MISSING_API_KEY"
          description: Machine-readable error code. Stable across releases.

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: API key is valid but the account's plan does not allow this endpoint
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found, or not owned by the authenticated account
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationError:
      description: The request body failed validation
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
              - type: object
                properties:
                  fields:
                    type: object
                    additionalProperties: { type: string }
                    example: { destination_url: "must be a valid HTTPS URL" }
    RateLimited:
      description: Per-minute or per-month rate limit exceeded
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the next request will be accepted
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer }
          description: Unix timestamp (seconds) when the current window resets
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
