openapi: 3.1.0
info:
  title: TangoQR Public API
  version: "1.0.0"
  description: >
    Create QR codes you can change later, repoint them, and receive a signed
    webhook on every scan. Every code is backed by an MGS-controlled redirect,
    so the destination can change without reprinting. The API is a Pro feature;
    keys are managed in the dashboard under Developers.
  contact:
    name: TangoQR support
    url: https://tangoqr.com/contact
servers:
  - url: https://tangoqr.com/api/public/v1
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Codes
    description: Create, list, repoint, and disable QR codes.
  - name: Webhooks
    description: Register endpoints that receive scan events.
paths:
  /codes:
    post:
      tags: [Codes]
      summary: Create a code
      description: Creates a redirect-backed QR code. Requires the `write` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CodeInput"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Code" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Invalid" }
    get:
      tags: [Codes]
      summary: List codes
      description: Lists the authenticated account's codes, newest first. Requires the `read` scope.
      parameters:
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: per,  in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodeList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /codes/{token}:
    parameters:
      - { name: token, in: path, required: true, schema: { type: string } }
    get:
      tags: [Codes]
      summary: Fetch a code
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Code" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Codes]
      summary: Update a code
      description: Repoint the destination, rename, or restyle. Requires the `write` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CodeInput" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Code" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Invalid" }
  /codes/{token}/disable:
    post:
      tags: [Codes]
      summary: Disable a code
      description: >
        Stops the code resolving (the redirect returns 410). Reversible — there
        is intentionally no hard delete, because a destroyed token would break
        every already-printed code. Requires the `write` scope.
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Code" }
        "404": { $ref: "#/components/responses/NotFound" }
  /codes/{token}/enable:
    post:
      tags: [Codes]
      summary: Re-enable a code
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Code" }
        "404": { $ref: "#/components/responses/NotFound" }
  /codes/{token}/image:
    get:
      tags: [Codes]
      summary: Fetch the code image
      description: 302-redirects to the cached PNG or SVG. Append `.svg` (default) or `.png`.
      parameters:
        - { name: token, in: path, required: true, schema: { type: string } }
      responses:
        "302": { description: Redirect to the rendered image URL }
        "404": { $ref: "#/components/responses/NotFound" }
  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
    post:
      tags: [Webhooks]
      summary: Register a webhook endpoint
      description: The signing `secret` is returned only in this response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, description: An https URL. }
                event_types:
                  type: array
                  items: { type: string, enum: [scan.recorded] }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookWithSecret" }
        "422": { $ref: "#/components/responses/Invalid" }
  /webhooks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    get:
      tags: [Webhooks]
      summary: Fetch a webhook endpoint
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      summary: Update a webhook endpoint
      description: Toggle `enabled` (e.g. to re-arm an auto-disabled endpoint) or change the URL.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                enabled: { type: boolean }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      responses:
        "204": { description: Deleted }
        "404": { $ref: "#/components/responses/NotFound" }
webhooks:
  scan.recorded:
    post:
      summary: A code was scanned
      description: >
        Delivered to each enabled endpoint on every scan of an owned code.
        Signed with HMAC-SHA256 in the `Tango-Signature` header
        (`t=<unix>,v1=<hex>`); the signed string is `"<t>.<raw body>"`.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScanEvent" }
      responses:
        "200": { description: Return any 2xx to acknowledge receipt. }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Authorization: Bearer tq_live_…"
  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The key's account is not on Pro, or the key lacks the required scope.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such resource on this account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Invalid:
      description: The request failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    CodeInput:
      type: object
      required: [destination_url]
      properties:
        destination_url: { type: string, description: "Where the code points. A bare host gets https:// prepended." }
        label: { type: string, maxLength: 80 }
        foreground_color: { type: string, example: "#111111" }
        background_color: { type: string, example: "#FFFFFF" }
        frame_style: { type: string, enum: [none, scan_me, rounded] }
        module_shape: { type: string, enum: [square, rounded, dot], description: "Data-module shape. Applies to the SVG image; PNG renders square modules." }
        eye_shape: { type: string, enum: [square, rounded, leaf, circle], description: "Finder-pattern (corner) shape. Applies to the SVG image; PNG renders square eyes." }
    Code:
      type: object
      properties:
        token: { type: string }
        short_url: { type: string, description: "The redirect URL the QR encodes." }
        destination_url: { type: string }
        label: { type: [string, "null"] }
        scan_count: { type: integer }
        frame_style: { type: [string, "null"] }
        module_shape: { type: [string, "null"], enum: [square, rounded, dot, null] }
        eye_shape: { type: [string, "null"], enum: [square, rounded, leaf, circle, null] }
        colors:
          type: object
          properties:
            foreground: { type: [string, "null"] }
            background: { type: [string, "null"] }
        image:
          type: object
          properties:
            svg: { type: string, format: uri }
            png: { type: string, format: uri }
        disabled: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CodeList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Code" }
        pagination:
          type: object
          properties:
            page: { type: integer }
            per: { type: integer }
            total: { type: integer }
            total_pages: { type: integer }
    Webhook:
      type: object
      properties:
        id: { type: integer }
        url: { type: string, format: uri }
        event_types: { type: array, items: { type: string } }
        enabled: { type: boolean }
        consecutive_failures: { type: integer }
        last_delivery_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    WebhookWithSecret:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          properties:
            secret: { type: string, description: "Returned only on creation." }
    ScanEvent:
      type: object
      properties:
        id: { type: string, example: "evt_123" }
        type: { type: string, example: "scan.recorded" }
        created: { type: string, format: date-time }
        data:
          type: object
          properties:
            code:
              type: object
              properties:
                token: { type: string }
                short_url: { type: string }
                destination_url: { type: string }
                label: { type: [string, "null"] }
            scan:
              type: object
              properties:
                scanned_at: { type: string, format: date-time }
                country_code: { type: [string, "null"] }
                device_class: { type: string, enum: [mobile, desktop, tablet, bot, unknown] }
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            type: { type: string, example: "invalid_request" }
            message: { type: string }
            details: { type: object, additionalProperties: true }
