openapi: 3.1.0

info:
  title: VisionLink QR API
  version: 1.0.0
  summary: Create and retarget hosted QR short links from your own systems.
  description: |
    The `/v1` API lets a workspace create, list, retarget, pause, activate and delete hosted QR codes, and read its plan usage. Every code gets a public scan URL at the domain root: `https://vlcard.me/<short_code>`. Scans get a `302` to the code's current destination, so you can change where a printed QR goes without reprinting it.

    Call the API from your server. Browser calls from other origins are blocked by CORS, and an API key in front-end code is a leaked key.

    Requests and responses are JSON (`Content-Type: application/json`). Timestamps are RFC 3339 in UTC. Codes created through the API always have `origin` set to `api`.

    Destination rules (checked on create and update, same rules as the dashboard):

    - `http` or `https` URL with a host, at most 2048 bytes after trimming surrounding whitespace.
    - No spaces or control characters inside the URL, no `user@` userinfo, no IP-literal hosts such as `[::1]`, and a port of 1 to 5 digits if present.
    - Percent escapes in the path and fragment must be valid and must not encode NUL, CR or LF. The query string is passed through as is.

    A destination that breaks any rule is rejected with `400 invalid_destination`.
  contact:
    name: VisionLink QR
    url: https://qr.visionlink.co.za/docs/api

servers:
  - url: https://vlcard.me/v1
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Codes
    description: Hosted QR codes owned by the key's workspace.
  - name: Usage
    description: Plan limits and what the workspace is using.
  - name: Health
    description: Liveness check. No key needed.

# The per-plan RPM list below is checked against frontend/src/lib/pricing.ts
# (itself checked against the plans SQL seed) by frontend/src/lib/pricing.test.ts.
x-rate-limits: |
  There are two budgets, chosen after the key is looked up.

  - **Valid key:** limited per key to your plan's requests per minute (Business 120, Enterprise 600, Partner 300, Scale 1,200, Platform 3,000). The window is a fixed minute that starts with the first request. Over the limit you get `429 rate_limited` with no rate-limit headers; wait for the minute to pass.
  - **Missing, unknown, revoked or no-access key:** 60 requests per minute per client IP, shared by every protected `/v1` route. These responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds until the window resets). The 61st request gets `429 rate_limited` with `Retry-After` in seconds.

  `GET /v1/health` is never rate limited. Scans of `https://vlcard.me/<short_code>` never count against these limits and are never answered with `429`.

x-errors: |
  Errors are JSON with one field, `error`, holding a stable snake_case code: `{"error": "not_found"}`. Branch on the code, not on the HTTP status alone. Unmatched paths under `/v1` return `404 not_found`.

