{
  "openapi": "3.1.0",
  "info": {
    "title": "QRchive public API",
    "version": "1.0.0",
    "summary": "Add files to a QRchive archive and read its public state.",
    "description": "QRchive stores permanent document archives that open by scanning a sticker.\n\nEverything described here can be done by a client that holds only a sticker's\npublic address — no sign-in, no keys. The address /docs/QR-123456-AB7CD carries\nboth values these calls need: the code (QR-123456) and its token (AB7CD).\n\n$49 CAD per archive, one time. Payment is always completed by a person in a\nbrowser; nothing here can buy anything.\n\nOwner-only operations — editing an archive's written details, creating\nsections, unlocking, ordering rolls — need a browser session and are\ndeliberately not described in this document.\n\nPlain-language guide: https://qrchive.ca/agents",
    "contact": {
      "name": "QRchive support",
      "email": "support@qrchive.ca"
    },
    "license": {
      "name": "Terms of service",
      "url": "https://qrchive.ca/terms"
    }
  },
  "servers": [
    {
      "url": "https://qrchive.ca"
    }
  ],
  "tags": [
    {
      "name": "upload",
      "description": "Add a file to an archive."
    },
    {
      "name": "archive",
      "description": "Read an archive's public state."
    }
  ],
  "paths": {
    "/api/upload/request": {
      "post": {
        "tags": [
          "upload"
        ],
        "operationId": "requestUpload",
        "summary": "Ask for a short-lived URL to send one file to storage.",
        "description": "Step 1 of 3. Returns a short-lived URL to PUT the raw bytes to, then call\n/api/upload/confirm with the returned objectKey. The URL expires in 15\nminutes and carries its own signature — send no QRchive credentials with it.\n\nOpen to anonymous callers when the archive is unlocked and still accepts\nadditions from anyone. A private archive, or one whose owner has turned\nadditions off, refuses with 401 or 403.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "qrCodeId",
                  "fileName",
                  "fileSize",
                  "fileType"
                ],
                "properties": {
                  "qrCodeId": {
                    "type": "string",
                    "pattern": "^QR-[0-9]{6}$",
                    "example": "QR-123456",
                    "description": "The code printed on the sticker and shown in its public address, e.g. QR-123456 in /docs/QR-123456-AB7CD."
                  },
                  "fileName": {
                    "type": "string",
                    "description": "Name including extension. The extension must be one of: pdf, jpg, jpeg, png, heic, heif, webp, xlsx, docx, csv, mov, mp4.",
                    "example": "as-built.pdf"
                  },
                  "fileSize": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Exact size in bytes. 50 MB maximum, or 500 MB for mov and mp4. Confirm rejects a size that does not match what was stored.",
                    "example": 482118
                  },
                  "fileType": {
                    "type": "string",
                    "description": "Media type of the file.",
                    "example": "application/pdf"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A URL to send the bytes to.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "success",
                    "uploadUrl",
                    "objectKey",
                    "expiresAt"
                  ],
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "uploadUrl": {
                      "type": "string",
                      "format": "uri",
                      "description": "PUT the raw bytes here within 15 minutes."
                    },
                    "objectKey": {
                      "type": "string",
                      "description": "Pass this back as r2ObjectKey when confirming."
                    },
                    "expiresAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "storageWarning": {
                      "type": "string",
                      "description": "Present when the archive is near its storage allowance."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many upload requests from this address. 500 per hour. Wait Retry-After seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Limit": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Remaining": {
                "schema": {
                  "type": "integer"
                }
              },
              "X-RateLimit-Reset": {
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/upload/confirm": {
      "post": {
        "tags": [
          "upload"
        ],
        "operationId": "confirmUpload",
        "summary": "Tell QRchive the file finished sending, so it joins the archive.",
        "description": "Step 3 of 3. Verifies the bytes really arrived, checks the file's contents\nagainst the type its name claims, and adds it to the archive.\n\nSkipping this leaves the bytes in storage but out of the archive. A failed\ncheck deletes them.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "r2ObjectKey",
                  "qrCodeId",
                  "fileName",
                  "fileSize",
                  "fileType"
                ],
                "properties": {
                  "r2ObjectKey": {
                    "type": "string",
                    "description": "The objectKey returned by /api/upload/request."
                  },
                  "qrCodeId": {
                    "type": "string",
                    "pattern": "^QR-[0-9]{6}$",
                    "example": "QR-123456",
                    "description": "The code printed on the sticker and shown in its public address, e.g. QR-123456 in /docs/QR-123456-AB7CD."
                  },
                  "fileName": {
                    "type": "string",
                    "example": "as-built.pdf"
                  },
                  "fileSize": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Must match the stored size within 1 KB.",
                    "example": 482118
                  },
                  "fileType": {
                    "type": "string",
                    "example": "application/pdf"
                  },
                  "folderId": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Optional section to place the file in. Omit or send null for the top of the archive."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The file is in the archive.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "success",
                    "fileId"
                  ],
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "fileId": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/public/folders": {
      "get": {
        "tags": [
          "archive"
        ],
        "operationId": "listSections",
        "summary": "List the sections inside an unlocked archive.",
        "description": "Lists the sections at one level of an unlocked archive. Omit\nparentFolderId for the top level.\n\nA wrong token and a code that does not exist answer identically, so this\ncannot be used to discover which codes are real.",
        "parameters": [
          {
            "name": "qrCodeId",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^QR-[0-9]{6}$",
              "example": "QR-123456",
              "description": "The code printed on the sticker and shown in its public address, e.g. QR-123456 in /docs/QR-123456-AB7CD."
            }
          },
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[A-HJKMNP-Z2-9]{5}$",
              "example": "AB7CD",
              "description": "The 5-character token from the sticker's public address. Its alphabet omits 0, O, 1, I and L so it can be read off a printed sticker without ambiguity."
            }
          },
          {
            "name": "parentFolderId",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "List the sections inside this section instead of the top level."
          }
        ],
        "responses": {
          "200": {
            "description": "The sections at this level.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "success",
                    "folders"
                  ],
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "folders": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "parent_folder_id": {
                            "type": [
                              "string",
                              "null"
                            ]
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/qr/{qrCodeId}/status": {
      "get": {
        "tags": [
          "archive"
        ],
        "operationId": "getArchiveStatus",
        "summary": "Check whether an archive has been unlocked yet.",
        "description": "Reports where an archive is in its life. Only `activated` accepts files —\nthe value is the internal one and is kept as-is so it never shifts under a\nclient that already handles it.\n\nA wrong token and a code that does not exist answer identically.",
        "parameters": [
          {
            "name": "qrCodeId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^QR-[0-9]{6}$",
              "example": "QR-123456",
              "description": "The code printed on the sticker and shown in its public address, e.g. QR-123456 in /docs/QR-123456-AB7CD."
            }
          },
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[A-HJKMNP-Z2-9]{5}$",
              "example": "AB7CD",
              "description": "The 5-character token from the sticker's public address. Its alphabet omits 0, O, 1, I and L so it can be read off a printed sticker without ambiguity."
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The archive's current state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "success",
                    "status"
                  ],
                  "properties": {
                    "success": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    },
                    "status": {
                      "type": "string",
                      "description": "`activated` means unlocked and open for files. Anything else means it is not.",
                      "examples": [
                        "activated",
                        "unactivated",
                        "suspended"
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Request refused.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "success",
          "error",
          "code"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "enum": [
              false
            ]
          },
          "error": {
            "type": "object",
            "required": [
              "message",
              "code"
            ],
            "properties": {
              "message": {
                "type": "string",
                "description": "Human-readable explanation."
              },
              "code": {
                "type": "string",
                "description": "Stable machine-readable code."
              }
            }
          },
          "code": {
            "type": "string",
            "description": "Same value as error.code, mirrored for convenience."
          }
        }
      }
    }
  }
}