{
  "openapi": "3.1.0",
  "info": {
    "title": "Panorama API",
    "version": "1.0.0",
    "description": "Public REST API for generating 360° equirectangular panoramas from a text prompt and/or up to 3 reference images. Authenticate every request with a Bearer API key: `Authorization: Bearer sk-...` (alternatively, the `x-api-key` header accepts the same raw key). Generation is asynchronous: `POST /panoramas` returns 202 with a task `id` immediately after reserving credits; poll `GET /panoramas/{id}` until `status` is `succeeded` or `failed` to retrieve the output. Credits are charged at creation time (1k = 4, 2k = 6, 4k = 10 credits) and automatically refunded if the task ends up `failed`."
  },
  "servers": [
    {
      "url": "https://panoramagenerator.com/api/v1"
    }
  ],
  "security": [
    {
      "apiKey": []
    }
  ],
  "tags": [
    {
      "name": "Panoramas",
      "description": "Create and poll panorama generation tasks."
    },
    {
      "name": "Images",
      "description": "Upload reference images used as input to panorama generation."
    },
    {
      "name": "Credits",
      "description": "Check remaining account credits."
    }
  ],
  "paths": {
    "/panoramas": {
      "post": {
        "tags": ["Panoramas"],
        "summary": "Create a panorama generation task",
        "operationId": "createPanorama",
        "description": "Submits a new panorama generation task and returns immediately with status `processing`. This is an asynchronous, credit-metered operation:\n\n1. Credits are reserved atomically before the upstream generation call is made (1k resolution = 4 credits, 2k = 6 credits, 4k = 10 credits).\n2. The response's `id` is the task id — poll `GET /panoramas/{id}` (recommended interval: a few seconds) until `status` becomes `succeeded` or `failed`.\n3. If the task ends up `failed`, the reserved credits are automatically refunded — no action needed from the caller.\n4. At least one of `prompt` or `image_urls` must be provided. If `image_urls` is omitted, `prompt` must be at least 3 characters (text-to-panorama). If `image_urls` is provided, `prompt` may be short or omitted (image-to-panorama) — the reference image carries most of the signal.\n5. A given account may have at most 5 panorama tasks in `processing` state at once (shared across all of the account's API keys); exceeding this returns `429 too_many_active_tasks`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PanoramaCreateRequest"
              },
              "examples": {
                "textToPanorama": {
                  "summary": "Text-to-panorama",
                  "value": {
                    "prompt": "a serene mountain lake at sunrise, mist over the water",
                    "resolution": "2k",
                    "visibility": "private"
                  }
                },
                "imageToPanorama": {
                  "summary": "Image-to-panorama (reference images)",
                  "value": {
                    "prompt": "extend this into a full equirectangular panorama",
                    "image_urls": ["https://example.com/my-photo.jpg"],
                    "resolution": "4k",
                    "visibility": "public"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Task accepted and queued for processing. Credits have already been reserved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PanoramaCreateAccepted"
                },
                "example": {
                  "id": "b6f0b1b0-6e3a-4c9a-8f1e-2a2f6b1a9c3d",
                  "status": "processing",
                  "credits_charged": 6
                }
              }
            }
          },
          "400": {
            "description": "Invalid request body: malformed JSON, more than 3 `image_urls`, a non-`https://` or overlong image URL, an invalid/missing `resolution`, or a `prompt` shorter than 3 characters when no `image_urls` are given.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "example": {
                  "error": {
                    "code": "invalid_request",
                    "message": "prompt too short"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "description": "The account does not have enough credits for the requested resolution.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "example": {
                  "error": {
                    "code": "insufficient_credits",
                    "message": "Not enough credits. Purchase more at /pricing."
                  }
                }
              }
            }
          },
          "429": {
            "description": "Either the per-minute write rate limit was exceeded (`rate_limited`, includes a `retry-after` header), or the account already has 5 panorama tasks in `processing` state (`too_many_active_tasks`, no `retry-after` header — retry once an existing task finishes).",
            "headers": {
              "retry-after": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "examples": {
                  "rateLimited": {
                    "summary": "Per-minute write rate limit exceeded",
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "Rate limit exceeded."
                      }
                    }
                  },
                  "tooManyActiveTasks": {
                    "summary": "Concurrency cap reached",
                    "value": {
                      "error": {
                        "code": "too_many_active_tasks",
                        "message": "At most 5 tasks may be processing at once."
                      }
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "The upstream generation provider errored, is misconfigured, or did not return a task id. Safe to retry.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "example": {
                  "error": {
                    "code": "upstream_error",
                    "message": "Generation service unavailable. Please retry."
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/panoramas/{id}": {
      "get": {
        "tags": ["Panoramas"],
        "summary": "Get panorama task status",
        "operationId": "getPanorama",
        "description": "Returns the current status of a panorama generation task created via `POST /panoramas`. Poll this endpoint until `status` is `succeeded` or `failed`. When `succeeded`, the `output` object is populated (it may briefly be omitted immediately after success while the result record is being finalized — a subsequent poll will include it). When `failed`, the `error` object explains that reserved credits were refunded.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The task id returned by `POST /panoramas`.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "Current task status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PanoramaStatusResponse"
                },
                "examples": {
                  "processing": {
                    "summary": "Still processing",
                    "value": {
                      "id": "b6f0b1b0-6e3a-4c9a-8f1e-2a2f6b1a9c3d",
                      "status": "processing",
                      "credits_charged": 6
                    }
                  },
                  "succeeded": {
                    "summary": "Succeeded, public visibility",
                    "value": {
                      "id": "b6f0b1b0-6e3a-4c9a-8f1e-2a2f6b1a9c3d",
                      "status": "succeeded",
                      "credits_charged": 6,
                      "output": {
                        "image_url": "https://cdn.panoramagenerator.com/abc123.jpg",
                        "slug": "abc123",
                        "viewer_url": "https://panoramagenerator.com/p/abc123",
                        "storage_status": "saved"
                      }
                    }
                  },
                  "failed": {
                    "summary": "Failed, credits refunded",
                    "value": {
                      "id": "b6f0b1b0-6e3a-4c9a-8f1e-2a2f6b1a9c3d",
                      "status": "failed",
                      "credits_charged": 6,
                      "error": {
                        "code": "generation_failed",
                        "message": "Generation failed. Reserved credits were refunded."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No task exists with this id for the authenticated account (unknown id and not-owned id are both reported as 404 — no ownership oracle is exposed).",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "example": {
                  "error": { "code": "not_found", "message": "Task not found." }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/images": {
      "post": {
        "tags": ["Images"],
        "summary": "Upload reference images",
        "operationId": "uploadImages",
        "description": "Uploads one or more images to use as `image_urls` reference input for `POST /panoramas`. Accepts `multipart/form-data` with one or more `files` parts. Limits: at most 5 files per request, 10 MB per file, 20 MB total (25 MB total request size cap enforced via `Content-Length`), and only `image/jpeg`, `image/png`, or `image/webp` (validated by file signature, not just declared content type). Identical file contents are deduplicated and return the same URL.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["files"],
                "properties": {
                  "files": {
                    "description": "One or more image files. Repeat the `files` field for multiple files.",
                    "oneOf": [
                      { "type": "string", "format": "binary" },
                      {
                        "type": "array",
                        "items": { "type": "string", "format": "binary" },
                        "maxItems": 5
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "All files uploaded successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImagesUploadResponse"
                },
                "example": {
                  "image_urls": [
                    "https://cdn.panoramagenerator.com/uploads/d41d8cd98f00b204e9800998ecf8427e.jpg"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "No `files` field provided, too many files, a file (or total) too large, an unsupported/mismatched image type, or the request exceeded the 25 MB size cap.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ErrorResponse" },
                "example": {
                  "error": {
                    "code": "invalid_request",
                    "message": "Provide files under the 'files' field."
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/credits": {
      "get": {
        "tags": ["Credits"],
        "summary": "Get remaining credits",
        "operationId": "getCredits",
        "description": "Returns the number of credits remaining on the authenticated account.",
        "responses": {
          "200": {
            "description": "Remaining credit balance.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CreditsResponse" },
                "example": { "remaining_credits": 42 }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "Bearer API key, e.g. `Authorization: Bearer sk-...`. The raw key may alternatively be sent via the `x-api-key` header."
      }
    },
    "headers": {
      "RetryAfter": {
        "description": "Seconds to wait before retrying. Present when the 429 is due to the per-minute rate limit (`rate_limited`).",
        "schema": { "type": "integer", "minimum": 1 }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": {
              "error": {
                "code": "invalid_api_key",
                "message": "Missing or invalid API key."
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Per-minute rate limit exceeded for this API key.",
        "headers": {
          "retry-after": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": {
              "error": { "code": "rate_limited", "message": "Rate limit exceeded." }
            }
          }
        }
      },
      "InternalError": {
        "description": "Unexpected server error.",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/ErrorResponse" },
            "example": {
              "error": { "code": "internal_error", "message": "Internal error." }
            }
          }
        }
      }
    },
    "schemas": {
      "ApiErrorCode": {
        "type": "string",
        "description": "Full enum of error codes returned by the public API.",
        "enum": [
          "invalid_request",
          "invalid_api_key",
          "insufficient_credits",
          "not_found",
          "rate_limited",
          "too_many_active_tasks",
          "upstream_error",
          "internal_error"
        ]
      },
      "ErrorResponse": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": { "$ref": "#/components/schemas/ApiErrorCode" },
              "message": { "type": "string" }
            }
          }
        }
      },
      "Resolution": {
        "type": "string",
        "enum": ["1k", "2k", "4k"],
        "description": "Output resolution tier. Credit cost: 1k = 4 credits, 2k = 6 credits, 4k = 10 credits."
      },
      "Visibility": {
        "type": "string",
        "enum": ["private", "public"],
        "default": "private",
        "description": "Whether the resulting panorama is published to the public gallery (`public`, viewable at `/p/{slug}`) or kept private (`private`, viewable only by the owner at `/works/{slug}`)."
      },
      "PanoramaCreateRequest": {
        "type": "object",
        "required": ["resolution"],
        "properties": {
          "prompt": {
            "type": "string",
            "description": "Text description of the panorama to generate. Required (minimum 3 characters) when `image_urls` is omitted. May be short or omitted when `image_urls` is provided — the reference image carries most of the signal."
          },
          "image_urls": {
            "type": "array",
            "maxItems": 3,
            "items": {
              "type": "string",
              "format": "uri",
              "maxLength": 2048,
              "pattern": "^https://"
            },
            "description": "Up to 3 publicly reachable `https://` reference image URLs (e.g. URLs returned by `POST /images`). Enables image-to-panorama mode."
          },
          "resolution": {
            "$ref": "#/components/schemas/Resolution"
          },
          "visibility": {
            "$ref": "#/components/schemas/Visibility"
          }
        }
      },
      "PanoramaCreateAccepted": {
        "type": "object",
        "required": ["id", "status", "credits_charged"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Task id. Use with `GET /panoramas/{id}` to poll for completion."
          },
          "status": {
            "type": "string",
            "enum": ["processing"]
          },
          "credits_charged": {
            "type": "integer",
            "description": "Credits reserved for this task at creation time (refunded automatically if the task ends up `failed`)."
          }
        }
      },
      "PanoramaOutput": {
        "type": "object",
        "required": ["image_url", "slug", "viewer_url", "storage_status"],
        "properties": {
          "image_url": {
            "type": "string",
            "format": "uri",
            "description": "Direct URL to the generated equirectangular panorama image. Initially the upstream provider's temporary URL; becomes the permanent CDN URL once `storage_status` reaches `saved` or `saved-no-thumb`."
          },
          "slug": {
            "type": "string",
            "description": "Slug identifying the panorama, used in `viewer_url`."
          },
          "viewer_url": {
            "type": "string",
            "format": "uri",
            "description": "Link to the interactive 360° viewer page: `/p/{slug}` if `visibility` was `public`, otherwise `/works/{slug}` (owner-only)."
          },
          "storage_status": {
            "type": "string",
            "enum": ["uploading", "saved", "saved-no-thumb", "failed", "expired"],
            "description": "Progress of persisting the generated image from the upstream provider's temporary storage to permanent CDN storage."
          }
        }
      },
      "PanoramaTaskError": {
        "type": "object",
        "required": ["code", "message"],
        "description": "Domain-specific failure detail on a `failed` task. Distinct from the transport-level `ErrorResponse`/`ApiErrorCode` used for non-2xx responses.",
        "properties": {
          "code": {
            "type": "string",
            "enum": ["generation_failed"]
          },
          "message": { "type": "string" }
        }
      },
      "PanoramaStatusResponse": {
        "type": "object",
        "required": ["id", "status", "credits_charged"],
        "properties": {
          "id": { "type": "string" },
          "status": {
            "type": "string",
            "enum": ["processing", "succeeded", "failed"]
          },
          "credits_charged": {
            "type": "integer",
            "description": "Credits reserved for this task (refunded if `status` is `failed`)."
          },
          "output": {
            "allOf": [{ "$ref": "#/components/schemas/PanoramaOutput" }],
            "description": "Present when `status` is `succeeded` (may be briefly omitted immediately after success while the result record is finalized)."
          },
          "error": {
            "allOf": [{ "$ref": "#/components/schemas/PanoramaTaskError" }],
            "description": "Present when `status` is `failed`."
          }
        }
      },
      "ImagesUploadResponse": {
        "type": "object",
        "required": ["image_urls"],
        "properties": {
          "image_urls": {
            "type": "array",
            "items": { "type": "string", "format": "uri" },
            "description": "Public URLs of the uploaded images, in the same order as the uploaded `files`. Pass these into `POST /panoramas`' `image_urls` field."
          }
        }
      },
      "CreditsResponse": {
        "type": "object",
        "required": ["remaining_credits"],
        "properties": {
          "remaining_credits": {
            "type": "integer",
            "description": "Credits remaining on the authenticated account."
          }
        }
      }
    }
  }
}