paths:
  /health:
    get:
      operationId: getHealth
      tags: [Health]
      summary: Check that the API is up
      description: Answers without touching the database. Use it for uptime checks, not to test your key.
      security: []
      responses:
        '200':
          description: The API process is running.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'
              example:
                status: ok

  /codes:
    get:
      operationId: listCodes
      tags: [Codes]
      summary: List codes
      description: Returns the workspace's codes, newest first. The list includes codes created in the dashboard, not only API-created ones. `total` is the count of all codes in the workspace, so page with `offset` until you have seen `total` rows.
      parameters:
        - name: limit
          in: query
          description: Page size, 1 or more. Values above 100 are treated as 100.
          schema:
            type: integer
            minimum: 1
            default: 20
          example: 50
        - name: offset
          in: query
          description: Rows to skip, 0 or more.
          schema:
            type: integer
            minimum: 0
            default: 0
          example: 0
      responses:
        '200':
          description: One page of codes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CodeList'
              example:
                codes:
                  - id: 3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18
                    org_id: 9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40
                    short_code: aB3xY9
                    name: Spring flyer
                    destination: https://example.com/spring?utm_source=qr
                    status: active
                    origin: api
                    expires_at: null
                    has_ad: false
                    created_at: '2026-09-17T08:30:00Z'
                    updated_at: '2026-09-17T08:30:00Z'
                total: 1
        '400':
          description: A query parameter is not a valid number.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_limit:
                  summary: limit is not an integer of 1 or more
                  value:
                    error: invalid_limit
                invalid_offset:
                  summary: offset is not an integer of 0 or more
                  value:
                    error: invalid_offset
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ApiAccessRequired'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

    post:
      operationId: createCode
      tags: [Codes]
      summary: Create a code
      description: Creates an active code with a new six-character `short_code`. The code counts against the plan's active pool (clean, or ad-supported when `has_ad` is true), library size and monthly redirect quota. Its scan URL is `https://vlcard.me/<short_code>`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCodeRequest'
            examples:
              minimal:
                summary: Destination only
                value:
                  destination: https://example.com/menu
              full:
                summary: Name, expiry and ad-supported pool (B2B plans)
                value:
                  destination: https://example.com/spring?utm_source=qr
                  name: Spring flyer
                  expires_at: '2026-12-31T22:00:00Z'
                  has_ad: true
      responses:
        '201':
          description: The code was created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QRCode'
              example:
                id: 3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18
                org_id: 9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40
                short_code: aB3xY9
                name: Spring flyer
                destination: https://example.com/spring?utm_source=qr
                status: active
                origin: api
                expires_at: '2026-12-31T22:00:00Z'
                has_ad: true
                created_at: '2026-09-17T08:30:00Z'
                updated_at: '2026-09-17T08:30:00Z'
        '400':
          description: The body or one of its values is invalid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_json:
                  summary: Body is not a JSON object with the documented field types
                  value:
                    error: invalid_json
                invalid_destination:
                  summary: destination breaks the destination rules
                  value:
                    error: invalid_destination
                invalid_expiry:
                  summary: expires_at is not an RFC 3339 timestamp
                  value:
                    error: invalid_expiry
                expiry_must_be_future:
                  summary: expires_at is not in the future
                  value:
                    error: expiry_must_be_future
                expiry_exceeds_plan_limit:
                  summary: expires_at is further out than the plan allows
                  value:
                    error: expiry_exceeds_plan_limit
                ads_not_available:
                  summary: has_ad is true on a plan without an ad-supported pool
                  value:
                    error: ads_not_available
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The key has no API access, or a plan limit blocks the create.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                api_access_required:
                  summary: The workspace's plan has no API access
                  value:
                    error: api_access_required
                active_limit_reached:
                  summary: The clean active pool is full; pause or delete a code first
                  value:
                    error: active_limit_reached
                ad_limit_reached:
                  summary: The ad-supported active pool is full
                  value:
                    error: ad_limit_reached
                library_limit_reached:
                  summary: The workspace holds as many codes as the plan allows
                  value:
                    error: library_limit_reached
                redirect_quota_reached:
                  summary: This month's redirect units are used up
                  value:
                    error: redirect_quota_reached
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /codes/{code}:
    parameters:
      - $ref: '#/components/parameters/ShortCode'

    get:
      operationId: getCode
      tags: [Codes]
      summary: Get a code
      description: A code deleted from the dashboard is not returned by the API (list or get); it answers `404` like a code that never existed.
      responses:
        '200':
          description: The code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QRCode'
              example:
                id: 3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18
                org_id: 9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40
                short_code: aB3xY9
                name: null
                destination: https://example.com/menu
                status: paused
                origin: api
                expires_at: null
                has_ad: false
                created_at: '2026-09-17T08:30:00Z'
                updated_at: '2026-09-18T12:05:41Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ApiAccessRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

    patch:
      operationId: updateCode
      tags: [Codes]
      summary: Retarget a code
      description: Sets a new destination and optionally changes the name. Scans follow the new destination straight away. The short code, status and expiry don't change.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCodeRequest'
            examples:
              retarget:
                summary: New destination, name unchanged
                value:
                  destination: https://example.com/summer
              rename:
                summary: New destination and name
                value:
                  destination: https://example.com/summer
                  name: Summer flyer
              clear_name:
                summary: New destination, name removed
                value:
                  destination: https://example.com/summer
                  name: null
      responses:
        '200':
          description: The updated code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QRCode'
              example:
                id: 3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18
                org_id: 9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40
                short_code: aB3xY9
                name: Summer flyer
                destination: https://example.com/summer
                status: active
                origin: api
                expires_at: null
                has_ad: false
                created_at: '2026-09-17T08:30:00Z'
                updated_at: '2026-10-01T06:00:00Z'
        '400':
          description: The body or one of its values is invalid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_json:
                  summary: Body is not a JSON object
                  value:
                    error: invalid_json
                invalid_name:
                  summary: name is neither a string nor null
                  value:
                    error: invalid_name
                invalid_destination:
                  summary: destination is missing or breaks the destination rules
                  value:
                    error: invalid_destination
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ApiAccessRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

    delete:
      operationId: deleteCode
      tags: [Codes]
      summary: Delete a code
      description: Permanently deletes the code and its scan analytics. The scan URL stops redirecting at once. To stop scans but keep the code, pause it instead.
      responses:
        '204':
          description: The code was deleted. No body.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ApiAccessRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /codes/{code}/pause:
    parameters:
      - $ref: '#/components/parameters/ShortCode'
    post:
      operationId: pauseCode
      tags: [Codes]
      summary: Pause a code
      description: Sets `status` to `paused`. Scans show an "unavailable" page instead of redirecting, and the code stops counting against the active pool. It stays in the library. No request body. Pausing an already paused code succeeds.
      responses:
        '200':
          description: The paused code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QRCode'
              example:
                id: 3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18
                org_id: 9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40
                short_code: aB3xY9
                name: Spring flyer
                destination: https://example.com/spring?utm_source=qr
                status: paused
                origin: api
                expires_at: null
                has_ad: false
                created_at: '2026-09-17T08:30:00Z'
                updated_at: '2026-09-18T12:05:41Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ApiAccessRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /codes/{code}/activate:
    parameters:
      - $ref: '#/components/parameters/ShortCode'
    post:
      operationId: activateCode
      tags: [Codes]
      summary: Activate a code
      description: Sets `status` to `active` so scans redirect again. Re-checks the plan's active pool for a paused code. A code past its `expires_at` can't be activated; create a new code instead. No request body.
      responses:
        '200':
          description: The active code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QRCode'
              example:
                id: 3f0c9a52-8a3b-4d7e-9f41-2b6c1d0e7a18
                org_id: 9b1e4c27-5d63-4f08-a2c9-7e3d5b1a6f40
                short_code: aB3xY9
                name: Spring flyer
                destination: https://example.com/spring?utm_source=qr
                status: active
                origin: api
                expires_at: null
                has_ad: false
                created_at: '2026-09-17T08:30:00Z'
                updated_at: '2026-09-19T07:45:12Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The key has no API access, or the matching active pool is full.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                api_access_required:
                  summary: The workspace's plan has no API access
                  value:
                    error: api_access_required
                active_limit_reached:
                  summary: The clean active pool is full
                  value:
                    error: active_limit_reached
                ad_limit_reached:
                  summary: The ad-supported active pool is full
                  value:
                    error: ad_limit_reached
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The code is past its expiry and can't be reactivated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                code_expired:
                  summary: expires_at has passed; create a new code instead
                  value:
                    error: code_expired
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /usage:
    get:
      operationId: getUsage
      tags: [Usage]
      summary: Get plan usage
      description: A snapshot of the workspace's plan and how much of it is used. A `null` limit means unlimited. Redirect units reset each calendar month (Africa/Johannesburg); a clean scan costs 1 unit and an ad-supported scan 0.25. When `over_quota` is true, existing codes keep redirecting but creates fail with `redirect_quota_reached`.
      responses:
        '200':
          description: Usage for the key's workspace.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Usage'
              example:
                audience: b2b
                api_access: true
                plan_id: partner
                display_name: Partner
                clean_active: 212
                clean_limit: 400
                ad_active: 35
                ad_limit: 1200
                library_used: 480
                library_limit: 10000
                redirect_units: 18250.75
                redirect_limit: 100000
                over_quota: false
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ApiAccessRequired'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        A workspace API key, sent as `X-API-Key: vlq_…`. Managers of a workspace whose plan includes API access mint keys in the dashboard under **API**. The full key is shown once; only a hash is stored, so if you lose it, revoke it and mint a new one. A key acts on its own workspace only.

  parameters:
    ShortCode:
      name: code
      in: path
      required: true
      description: The code's six-character `short_code` (not its `id`). Case-sensitive. Anything that isn't six letters or digits, or belongs to another workspace, returns `404 not_found`.
      schema:
        type: string
        pattern: '^[0-9A-Za-z]{6}$'
      example: aB3xY9

  headers:
    X-RateLimit-Limit:
      description: Requests allowed per minute from this IP without a usable key (60).
      schema:
        type: integer
      example: 60
    X-RateLimit-Remaining:
      description: Requests left in the current window from this IP without a usable key.
      schema:
        type: integer
      example: 59
    X-RateLimit-Reset:
      description: Seconds until the window resets.
      schema:
        type: integer
      example: 42
    Retry-After:
      description: Seconds to wait before retrying. Only sent when the no-key IP budget is spent.
      schema:
        type: integer
      example: 42

  responses:
    Unauthorized:
      description: The `X-API-Key` header is missing, or the key is unknown or revoked.
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missing_api_key:
              summary: No X-API-Key header
              value:
                error: missing_api_key
            invalid_api_key:
              summary: Unknown, mistyped or revoked key
              value:
                error: invalid_api_key
    ApiAccessRequired:
      description: The key is valid but the workspace's plan has no API access.
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            api_access_required:
              summary: The workspace's plan has no API access
              value:
                error: api_access_required
    NotFound:
      description: No code with this short code in the key's workspace.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            not_found:
              summary: Unknown short code
              value:
                error: not_found
    RateLimited:
      description: Too many requests. See the rate limit section.
      headers:
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rate_limited:
              summary: Per-key or no-key IP budget spent
              value:
                error: rate_limited
    InternalError:
      description: Something failed on our side. Safe to retry reads; check the code's state before retrying a write.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            internal_error:
              summary: Unexpected server error
              value:
                error: internal_error

  schemas:
    Health:
      type: object
      required: [status]
      properties:
        status:
          type: string
          const: ok

    QRCode:
      type: object
      required: [id, org_id, short_code, name, destination, status, origin, expires_at, has_ad, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
          description: Internal id. Use `short_code` in API paths.
        org_id:
          type: string
          format: uuid
          description: The workspace that owns the code.
        short_code:
          type: string
          pattern: '^[0-9A-Za-z]{6}$'
          description: Six case-sensitive letters and digits. The scan URL is `https://vlcard.me/<short_code>`.
        name:
          type: [string, 'null']
          description: Your label for the code. Never shown to scanners.
        destination:
          type: string
          format: uri
          maxLength: 2048
          description: Where scans are sent, stored trimmed. `maxLength` is in bytes.
        status:
          type: string
          enum: [active, paused, expired]
          description: '`active` redirects. `paused` and `expired` show an unavailable page. A code becomes `expired` once a scan finds it past `expires_at`.'
        origin:
          type: string
          enum: [api, standalone, once_off, visionlink_card]
          description: How the code was created. `api` for this API; the others are dashboard origins.
        expires_at:
          type: [string, 'null']
          format: date-time
          description: After this moment scans stop redirecting. `null` means no expiry.
        has_ad:
          type: boolean
          description: Whether the code is in the ad-supported pool.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    CodeList:
      type: object
      required: [codes, total]
      properties:
        codes:
          type: array
          items:
            $ref: '#/components/schemas/QRCode'
        total:
          type: integer
          minimum: 0
          description: All codes in the workspace, not just this page.

    CreateCodeRequest:
      type: object
      required: [destination]
      properties:
        destination:
          type: string
          format: uri
          maxLength: 2048
          description: '`http` or `https` URL that follows the destination rules. Surrounding whitespace is trimmed. `maxLength` is in bytes.'
        name:
          type: [string, 'null']
          description: Optional label. Trimmed; empty means no name.
        expires_at:
          type: [string, 'null']
          format: date-time
          description: Optional RFC 3339 timestamp in the future, within the plan's maximum expiry. `null` or an empty string means no expiry.
        has_ad:
          type: boolean
          default: false
          description: Partner plans (Partner, Scale, Platform) only. Puts the code in the ad-supported pool, whose scans cost 0.25 redirect units. On other plans `true` returns `400 ads_not_available`.

    UpdateCodeRequest:
      type: object
      required: [destination]
      properties:
        destination:
          type: string
          format: uri
          maxLength: 2048
          description: The new destination. Required on every update, even if only the name changes.
        name:
          type: [string, 'null']
          description: Leave the field out to keep the current name. Send a string to set it, or `null` or `""` to remove it.

    Usage:
      type: object
      required: [audience, api_access, plan_id, display_name, clean_active, clean_limit, ad_active, ad_limit, library_used, library_limit, redirect_units, redirect_limit, over_quota]
      properties:
        audience:
          type: string
          enum: [b2b, b2c]
          description: '`b2b` for partner plans, `b2c` for Business and Enterprise.'
        api_access:
          type: boolean
        plan_id:
          type: string
          description: Plan id, for example `partner`, `scale`, `pro` (Business) or `enterprise`.
        display_name:
          type: string
        clean_active:
          type: integer
          description: Active codes in the clean pool.
        clean_limit:
          type: [integer, 'null']
          description: Clean active cap. `null` is unlimited.
        ad_active:
          type: integer
          description: Active codes in the ad-supported pool.
        ad_limit:
          type: [integer, 'null']
          description: Ad-supported active cap. `null` is unlimited.
        library_used:
          type: integer
          description: Codes stored in the workspace, active or not.
        library_limit:
          type: [integer, 'null']
          description: Library cap. `null` is unlimited.
        redirect_units:
          type: number
          description: Redirect units used this month.
        redirect_limit:
          type: [integer, 'null']
          description: Monthly redirect units. `null` is unlimited.
        over_quota:
          type: boolean
          description: True when `redirect_units` has reached `redirect_limit`. Creates are blocked; scans still redirect.

    Error:
      type: object
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/ErrorCode'

    ErrorCode:
      type: string
      description: Stable machine-readable error code.
      enum:
        - missing_api_key
        - invalid_api_key
        - api_access_required
        - rate_limited
        - not_found
        - invalid_json
        - invalid_limit
        - invalid_offset
        - invalid_expiry
        - invalid_name
        - invalid_destination
        - expiry_must_be_future
        - expiry_exceeds_plan_limit
        - ads_not_available
        - invalid_status
        - active_limit_reached
        - ad_limit_reached
        - library_limit_reached
        - redirect_quota_reached
        - code_expired
        - internal_error
      x-enum-descriptions:
        missing_api_key: 401. No `X-API-Key` header.
        invalid_api_key: 401. The key is unknown, mistyped or revoked.
        api_access_required: 403. The workspace's plan doesn't include API access.
        rate_limited: 429. The per-key or no-key IP budget is spent.
        not_found: 404. No such code in this workspace, a malformed short code, or an unknown `/v1` path.
        invalid_json: 400. The body isn't a JSON object with the documented field types.
        invalid_limit: 400. `limit` isn't an integer of 1 or more.
        invalid_offset: 400. `offset` isn't an integer of 0 or more.
        invalid_expiry: 400. `expires_at` isn't an RFC 3339 timestamp.
        invalid_name: 400. `name` is neither a string nor `null`.
        invalid_destination: 400. The destination is missing or breaks the destination rules.
        expiry_must_be_future: 400. `expires_at` isn't in the future.
        expiry_exceeds_plan_limit: 400. `expires_at` is further out than the plan allows.
        ads_not_available: 400. `has_ad` is true on a plan without an ad-supported pool.
        invalid_status: 400. Reserved; not returned by the current endpoints.
        active_limit_reached: 403. The clean active pool is full.
        ad_limit_reached: 403. The ad-supported active pool is full.
        library_limit_reached: 403. The workspace holds as many codes as the plan allows.
        redirect_quota_reached: 403. This month's redirect units are used up.
        code_expired: 409. The code is past `expires_at` and can't be activated.
        internal_error: 500. Unexpected server error.
