{
  "openapi": "3.1.0",
  "info": {
    "title": "qsy API",
    "version": "1.0.0",
    "summary": "Short links and QR codes for your own systems.",
    "description": "REST API for creating and managing qsy short links and QR codes from your own systems: built for integrations such as restaurant, POS and booking systems. JSON over HTTPS. Part of the Business plan.\n\n## Authentication\nSend your key on every request: `Authorization: Bearer qsy_live_…`. Keys are created under Account → API keys, shown once, and stored only as a SHA-256 hash. A key can expire (30, 90 or 365 days) and is revoked immediately when you revoke it. Max 20 active keys per account. Each key has scopes; a missing scope → 403.\n\n## Folder-scoped keys\nA key can be limited to one folder (for example one key per restaurant). It then sees only that folder and its subfolders: the folder appears as a top-level folder (parent_id null, paths start with its name), links elsewhere return 404, new links go into it by default, and it cannot move or delete its own folder. Styles and POST /qr are account-level.\n\n## Errors\nEvery error uses one envelope: `{\"error\": {\"code\", \"message\", \"details\"?, \"request_id\"}}`. The code is stable and always maps to the same status (see the ErrorCode schema). Messages are English. Infrastructure problems are 500/503, never 401/403/400.\n\n## Rate limits\nSliding window: 600 requests per minute per key and 1200 per minute per account. Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds) for the tighter bucket. 429 includes Retry-After.\n\n## Idempotency and external ids\nPOST /links and POST /links/bulk accept an Idempotency-Key header (24 h). Give each link your own external_id (unique per account) and use PUT /links/by-external-id/{external_id} to create-or-update without keeping our ids.\n\n## Propagation\nWrites update the edge cache that serves the short links. edge_synced: true means the change is live now; false means it will be live within seconds. A freshly created link may briefly return \"not found\" at the short URL if edge_synced is false.\n\n## Pagination\nLists are keyset-paginated: pass next_cursor as ?cursor= until it is null. Cursors are opaque.\n\n## Time zone\nStatistics dates and buckets are calendar days in Europe/Oslo. Timestamps (created_at, updated_at) are ISO 8601 in UTC.\n\n## CORS\nNone. The API is server-to-server: never put a key in a browser or app.\n\n## Request ids\nEvery response has X-Request-Id. Include it when you contact support.",
    "contact": {
      "name": "qsy support",
      "email": "support@qsy.no"
    }
  },
  "servers": [
    {
      "url": "https://api.qsy.no/v1",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Links",
      "description": "Short links with QR codes."
    },
    {
      "name": "Folders",
      "description": "Organise links; also the unit for folder-scoped keys."
    },
    {
      "name": "QR codes",
      "description": "SVG rendering for links and static QR codes."
    },
    {
      "name": "Statistics",
      "description": "Aggregated, privacy-friendly scan counts."
    },
    {
      "name": "Styles",
      "description": "Saved QR styles (read-only)."
    }
  ],
  "x-qsy-scopes": {
    "links:read": "Read links and folders, and render link QR codes.",
    "links:write": "Create, update and delete links and folders.",
    "styles:read": "Read saved styles.",
    "analytics:read": "Read scan statistics.",
    "qr:write": "Generate static QR codes (POST /qr)."
  },
  "x-qsy-limits": {
    "rate_limit_per_key_per_min": 600,
    "rate_limit_per_account_per_min": 1200,
    "max_active_api_keys": 20,
    "page_size_default": 50,
    "page_size_max": 100,
    "bulk_max_operations": 100,
    "qr_size_min": 128,
    "qr_size_max": 2048,
    "qr_size_default": 1024,
    "qr_data_max_chars": 2953,
    "title_max_chars": 200,
    "destination_url_max_chars": 4096,
    "external_id_max_chars": 128,
    "metadata_max_bytes": 4096,
    "max_folders": 50,
    "max_folder_depth": 5,
    "folder_name_max_chars": 60,
    "idempotency_ttl_hours": 24,
    "idempotency_key_max_chars": 255,
    "stats_default_window_days": 30,
    "stats_top_countries": 5
  },
  "paths": {
    "/links": {
      "get": {
        "operationId": "listLinks",
        "summary": "List links",
        "x-summary-nb": "List lenker",
        "description": "Links visible to the key, newest first (created_at desc, id desc), keyset-paginated. Folder-scoped keys only see links in their folder subtree.",
        "tags": [
          "Links"
        ],
        "x-scope": "links:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "Page size, 1-100 (default 50). Larger values are clamped to 100; 0, negative or non-numeric → 422.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "next_cursor from the previous page. A tampered or invalid cursor → 422.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "folder_id",
            "in": "query",
            "description": "Only links directly in this folder (not subfolders). \"null\" or empty → root links only.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "external_id",
            "in": "query",
            "description": "Only the link with this external_id.",
            "schema": {
              "type": "string",
              "maxLength": 128
            }
          },
          {
            "$ref": "#/components/parameters/Expand"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of links.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/LinkList"
                },
                "example": {
                  "links": [
                    {
                      "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                      "slug": "k7qm2x",
                      "short_url": "https://qsy.no/k7qm2x",
                      "qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
                      "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
                      "title": "Table 12 menu",
                      "active": true,
                      "use_shortlink": true,
                      "folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
                      "folder_path": "Bistro Nord/Tables",
                      "external_id": "pos-table-12",
                      "metadata": {
                        "table": 12,
                        "location": "grunerlokka"
                      },
                      "created_at": "2026-10-25T10:00:00.000Z",
                      "updated_at": "2026-10-25T10:00:00.000Z",
                      "edge_synced": true
                    }
                  ],
                  "next_cursor": "MjAyNi0xMC0yNVQxMDowMDowMC4wMDBafDNmNmMyYTll…"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      },
      "post": {
        "operationId": "createLink",
        "summary": "Create a link",
        "x-summary-nb": "Opprett lenke",
        "description": "Creates a short link with a QR code. Placement: folder_id, else folder (path, created as needed), else the key's scope folder (folder-scoped keys) or \"Via API\" (account-wide keys). Counts towards the active-link limit of the plan (atomic). Send an Idempotency-Key so a retry never creates a duplicate.",
        "tags": [
          "Links"
        ],
        "x-scope": "links:write",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/Expand"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateLinkRequest"
              },
              "example": {
                "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
                "title": "Table 12 menu",
                "folder": "Bistro Nord/Tables",
                "external_id": "pos-table-12",
                "metadata": {
                  "table": 12,
                  "location": "grunerlokka"
                },
                "style_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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"
              },
              "Idempotent-Replayed": {
                "$ref": "#/components/headers/Idempotent-Replayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LinkRow"
                },
                "example": {
                  "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                  "slug": "k7qm2x",
                  "short_url": "https://qsy.no/k7qm2x",
                  "qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
                  "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
                  "title": "Table 12 menu",
                  "active": true,
                  "use_shortlink": true,
                  "folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
                  "folder_path": "Bistro Nord/Tables",
                  "external_id": "pos-table-12",
                  "metadata": {
                    "table": 12,
                    "location": "grunerlokka"
                  },
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:00:00.000Z",
                  "edge_synced": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "bad_request",
          "key_expired",
          "unauthorized",
          "forbidden",
          "duplicate_external_id",
          "folder_limit_reached",
          "idempotency_key_in_use",
          "idempotency_key_reused",
          "link_limit_reached",
          "unsupported_media_type",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/links/bulk": {
      "post": {
        "operationId": "bulkLinks",
        "summary": "Create or update links in bulk",
        "x-summary-nb": "Opprett eller oppdater lenker i bulk",
        "description": "Up to 100 operations, run in order with exactly the same rules as POST /links and PATCH /links/{id}. Partial success is normal: the response is 200 and each result has ok true/false. If the request runs short of time, the remaining operations are not started and come back with error.code service_unavailable and details.reason \"not_processed\"; send those again (with a new Idempotency-Key). Supports Idempotency-Key.",
        "tags": [
          "Links"
        ],
        "x-scope": "links:write",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/Expand"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BulkRequest"
              },
              "example": {
                "operations": [
                  {
                    "destination_url": "https://bistro-nord.no/meny?t=13",
                    "external_id": "pos-table-13",
                    "folder": "Bistro Nord/Tables"
                  },
                  {
                    "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                    "destination_url": "https://bistro-nord.no/vinter-meny"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-item results (also when some items failed).",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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"
              },
              "Idempotent-Replayed": {
                "$ref": "#/components/headers/Idempotent-Replayed"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BulkResponse"
                },
                "example": {
                  "results": [
                    {
                      "ok": true,
                      "index": 0,
                      "link": {
                        "id": "5d7f9b1c-3e5a-4c7e-9f1b-3d5f7a9c1e3b",
                        "slug": "p4rt8w",
                        "short_url": "https://qsy.no/p4rt8w",
                        "qr_encoded_url": "HTTPS://QSY.NO/P4RT8W",
                        "destination_url": "https://bistro-nord.no/meny?t=13",
                        "title": null,
                        "active": true,
                        "use_shortlink": true,
                        "folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
                        "folder_path": "Bistro Nord/Tables",
                        "external_id": "pos-table-13",
                        "metadata": {},
                        "created_at": "2026-10-25T10:00:00.000Z",
                        "updated_at": "2026-10-25T10:00:00.000Z",
                        "edge_synced": true
                      }
                    },
                    {
                      "ok": false,
                      "index": 1,
                      "error": {
                        "code": "not_found",
                        "message": "No link found with this id."
                      }
                    }
                  ],
                  "summary": {
                    "total": 2,
                    "succeeded": 1,
                    "failed": 1
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "bad_request",
          "key_expired",
          "unauthorized",
          "forbidden",
          "idempotency_key_in_use",
          "idempotency_key_reused",
          "unsupported_media_type",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/links/by-external-id/{external_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ExternalId"
        }
      ],
      "get": {
        "operationId": "getLinkByExternalId",
        "summary": "Get a link by external_id",
        "x-summary-nb": "Hent lenke med external_id",
        "description": "The link with this external_id. A link outside the key's folder → 404.",
        "tags": [
          "Links"
        ],
        "x-scope": "links:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          }
        ],
        "responses": {
          "200": {
            "description": "The link.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/LinkRow"
                },
                "example": {
                  "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                  "slug": "k7qm2x",
                  "short_url": "https://qsy.no/k7qm2x",
                  "qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
                  "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
                  "title": "Table 12 menu",
                  "active": true,
                  "use_shortlink": true,
                  "folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
                  "folder_path": "Bistro Nord/Tables",
                  "external_id": "pos-table-12",
                  "metadata": {
                    "table": 12,
                    "location": "grunerlokka"
                  },
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:00:00.000Z",
                  "edge_synced": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      },
      "put": {
        "operationId": "upsertLinkByExternalId",
        "summary": "Create or update a link by external_id (upsert)",
        "x-summary-nb": "Opprett eller oppdater med external_id (upsert)",
        "description": "Missing → created with the POST /links rules (destination_url required) → 201. Exists → updated with the PATCH rules (only the fields you send change; an empty object changes nothing) → 200. external_id in the body is optional but must match the path. Naturally idempotent, so no Idempotency-Key is needed. If the id is used by a link outside a folder-scoped key's folder → 409 duplicate_external_id.",
        "tags": [
          "Links"
        ],
        "x-scope": "links:write",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateLinkRequest"
              },
              "example": {
                "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
                "title": "Table 12 menu",
                "folder": "Bistro Nord/Tables"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/LinkRow"
                },
                "example": {
                  "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                  "slug": "k7qm2x",
                  "short_url": "https://qsy.no/k7qm2x",
                  "qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
                  "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
                  "title": "Table 12 menu",
                  "active": true,
                  "use_shortlink": true,
                  "folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
                  "folder_path": "Bistro Nord/Tables",
                  "external_id": "pos-table-12",
                  "metadata": {
                    "table": 12,
                    "location": "grunerlokka"
                  },
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:00:00.000Z",
                  "edge_synced": true
                }
              }
            }
          },
          "201": {
            "description": "Created.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/LinkRow"
                },
                "example": {
                  "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                  "slug": "k7qm2x",
                  "short_url": "https://qsy.no/k7qm2x",
                  "qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
                  "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
                  "title": "Table 12 menu",
                  "active": true,
                  "use_shortlink": true,
                  "folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
                  "folder_path": "Bistro Nord/Tables",
                  "external_id": "pos-table-12",
                  "metadata": {
                    "table": 12,
                    "location": "grunerlokka"
                  },
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:00:00.000Z",
                  "edge_synced": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "bad_request",
          "key_expired",
          "unauthorized",
          "forbidden",
          "duplicate_external_id",
          "folder_limit_reached",
          "link_limit_reached",
          "unsupported_media_type",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/links/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/LinkId"
        }
      ],
      "get": {
        "operationId": "getLink",
        "summary": "Get a link",
        "x-summary-nb": "Hent lenke",
        "description": "One link. Another account's link, or one outside the key's folder → 404.",
        "tags": [
          "Links"
        ],
        "x-scope": "links:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          }
        ],
        "responses": {
          "200": {
            "description": "The link.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/LinkRow"
                },
                "example": {
                  "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                  "slug": "k7qm2x",
                  "short_url": "https://qsy.no/k7qm2x",
                  "qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
                  "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
                  "title": "Table 12 menu",
                  "active": true,
                  "use_shortlink": true,
                  "folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
                  "folder_path": "Bistro Nord/Tables",
                  "external_id": "pos-table-12",
                  "metadata": {
                    "table": 12,
                    "location": "grunerlokka"
                  },
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:00:00.000Z",
                  "edge_synced": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      },
      "patch": {
        "operationId": "updateLink",
        "summary": "Update a link",
        "x-summary-nb": "Oppdater lenke",
        "description": "Change the destination (the printed QR code keeps working), title, active, use_shortlink, style, folder, external_id or metadata. The slug never changes. Turning use_shortlink on or off changes what the QR code encodes, so printed codes must be reprinted.",
        "tags": [
          "Links"
        ],
        "x-scope": "links:write",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Expand"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateLinkRequest"
              },
              "example": {
                "destination_url": "https://bistro-nord.no/vinter-meny?utm_source=qr"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/LinkRow"
                },
                "example": {
                  "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                  "slug": "k7qm2x",
                  "short_url": "https://qsy.no/k7qm2x",
                  "qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
                  "destination_url": "https://bistro-nord.no/vinter-meny?utm_source=qr",
                  "title": "Table 12 menu",
                  "active": true,
                  "use_shortlink": true,
                  "folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
                  "folder_path": "Bistro Nord/Tables",
                  "external_id": "pos-table-12",
                  "metadata": {
                    "table": 12,
                    "location": "grunerlokka"
                  },
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:05:00.000Z",
                  "edge_synced": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "bad_request",
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "duplicate_external_id",
          "folder_limit_reached",
          "unsupported_media_type",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      },
      "delete": {
        "operationId": "deleteLink",
        "summary": "Delete a link",
        "x-summary-nb": "Slett lenke",
        "description": "Deletes the link and its scan data. The slug is retired for good: it will never be given to another link, so a printed code can never point somewhere unexpected. Use active=false instead if the code might come back.",
        "tags": [
          "Links"
        ],
        "x-scope": "links:write",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/DeleteLinkResult"
                },
                "example": {
                  "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                  "deleted": true,
                  "edge_synced": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/links/{id}/qr": {
      "parameters": [
        {
          "$ref": "#/components/parameters/LinkId"
        }
      ],
      "get": {
        "operationId": "getLinkQr",
        "summary": "Get the QR code of a link (SVG)",
        "x-summary-nb": "Hent QR-koden til en lenke (SVG)",
        "description": "Renders the link's QR code as SVG. It encodes qr_encoded_url: the short link, or the destination only for use_shortlink=false and WIFI:/vCard links. No query parameter can change what the code points to. Style overrides apply to this rendering only; nothing is stored.",
        "tags": [
          "QR codes"
        ],
        "x-scope": "links:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "description": "Only svg.",
            "schema": {
              "type": "string",
              "enum": [
                "svg"
              ],
              "default": "svg"
            }
          },
          {
            "name": "size",
            "in": "query",
            "description": "Pixel size of the SVG coordinate system, clamped to 128-2048 (default 1024; invalid → 1024).",
            "schema": {
              "type": "integer",
              "minimum": 128,
              "maximum": 2048,
              "default": 1024
            }
          },
          {
            "name": "style_id",
            "in": "query",
            "description": "Render with a saved style instead of the link's own.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "qr_config",
            "in": "query",
            "description": "URL-encoded JSON QrConfig, full or partial (merged over the defaults). Wins over style_id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The QR code. Cache-Control: private, no-store.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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": {
              "image/svg+xml": {
                "schema": {
                  "type": "string"
                },
                "example": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1024\" height=\"1024\" viewBox=\"0 0 1024 1024\">…</svg>"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/links/{id}/stats": {
      "parameters": [
        {
          "$ref": "#/components/parameters/LinkId"
        }
      ],
      "get": {
        "operationId": "getLinkStats",
        "summary": "Get scan statistics for a link",
        "x-summary-nb": "Hent skann-statistikk for en lenke",
        "description": "Aggregated scans for one link. Dates are calendar days in Europe/Oslo. Counters only: no IP addresses, no cities, no personal data. If analytics are unavailable the API answers 503 (never zeros).",
        "tags": [
          "Statistics"
        ],
        "x-scope": "analytics:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "description": "YYYY-MM-DD (Europe/Oslo). Default: 29 days before today (a 30-day window).",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "example": "2026-10-19"
          },
          {
            "name": "to",
            "in": "query",
            "description": "YYYY-MM-DD (Europe/Oslo), inclusive. Default: today. Must not be before from, and the window can be at most 1096 days.",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "example": "2026-10-25"
          }
        ],
        "responses": {
          "200": {
            "description": "Statistics.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/Stats"
                },
                "example": {
                  "link_id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
                  "from": "2026-10-19",
                  "to": "2026-10-25",
                  "granularity": "day",
                  "total": 412,
                  "all_time": 3871,
                  "series": [
                    {
                      "bucket": "2026-10-19",
                      "count": 48
                    },
                    {
                      "bucket": "2026-10-20",
                      "count": 51
                    },
                    {
                      "bucket": "2026-10-21",
                      "count": 44
                    },
                    {
                      "bucket": "2026-10-22",
                      "count": 57
                    },
                    {
                      "bucket": "2026-10-23",
                      "count": 79
                    },
                    {
                      "bucket": "2026-10-24",
                      "count": 91
                    },
                    {
                      "bucket": "2026-10-25",
                      "count": 42
                    }
                  ],
                  "breakdowns": {
                    "device": {
                      "mobile": 398,
                      "desktop": 6,
                      "tablet": 8,
                      "unknown": 0
                    },
                    "source": {
                      "qr": 401,
                      "link": 11,
                      "unknown": 0
                    },
                    "country": [
                      {
                        "country": "NO",
                        "count": 371
                      },
                      {
                        "country": "SE",
                        "count": 22
                      },
                      {
                        "country": "DE",
                        "count": 19
                      }
                    ],
                    "daypart": {
                      "night": 9,
                      "morning": 61,
                      "day": 158,
                      "evening": 184
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/folders": {
      "get": {
        "operationId": "listFolders",
        "summary": "List folders",
        "x-summary-nb": "List mapper",
        "description": "All folders visible to the key as a flat list sorted by path. A folder-scoped key sees its folder as a top-level folder (parent_id null).",
        "tags": [
          "Folders"
        ],
        "x-scope": "links:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Folders.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/FolderList"
                },
                "example": {
                  "folders": [
                    {
                      "id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
                      "name": "Bistro Nord",
                      "parent_id": null,
                      "path": "Bistro Nord",
                      "link_count": 2,
                      "subtree_link_count": 14,
                      "created_at": "2026-10-25T10:00:00.000Z",
                      "updated_at": "2026-10-25T10:00:00.000Z"
                    },
                    {
                      "id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
                      "name": "Tables",
                      "parent_id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
                      "path": "Bistro Nord/Tables",
                      "link_count": 12,
                      "subtree_link_count": 12,
                      "created_at": "2026-10-25T10:00:00.000Z",
                      "updated_at": "2026-10-25T10:00:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      },
      "post": {
        "operationId": "createFolder",
        "summary": "Create a folder",
        "x-summary-nb": "Opprett mappe",
        "description": "Max 50 folders per account, nested at most 5 levels. A sibling with the same name (case-insensitive) → 409 folder_exists with its id in details.",
        "tags": [
          "Folders"
        ],
        "x-scope": "links:write",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateFolderRequest"
              },
              "example": {
                "name": "Bistro Nord"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/Folder"
                },
                "example": {
                  "id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
                  "name": "Bistro Nord",
                  "parent_id": null,
                  "path": "Bistro Nord",
                  "link_count": 0,
                  "subtree_link_count": 0,
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:00:00.000Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "bad_request",
          "key_expired",
          "unauthorized",
          "forbidden",
          "folder_exists",
          "folder_limit_reached",
          "unsupported_media_type",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/folders/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/FolderId"
        }
      ],
      "get": {
        "operationId": "getFolder",
        "summary": "Get a folder",
        "x-summary-nb": "Hent mappe",
        "description": "One folder with link counts.",
        "tags": [
          "Folders"
        ],
        "x-scope": "links:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The folder.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/Folder"
                },
                "example": {
                  "id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
                  "name": "Bistro Nord",
                  "parent_id": null,
                  "path": "Bistro Nord",
                  "link_count": 2,
                  "subtree_link_count": 14,
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:00:00.000Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      },
      "patch": {
        "operationId": "updateFolder",
        "summary": "Rename or move a folder",
        "x-summary-nb": "Gi nytt navn til eller flytt mappe",
        "description": "Rename and/or move. A folder-scoped key can rename its own folder but not move it (403).",
        "tags": [
          "Folders"
        ],
        "x-scope": "links:write",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateFolderRequest"
              },
              "example": {
                "name": "Bistro Nord Grünerløkka"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/Folder"
                },
                "example": {
                  "id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
                  "name": "Bistro Nord Grünerløkka",
                  "parent_id": null,
                  "path": "Bistro Nord Grünerløkka",
                  "link_count": 2,
                  "subtree_link_count": 14,
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:00:00.000Z"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "bad_request",
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "folder_exists",
          "unsupported_media_type",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      },
      "delete": {
        "operationId": "deleteFolder",
        "summary": "Delete a folder",
        "x-summary-nb": "Slett mappe",
        "description": "An empty folder is deleted. A folder with subfolders or links → 409 folder_not_empty with counts, unless ?force=true: then its contents move up one level and no link is ever deleted. API keys limited to the deleted folder are revoked. A folder-scoped key cannot delete its own folder (403).",
        "tags": [
          "Folders"
        ],
        "x-scope": "links:write",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "force",
            "in": "query",
            "description": "\"true\" moves contents up one level instead of refusing.",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ],
              "default": "false"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/DeleteFolderResult"
                },
                "example": {
                  "id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
                  "deleted": true,
                  "contents_moved_to": null
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "folder_not_empty",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/qr": {
      "post": {
        "operationId": "createStaticQr",
        "summary": "Generate a static QR code (SVG)",
        "x-summary-nb": "Lag statisk QR-kode (SVG)",
        "description": "Encodes data literally: no short link, no tracking, nothing stored. For labels, WiFi and contact cards that must work without a redirect. The code can never be changed after printing.",
        "tags": [
          "QR codes"
        ],
        "x-scope": "qr:write",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StaticQrRequest"
              },
              "example": {
                "data": "WIFI:T:WPA;S:BistroNord-Guest;P:velkommen2026;;",
                "type": "wifi",
                "style_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
                "size": 1024
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The QR code. Cache-Control: private, no-store.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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": {
              "image/svg+xml": {
                "schema": {
                  "type": "string"
                },
                "example": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1024\" height=\"1024\" viewBox=\"0 0 1024 1024\">…</svg>"
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "bad_request",
          "key_expired",
          "unauthorized",
          "forbidden",
          "unsupported_media_type",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/styles": {
      "get": {
        "operationId": "listStyles",
        "summary": "List styles",
        "x-summary-nb": "List stiler",
        "description": "Saved QR styles on the account, newest first. Styles are made in the qsy studio; the API only reads them. Account-level: not limited by folder scope.",
        "tags": [
          "Styles"
        ],
        "x-scope": "styles:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Styles.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/StyleList"
                },
                "example": {
                  "styles": [
                    {
                      "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
                      "name": "Bistro Nord",
                      "qr_config": {
                        "mode": "styled",
                        "shape": "square",
                        "errorCorrectionLevel": "M",
                        "margin": 0,
                        "dots": {
                          "type": "extra-rounded",
                          "color": "#1A1A1A",
                          "gradient": null
                        },
                        "cornersSquare": {
                          "type": "extra-rounded",
                          "color": "#1A1A1A",
                          "gradient": null
                        },
                        "cornersDot": {
                          "type": "dot",
                          "color": "#C0392B",
                          "gradient": null
                        },
                        "background": {
                          "color": "#FAFAF7",
                          "transparent": false,
                          "gradient": null,
                          "round": 0
                        },
                        "image": null,
                        "imageOptions": {
                          "imageSize": 0.4,
                          "margin": 2,
                          "mode": "center"
                        }
                      },
                      "created_at": "2026-10-25T10:00:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/styles/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/StyleId"
        }
      ],
      "get": {
        "operationId": "getStyle",
        "summary": "Get a style",
        "x-summary-nb": "Hent stil",
        "description": "One saved style.",
        "tags": [
          "Styles"
        ],
        "x-scope": "styles:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The style.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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/StyleDetail"
                },
                "example": {
                  "id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
                  "name": "Bistro Nord",
                  "qr_config": {
                    "mode": "styled",
                    "shape": "square",
                    "errorCorrectionLevel": "M",
                    "margin": 0,
                    "dots": {
                      "type": "extra-rounded",
                      "color": "#1A1A1A",
                      "gradient": null
                    },
                    "cornersSquare": {
                      "type": "extra-rounded",
                      "color": "#1A1A1A",
                      "gradient": null
                    },
                    "cornersDot": {
                      "type": "dot",
                      "color": "#C0392B",
                      "gradient": null
                    },
                    "background": {
                      "color": "#FAFAF7",
                      "transparent": false,
                      "gradient": null,
                      "round": 0
                    },
                    "image": null,
                    "imageOptions": {
                      "imageSize": 0.4,
                      "margin": 2,
                      "mode": "center"
                    }
                  },
                  "created_at": "2026-10-25T10:00:00.000Z",
                  "updated_at": "2026-10-25T10:05:00.000Z"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    },
    "/styles/{id}/preview": {
      "get": {
        "operationId": "getStylePreview",
        "summary": "Preview a style (SVG)",
        "x-summary-nb": "Forhåndsvis en stil (SVG)",
        "description": "Renders a saved style as an SVG preview for style pickers. The code in the image always encodes the fixed sample URL https://qsy.no; no parameter can change what it encodes. Use id \"default\" for the house default style. To get the code of a real link, use GET /links/{id}/qr.",
        "tags": [
          "Styles"
        ],
        "x-scope": "styles:read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "Style id (UUID), or \"default\".",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "format",
            "in": "query",
            "description": "Only svg.",
            "schema": {
              "type": "string",
              "enum": [
                "svg"
              ],
              "default": "svg"
            }
          },
          {
            "name": "size",
            "in": "query",
            "description": "Pixel size of the SVG coordinate system, clamped to 128-1024 (default 512; invalid → 512).",
            "schema": {
              "type": "integer",
              "minimum": 128,
              "maximum": 1024,
              "default": 512
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The preview. Cache-Control: private, no-store.",
            "headers": {
              "X-Request-Id": {
                "$ref": "#/components/headers/X-Request-Id"
              },
              "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": {
              "image/svg+xml": {
                "schema": {
                  "type": "string"
                },
                "example": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"512\" height=\"512\" viewBox=\"0 0 512 512\">…</svg>"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        },
        "x-error-codes": [
          "key_expired",
          "unauthorized",
          "forbidden",
          "not_found",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "qsy_live_ + 32 base62 characters",
        "description": "Authorization: Bearer qsy_live_…"
      }
    },
    "schemas": {
      "ErrorCode": {
        "type": "string",
        "description": "Stable, machine-readable error code. Each code always maps to the same HTTP status (x-status).",
        "enum": [
          "bad_request",
          "unauthorized",
          "key_expired",
          "forbidden",
          "not_found",
          "conflict",
          "link_limit_reached",
          "folder_limit_reached",
          "folder_exists",
          "folder_not_empty",
          "duplicate_external_id",
          "idempotency_key_reused",
          "idempotency_key_in_use",
          "unsupported_media_type",
          "validation_error",
          "rate_limited",
          "internal_error",
          "service_unavailable"
        ],
        "x-status": {
          "bad_request": 400,
          "unauthorized": 401,
          "key_expired": 401,
          "forbidden": 403,
          "not_found": 404,
          "conflict": 409,
          "link_limit_reached": 409,
          "folder_limit_reached": 409,
          "folder_exists": 409,
          "folder_not_empty": 409,
          "duplicate_external_id": 409,
          "idempotency_key_reused": 409,
          "idempotency_key_in_use": 409,
          "unsupported_media_type": 415,
          "validation_error": 422,
          "rate_limited": 429,
          "internal_error": 500,
          "service_unavailable": 503
        }
      },
      "FieldIssue": {
        "type": "object",
        "required": [
          "field",
          "code",
          "message"
        ],
        "additionalProperties": false,
        "properties": {
          "field": {
            "type": "string",
            "description": "Dot path of the offending field, e.g. \"qr_config.dots.color\" or \"cursor\"."
          },
          "code": {
            "type": "string",
            "description": "Issue code, e.g. invalid_value, too_long, unrecognized_key, unknown_folder."
          },
          "message": {
            "type": "string"
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "additionalProperties": false,
        "description": "Every error (any 4xx/5xx) uses this envelope, including errors on the SVG endpoints.",
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message",
              "request_id"
            ],
            "additionalProperties": false,
            "properties": {
              "code": {
                "$ref": "#/components/schemas/ErrorCode"
              },
              "message": {
                "type": "string",
                "description": "English, safe to show a developer. Never database or library text."
              },
              "details": {
                "description": "Optional. validation_error → FieldIssue[]; link_limit_reached / folder_limit_reached → {limit, used}; folder_exists → {id}; folder_not_empty → {folders, links}; forbidden → {reason: plan_required | missing_scope | scope_root, required_scope?}."
              },
              "request_id": {
                "type": "string",
                "pattern": "^req_[0-9a-f]{24}$",
                "description": "Same value as the X-Request-Id header."
              }
            }
          }
        }
      },
      "QrConfig": {
        "type": "object",
        "description": "A QR style as produced by the qsy studio. The easiest way to get one is GET /styles or GET /links/{id}?expand=qr_config. When sent on a link or to POST /qr the object must be complete; GET /links/{id}/qr also accepts a partial object that is merged over the defaults.",
        "required": [
          "mode",
          "shape",
          "errorCorrectionLevel",
          "dots",
          "cornersSquare",
          "cornersDot",
          "background"
        ],
        "properties": {
          "mode": {
            "type": "string",
            "enum": [
              "styled",
              "ai"
            ]
          },
          "shape": {
            "type": "string",
            "enum": [
              "square",
              "circle"
            ]
          },
          "errorCorrectionLevel": {
            "type": "string",
            "enum": [
              "L",
              "M",
              "Q",
              "H"
            ]
          },
          "margin": {
            "type": "number"
          },
          "dots": {
            "type": "object"
          },
          "cornersSquare": {
            "type": "object"
          },
          "cornersDot": {
            "type": "object"
          },
          "background": {
            "type": "object"
          },
          "image": {
            "type": [
              "string",
              "null"
            ]
          },
          "imageOptions": {
            "type": "object"
          }
        },
        "additionalProperties": true,
        "example": {
          "mode": "styled",
          "shape": "square",
          "errorCorrectionLevel": "M",
          "margin": 0,
          "dots": {
            "type": "extra-rounded",
            "color": "#1A1A1A",
            "gradient": null
          },
          "cornersSquare": {
            "type": "extra-rounded",
            "color": "#1A1A1A",
            "gradient": null
          },
          "cornersDot": {
            "type": "dot",
            "color": "#C0392B",
            "gradient": null
          },
          "background": {
            "color": "#FAFAF7",
            "transparent": false,
            "gradient": null,
            "round": 0
          },
          "image": null,
          "imageOptions": {
            "imageSize": 0.4,
            "margin": 2,
            "mode": "center"
          }
        }
      },
      "LinkRow": {
        "type": "object",
        "description": "The one link shape returned by every endpoint.",
        "required": [
          "id",
          "slug",
          "short_url",
          "qr_encoded_url",
          "destination_url",
          "title",
          "active",
          "use_shortlink",
          "folder_id",
          "folder_path",
          "external_id",
          "metadata",
          "created_at",
          "updated_at",
          "edge_synced"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "slug": {
            "type": "string",
            "description": "Permanent. Never changes and is never reused, even after the link is deleted."
          },
          "short_url": {
            "type": "string",
            "format": "uri"
          },
          "qr_encoded_url": {
            "type": "string",
            "description": "Exactly what the QR code encodes: the short link (upper case, for a smaller code) or, for use_shortlink=false and WIFI:/vCard links, the destination itself."
          },
          "destination_url": {
            "type": "string"
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "active": {
            "type": "boolean"
          },
          "use_shortlink": {
            "type": "boolean"
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "folder_path": {
            "type": [
              "string",
              "null"
            ],
            "description": "Folder path as seen by the key (scoped keys see paths from their folder). null for root links."
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "edge_synced": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Writes: true when the edge (redirect) cache was updated in this request, false when it will catch up shortly (normally within seconds). Reads: always null."
          },
          "qr_config": {
            "$ref": "#/components/schemas/QrConfig",
            "description": "Only present with ?expand=qr_config."
          }
        },
        "example": {
          "id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
          "slug": "k7qm2x",
          "short_url": "https://qsy.no/k7qm2x",
          "qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
          "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
          "title": "Table 12 menu",
          "active": true,
          "use_shortlink": true,
          "folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
          "folder_path": "Bistro Nord/Tables",
          "external_id": "pos-table-12",
          "metadata": {
            "table": 12,
            "location": "grunerlokka"
          },
          "created_at": "2026-10-25T10:00:00.000Z",
          "updated_at": "2026-10-25T10:00:00.000Z",
          "edge_synced": true
        }
      },
      "LinkList": {
        "type": "object",
        "required": [
          "links",
          "next_cursor"
        ],
        "additionalProperties": false,
        "properties": {
          "links": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LinkRow"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Opaque. Pass as ?cursor= to get the next page; null on the last page."
          }
        }
      },
      "CreateLinkRequest": {
        "type": "object",
        "required": [
          "destination_url"
        ],
        "additionalProperties": false,
        "description": "Unknown fields are rejected (422, one issue per field).",
        "properties": {
          "destination_url": {
            "type": "string",
            "maxLength": 4096,
            "description": "http(s) URL (a bare domain gets https://), or a mailto:, tel:, sms:, WIFI: or vCard destination. Checked against Google Safe Browsing. UTM goes straight into the URL and is stored as sent. On redirect, qsy.no rewrites utm_medium=qr (case-insensitive) to utm_medium=qsy.no-shortlink when the shared short link is opened (lowercase path, no ?src=qr); QR scans keep qr, other media and parameters are never changed, and utm_medium is never added."
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Human-readable name. Trimmed; empty → null."
          },
          "active": {
            "type": "boolean",
            "description": "Inactive links stop redirecting. Only active links count towards the plan limit."
          },
          "use_shortlink": {
            "type": "boolean",
            "description": "true (default): the QR code encodes the short link, so the destination can be changed after printing. false: the QR code encodes destination_url directly (no redirect, no scan statistics). WIFI: and vCard destinations are always encoded directly."
          },
          "qr_config": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/QrConfig"
              },
              {
                "type": "null"
              }
            ],
            "description": "Full QR style. Wins over style_id. Stored on the link (replaced as a whole on update)."
          },
          "style_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Copy a saved style from your account into the link. Input only (copy model): later edits to the style do not change existing links, and style_id is never returned."
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Target folder. Must be visible to the key. On update, null moves the link to the account root (account-wide keys only)."
          },
          "folder": {
            "type": [
              "string",
              "null"
            ],
            "description": "Folder path such as \"Bistro Nord/Tables\". Each level is found (case-insensitive) or created, relative to the key's root. Max 5 levels, each 1-60 characters. Send folder_id or folder, not both.",
            "examples": [
              "Bistro Nord/Tables"
            ]
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 128,
            "description": "Your own identifier (POS table id, product id …). Unique per account. On update, null clears it."
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true,
            "description": "Free-form JSON object, at most 4096 bytes as compact JSON. Replaced as a whole on update (send {} to clear)."
          }
        }
      },
      "UpdateLinkRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "Only the fields you send change. At least one field is required (otherwise 400). The slug can never change.",
        "properties": {
          "destination_url": {
            "type": "string",
            "maxLength": 4096,
            "description": "http(s) URL, or a mailto:, tel:, sms:, WIFI: or vCard destination. UTM goes straight into the URL and is stored as sent. On redirect, qsy.no rewrites utm_medium=qr (case-insensitive) to utm_medium=qsy.no-shortlink when the shared short link is opened (lowercase path, no ?src=qr); QR scans keep qr, other media and parameters are never changed, and utm_medium is never added."
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "description": "Human-readable name. Trimmed; empty → null."
          },
          "active": {
            "type": "boolean",
            "description": "Inactive links stop redirecting. Only active links count towards the plan limit."
          },
          "use_shortlink": {
            "type": "boolean",
            "description": "true (default): the QR code encodes the short link, so the destination can be changed after printing. false: the QR code encodes destination_url directly (no redirect, no scan statistics). WIFI: and vCard destinations are always encoded directly."
          },
          "qr_config": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/QrConfig"
              },
              {
                "type": "null"
              }
            ],
            "description": "Full QR style. Wins over style_id. Stored on the link (replaced as a whole on update)."
          },
          "style_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Copy a saved style from your account into the link. Input only (copy model): later edits to the style do not change existing links, and style_id is never returned."
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Target folder. Must be visible to the key. On update, null moves the link to the account root (account-wide keys only)."
          },
          "folder": {
            "type": [
              "string",
              "null"
            ],
            "description": "Folder path such as \"Bistro Nord/Tables\". Each level is found (case-insensitive) or created, relative to the key's root. Max 5 levels, each 1-60 characters. Send folder_id or folder, not both.",
            "examples": [
              "Bistro Nord/Tables"
            ]
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ],
            "minLength": 1,
            "maxLength": 128,
            "description": "Your own identifier (POS table id, product id …). Unique per account. On update, null clears it."
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true,
            "description": "Free-form JSON object, at most 4096 bytes as compact JSON. Replaced as a whole on update (send {} to clear)."
          }
        }
      },
      "BulkUpdateItem": {
        "allOf": [
          {
            "$ref": "#/components/schemas/UpdateLinkRequest"
          },
          {
            "type": "object",
            "required": [
              "id"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              }
            }
          }
        ],
        "description": "An operation with an id is an update (same fields as PATCH /links/{id})."
      },
      "BulkRequest": {
        "type": "object",
        "required": [
          "operations"
        ],
        "additionalProperties": false,
        "properties": {
          "operations": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "oneOf": [
                {
                  "$ref": "#/components/schemas/CreateLinkRequest"
                },
                {
                  "$ref": "#/components/schemas/BulkUpdateItem"
                }
              ]
            },
            "description": "Without id → create (same rules as POST /links). With id → update (same rules as PATCH /links/{id}). Run in order."
          }
        }
      },
      "BulkItemResult": {
        "oneOf": [
          {
            "type": "object",
            "required": [
              "ok",
              "index",
              "link"
            ],
            "additionalProperties": false,
            "properties": {
              "ok": {
                "const": true
              },
              "index": {
                "type": "integer"
              },
              "link": {
                "$ref": "#/components/schemas/LinkRow"
              }
            }
          },
          {
            "type": "object",
            "required": [
              "ok",
              "index",
              "error"
            ],
            "additionalProperties": false,
            "properties": {
              "ok": {
                "const": false
              },
              "index": {
                "type": "integer"
              },
              "error": {
                "type": "object",
                "required": [
                  "code",
                  "message"
                ],
                "properties": {
                  "code": {
                    "$ref": "#/components/schemas/ErrorCode"
                  },
                  "message": {
                    "type": "string"
                  },
                  "details": {}
                }
              }
            }
          }
        ]
      },
      "BulkResponse": {
        "type": "object",
        "required": [
          "results",
          "summary"
        ],
        "additionalProperties": false,
        "properties": {
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BulkItemResult"
            }
          },
          "summary": {
            "type": "object",
            "required": [
              "total",
              "succeeded",
              "failed"
            ],
            "properties": {
              "total": {
                "type": "integer"
              },
              "succeeded": {
                "type": "integer"
              },
              "failed": {
                "type": "integer"
              }
            }
          }
        }
      },
      "DeleteLinkResult": {
        "type": "object",
        "required": [
          "id",
          "deleted",
          "edge_synced"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "deleted": {
            "const": true
          },
          "edge_synced": {
            "type": "boolean",
            "description": "false → the short link stops resolving within seconds instead of immediately."
          }
        }
      },
      "Folder": {
        "type": "object",
        "required": [
          "id",
          "name",
          "parent_id",
          "path",
          "link_count",
          "subtree_link_count",
          "created_at",
          "updated_at"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "null for top-level folders (and for the scope folder of a folder-scoped key)."
          },
          "path": {
            "type": "string",
            "examples": [
              "Bistro Nord/Tables"
            ]
          },
          "link_count": {
            "type": "integer",
            "description": "Links directly in this folder."
          },
          "subtree_link_count": {
            "type": "integer",
            "description": "Links in this folder and all its subfolders."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
          "name": "Bistro Nord",
          "parent_id": null,
          "path": "Bistro Nord",
          "link_count": 2,
          "subtree_link_count": 14,
          "created_at": "2026-10-25T10:00:00.000Z",
          "updated_at": "2026-10-25T10:00:00.000Z"
        }
      },
      "FolderList": {
        "type": "object",
        "required": [
          "folders"
        ],
        "additionalProperties": false,
        "properties": {
          "folders": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Folder"
            }
          }
        }
      },
      "CreateFolderRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60,
            "description": "Trimmed. Must not contain \"/\". Unique among siblings (case-insensitive)."
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Omitted or null → the key's root."
          }
        }
      },
      "UpdateFolderRequest": {
        "type": "object",
        "additionalProperties": false,
        "description": "At least one of name and parent_id.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Move the folder. null → the key's root. Cannot move into itself or a subfolder."
          }
        }
      },
      "DeleteFolderResult": {
        "type": "object",
        "required": [
          "id",
          "deleted",
          "contents_moved_to"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "deleted": {
            "const": true
          },
          "contents_moved_to": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Where subfolders and links went (the parent, or null for the root)."
          }
        }
      },
      "Style": {
        "type": "object",
        "required": [
          "id",
          "name",
          "qr_config",
          "created_at"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "qr_config": {
            "$ref": "#/components/schemas/QrConfig"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "StyleDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Style"
          },
          {
            "type": "object",
            "required": [
              "updated_at"
            ],
            "properties": {
              "updated_at": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        ]
      },
      "StyleList": {
        "type": "object",
        "required": [
          "styles"
        ],
        "additionalProperties": false,
        "properties": {
          "styles": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Style"
            }
          }
        }
      },
      "Stats": {
        "type": "object",
        "required": [
          "link_id",
          "from",
          "to",
          "granularity",
          "total",
          "all_time",
          "series",
          "breakdowns"
        ],
        "additionalProperties": false,
        "properties": {
          "link_id": {
            "type": "string",
            "format": "uuid"
          },
          "from": {
            "type": "string",
            "format": "date"
          },
          "to": {
            "type": "string",
            "format": "date"
          },
          "granularity": {
            "type": "string",
            "enum": [
              "day",
              "week",
              "month"
            ],
            "description": "Chosen from the range: up to 31 days → day, up to 92 days → week, longer → month."
          },
          "total": {
            "type": "integer",
            "description": "Scans in the range."
          },
          "all_time": {
            "type": "integer",
            "description": "Scans since the link was created."
          },
          "series": {
            "type": "array",
            "description": "One entry per bucket, gap-filled with zeros. bucket is YYYY-MM-DD (the day, the Monday of the week, or the first of the month), in Europe/Oslo.",
            "items": {
              "type": "object",
              "required": [
                "bucket",
                "count"
              ],
              "properties": {
                "bucket": {
                  "type": "string",
                  "format": "date"
                },
                "count": {
                  "type": "integer"
                }
              }
            }
          },
          "breakdowns": {
            "type": "object",
            "required": [
              "device",
              "source",
              "country",
              "daypart"
            ],
            "properties": {
              "device": {
                "type": "object",
                "required": [
                  "mobile",
                  "desktop",
                  "tablet",
                  "unknown"
                ],
                "properties": {
                  "mobile": {
                    "type": "integer"
                  },
                  "desktop": {
                    "type": "integer"
                  },
                  "tablet": {
                    "type": "integer"
                  },
                  "unknown": {
                    "type": "integer"
                  }
                }
              },
              "source": {
                "type": "object",
                "required": [
                  "qr",
                  "link",
                  "unknown"
                ],
                "properties": {
                  "qr": {
                    "type": "integer"
                  },
                  "link": {
                    "type": "integer"
                  },
                  "unknown": {
                    "type": "integer"
                  }
                }
              },
              "country": {
                "type": "array",
                "maxItems": 5,
                "description": "Top 5 countries (ISO 3166-1 alpha-2), k-anonymised. Cities are never returned by the API.",
                "items": {
                  "type": "object",
                  "required": [
                    "country",
                    "count"
                  ],
                  "properties": {
                    "country": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    }
                  }
                }
              },
              "daypart": {
                "type": "object",
                "description": "Europe/Oslo local time: night 00-06, morning 06-11, day 11-17, evening 17-24.",
                "required": [
                  "night",
                  "morning",
                  "day",
                  "evening"
                ],
                "properties": {
                  "night": {
                    "type": "integer"
                  },
                  "morning": {
                    "type": "integer"
                  },
                  "day": {
                    "type": "integer"
                  },
                  "evening": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        }
      },
      "StaticQrRequest": {
        "type": "object",
        "required": [
          "data",
          "type"
        ],
        "description": "Unrecognised fields are ignored.",
        "properties": {
          "data": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2953,
            "description": "Encoded literally. You build the WIFI:, BEGIN:VCARD or geo: string yourself."
          },
          "type": {
            "type": "string",
            "enum": [
              "url",
              "wifi",
              "vcard",
              "text",
              "geo"
            ],
            "description": "Documents the content; it does not change what is encoded."
          },
          "qr_config": {
            "$ref": "#/components/schemas/QrConfig",
            "description": "Full style. Wins over style_id."
          },
          "style_id": {
            "type": "string",
            "format": "uuid",
            "description": "A saved style on your account."
          },
          "format": {
            "type": "string",
            "enum": [
              "svg"
            ],
            "default": "svg",
            "description": "Only SVG. Rasterise it yourself if you need PNG."
          },
          "size": {
            "type": "integer",
            "minimum": 128,
            "maximum": 2048,
            "default": 1024,
            "description": "Out-of-range values are clamped, invalid values fall back to 1024."
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "bad_request: empty or invalid JSON, a body that is not an object, an invalid Idempotency-Key, or an update with nothing to change.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "bad_request",
                "message": "The request body is not valid JSON.",
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "unauthorized: missing, malformed, unknown or revoked key. key_expired: the key has passed its expiry date.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          },
          "WWW-Authenticate": {
            "$ref": "#/components/headers/WWW-Authenticate"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "unauthorized",
                "message": "Missing API key. Send it as \"Authorization: Bearer qsy_live_…\".",
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "forbidden: the account has no API access (details.reason = plan_required), the key lacks the scope (missing_scope), or a folder-scoped key tried to move or delete its own folder (scope_root).",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          },
          "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"
            },
            "example": {
              "error": {
                "code": "forbidden",
                "message": "This API key is missing the required scope \"links:write\".",
                "details": {
                  "reason": "missing_scope",
                  "required_scope": "links:write"
                },
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "not_found: the resource does not exist, belongs to another account, or is outside the key's folder. These cases are indistinguishable on purpose.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          },
          "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"
            },
            "example": {
              "error": {
                "code": "not_found",
                "message": "No link found with this id.",
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      },
      "Conflict": {
        "description": "One of the 409 codes listed in x-error-codes for this operation (link_limit_reached, folder_limit_reached, folder_exists, folder_not_empty, duplicate_external_id, idempotency_key_reused, idempotency_key_in_use, conflict).",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          },
          "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"
            },
            "example": {
              "error": {
                "code": "link_limit_reached",
                "message": "You have reached the limit of 500 active links on your plan. Deactivate or delete a link to create more.",
                "details": {
                  "limit": 500,
                  "used": 500
                },
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      },
      "UnsupportedMediaType": {
        "description": "unsupported_media_type: the body must be sent with Content-Type: application/json.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          },
          "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"
            },
            "example": {
              "error": {
                "code": "unsupported_media_type",
                "message": "Content-Type must be application/json.",
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      },
      "ValidationError": {
        "description": "validation_error: details lists every invalid field.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          },
          "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"
            },
            "example": {
              "error": {
                "code": "validation_error",
                "message": "The request contains invalid fields.",
                "details": [
                  {
                    "field": "external_id",
                    "code": "too_big",
                    "message": "external_id must be 1-128 characters."
                  }
                ],
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "rate_limited: wait Retry-After seconds.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          },
          "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"
          },
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "rate_limited",
                "message": "Too many requests. Slow down and retry after the indicated delay.",
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      },
      "InternalError": {
        "description": "internal_error: our fault. Retry; contact support with the request_id if it persists.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "internal_error",
                "message": "An unexpected error occurred. Please try again; contact support with the request id if it persists.",
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "service_unavailable: a dependency (database, rate limiter, analytics) is temporarily down. Retry after Retry-After seconds. Never returned as 401/403.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/X-Request-Id"
          },
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "service_unavailable",
                "message": "The service is temporarily unavailable. Please retry shortly.",
                "request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
              }
            }
          }
        }
      }
    },
    "parameters": {
      "LinkId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Link id. A value that is not a UUID → 404.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f"
      },
      "FolderId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Folder id. A value that is not a UUID → 404.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c"
      },
      "StyleId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "Style id. A value that is not a UUID → 404.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
      },
      "ExternalId": {
        "name": "external_id",
        "in": "path",
        "required": true,
        "description": "Your identifier, percent-encoded. Avoid \"/\" in external ids.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 128
        },
        "example": "pos-table-12"
      },
      "Expand": {
        "name": "expand",
        "in": "query",
        "required": false,
        "description": "Comma-separated. Only qr_config is supported; unknown values → 422.",
        "schema": {
          "type": "string",
          "enum": [
            "qr_config"
          ]
        }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "1-255 visible ASCII characters (use a UUID per logical operation). Same key + same body within 24 h → the stored response is replayed with Idempotent-Replayed: true. Same key + different body → 409 idempotency_key_reused; still running → 409 idempotency_key_in_use. Keys are scoped to the API key.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 255
        },
        "example": "0e6c4b8a-2f1d-4d7e-9b3a-5c8f1e2d4a6b"
      }
    },
    "headers": {
      "X-Request-Id": {
        "description": "Unique id for this request (req_ + 24 hex). Also in every error body. Quote it to support.",
        "schema": {
          "type": "string"
        }
      },
      "X-RateLimit-Limit": {
        "description": "Requests allowed per minute in the tighter of the two buckets (per key, per account).",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Remaining": {
        "description": "Requests left in that bucket.",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Reset": {
        "description": "Unix time (seconds) when the bucket is full again.",
        "schema": {
          "type": "integer"
        }
      },
      "Retry-After": {
        "description": "Seconds to wait before retrying (429 and 503).",
        "schema": {
          "type": "integer"
        }
      },
      "Idempotent-Replayed": {
        "description": "\"true\" when the response is a replay of an earlier request with the same Idempotency-Key.",
        "schema": {
          "type": "string",
          "enum": [
            "true"
          ]
        }
      },
      "WWW-Authenticate": {
        "description": "Always \"Bearer\" on 401.",
        "schema": {
          "type": "string"
        }
      }
    }
  }
}