{
  "openapi": "3.1.0",
  "info": {
    "title": "ClearList API",
    "version": "0.3.6",
    "description": "API for ClearList, an AI resale manager. Agents authenticate via an API key acquired through the email verification flow, or as an OAuth 2.1 client (see /auth.md). Standard response envelope: `{ success: boolean, data?: object, error?: string }`. Errors return the Error schema with a machine-readable `code` and a resolution `hint`. Versioning: every response carries an X-API-Version header (currently 0.3); breaking changes ship as a new version and the old behavior is kept through a deprecation window announced via Deprecation and Sunset headers (RFC 8594) and the changelog at /developers. Retries: the operations that declare the Idempotency-Key header parameter accept it (1-255 chars; a UUID works well). The first successful (2xx) response is stored for 24h, and a retry with the same key replays it instead of duplicating the side effect, marked by an Idempotency-Replayed: true response header. An error returned by the operation itself is never stored and releases the key, so retrying that same request re-executes it. A key is scoped to the seller and the operation, and it identifies ONE request: while its record is live (the first request is still running, or it succeeded within the last 24h), the same key on the same operation with a different request body (compared byte for byte, so reformatted JSON counts as different) or from a different kind of credential (a signed-in session, an API key, and an OAuth token are three kinds) is refused with 422 `idempotency_key_reuse`. That refusal leaves the original record in place, so a corrected request needs a NEW key. A key outside the allowed characters is refused with 400 `invalid_idempotency_key`. Do not assume an operation is retry-safe because it looks like an update: check for the parameter on the operation itself.",
    "contact": {
      "email": "hello@clearlist.me",
      "url": "https://clearlist.me"
    }
  },
  "servers": [
    {
      "url": "https://clearlist.me",
      "description": "Production"
    }
  ],
  "security": [
    {
      "apiKey": []
    },
    {
      "oauth2": []
    }
  ],
  "tags": [
    {
      "name": "Onboarding",
      "description": "Email-OTP auth and API-key acquisition."
    },
    {
      "name": "Listings",
      "description": "Create, read, update, delete, and bulk-create items."
    },
    {
      "name": "Publishing",
      "description": "Publish the shareable sale page."
    },
    {
      "name": "Orders",
      "description": "Reservations and buyer conversations."
    },
    {
      "name": "Scheduling",
      "description": "Pickup availability."
    },
    {
      "name": "Account",
      "description": "Tier, capacity, and payment links."
    },
    {
      "name": "Jobs",
      "description": "Async job polling."
    },
    {
      "name": "System",
      "description": "Health and status probes."
    }
  ],
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-ClearList-API-Key",
        "description": "API key in `cl_<64-hex>` format. Obtained via POST /api/auth/verify-code with `{ email, code, agent: true }`. Always full access (read and write). The same credential is also accepted as `Authorization: Bearer cl_<64-hex>`."
      },
      "oauth2": {
        "type": "oauth2",
        "description": "OAuth 2.1 authorization code flow. PKCE with code_challenge_method=S256 is required; public clients only (token_endpoint_auth_method none). Access tokens are `cl_<64-hex>` and are sent as `Authorization: Bearer <token>` (X-ClearList-API-Key also works). Operations that change state require the `write` scope; a grant without it gets 403. Reading needs no specific scope, so a `write`-only grant can also read.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://clearlist.me/oauth/authorize",
            "tokenUrl": "https://clearlist.me/api/oauth/token",
            "refreshUrl": "https://clearlist.me/api/oauth/token",
            "scopes": {
              "read": "Read listings, reservations, conversations, page stats and plan status.",
              "write": "Create, edit, delete and publish listings; reply to buyers; share an address through the confirmed flow; set availability; create payment links."
            }
          }
        }
      },
      "bearerToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "Firebase ID token for web UI authentication. A `Bearer cl_…` value is not a Firebase token: it is an API key or OAuth access token, covered by the apiKey and oauth2 schemes."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "success",
          "error"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "example": false
          },
          "error": {
            "type": "string",
            "description": "Human-readable error message."
          },
          "code": {
            "type": "string",
            "description": "Machine-readable error code, e.g. route_not_found, invalid_token, rate_limited."
          },
          "message": {
            "type": "string",
            "description": "Detailed message."
          },
          "hint": {
            "type": "string",
            "description": "Resolution guidance for agents."
          },
          "retryable": {
            "type": "boolean",
            "description": "Present on refusals that are worth retrying unchanged. Currently only a wrong-conversation address confirmation, where the token is untouched and only the id was wrong. Absent means terminal."
          }
        }
      },
      "Job": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "processing",
              "completed",
              "failed"
            ]
          },
          "data": {
            "type": "object",
            "nullable": true
          },
          "error": {
            "type": "string",
            "nullable": true
          }
        }
      }
    },
    "parameters": {
      "CursorParam": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string"
        },
        "description": "Pagination cursor: pass the `next_cursor` (ISO timestamp) from a previous response to fetch the next page."
      },
      "LimitParam": {
        "name": "limit",
        "in": "query",
        "required": false,
        "schema": {
          "type": "integer",
          "default": 50,
          "maximum": 100,
          "minimum": 1
        },
        "description": "Maximum number of records to return (max 100)."
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Unique client-generated key (1-255 chars from A-Za-z0-9_-:.; a UUID works well), scoped to the seller and the operation. The first successful (2xx) response is stored for 24h; retrying the identical request with the same key replays it instead of re-executing, so network-failure retries never create duplicates. A duplicate that arrives while the first is still running gets 409 idempotency_conflict. The same key with a different body (byte for byte) or a different kind of credential gets 422 idempotency_key_reuse and leaves the original record in place: send a new key. An error the operation itself returns is never stored and releases the key.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 255,
          "pattern": "^[A-Za-z0-9_:.-]+$"
        }
      }
    },
    "headers": {
      "WWWAuthenticate": {
        "schema": {
          "type": "string"
        },
        "description": "RFC 9728 challenge: `Bearer resource_metadata=\"https://clearlist.me/.well-known/oauth-protected-resource\", error=\"invalid_token\"`."
      },
      "RateLimitLimit": {
        "schema": {
          "type": "integer"
        },
        "description": "Request quota for the current window."
      },
      "RateLimitRemaining": {
        "schema": {
          "type": "integer"
        },
        "description": "Requests remaining in the current window."
      },
      "RateLimitReset": {
        "schema": {
          "type": "integer"
        },
        "description": "Unix epoch seconds when the window resets."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid or missing parameters.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key.",
        "headers": {
          "WWW-Authenticate": {
            "$ref": "#/components/headers/WWWAuthenticate"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated, but the credential does not own this resource. Distinct from 404: the resource exists and belongs to another seller. Never retry: no key rotation or backoff changes the answer.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource (or API route) not found. Returned as JSON, never HTML.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests.",
        "headers": {
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/RateLimitLimit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/RateLimitRemaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/RateLimitReset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServerError": {
        "description": "Unexpected server error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "IdempotencyKeyReuse": {
        "description": "`code: idempotency_key_reuse`. While an earlier request with this Idempotency-Key is still running or succeeded within the last 24h, the key was sent to the same operation with a different body (compared byte for byte) or from a different kind of credential (signed-in session, API key, or OAuth token). Nothing was executed and the original record is untouched, so retrying with this key repeats the 422. Send the request with a new key.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "IdempotencyConflict": {
        "description": "`code: idempotency_conflict`. A request with this Idempotency-Key is still being processed. Retry the same request with the same key after a few seconds: if the first request succeeded the retry replays its response; if it failed, or has been pending for over a minute, the retry runs the operation again.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  },
  "paths": {
    "/api/auth/send-code": {
      "post": {
        "operationId": "sendVerificationCode",
        "x-tool-id": "send_verification_code",
        "tags": [
          "Onboarding"
        ],
        "summary": "Send verification code",
        "description": "Sends a 6-digit verification code to the given email address. No authentication required.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Code sent.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/auth/verify-code": {
      "post": {
        "operationId": "verifyCode",
        "x-tool-id": "verify_code",
        "tags": [
          "Onboarding"
        ],
        "summary": "Verify code and get API key",
        "description": "Verifies the 6-digit code. Creates the account if new. Returns an API key when `agent: true` is set.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "code"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "code": {
                    "type": "string",
                    "pattern": "^[0-9]{6}$"
                  },
                  "agent": {
                    "type": "boolean",
                    "description": "Set true to receive an API key."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verified.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "isNewUser": {
                      "type": "boolean"
                    },
                    "uid": {
                      "type": "string"
                    },
                    "apiKey": {
                      "type": "string",
                      "description": "Only when agent=true. Format cl_<64-hex>."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/items": {
      "get": {
        "operationId": "getListings",
        "x-tool-id": "get_listings",
        "tags": [
          "Listings"
        ],
        "summary": "List items",
        "description": "Returns the authenticated seller's items. Soft-deleted items (tombstones) are hidden unless `include_deleted=true`, which is the only way to recover an item_id for POST /api/items/{id}/restore after the deleting session is gone. Tombstone rows are enriched with `restorable` (boolean), `restorable_until` (ISO timestamp — the field to trust) and `restore_hours_remaining` (countdown, stale the moment it is read); tombstones past the window still appear, flagged `restorable: false`, until the purge collects them.",
        "parameters": [
          {
            "name": "include_deleted",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ]
            },
            "description": "Set to \"true\" to include soft-deleted items. Any other non-empty value is a 400, never silently treated as false — a silently-dropped tombstone reads as \"this listing never existed\" to the caller."
          }
        ],
        "responses": {
          "200": {
            "description": "Items list.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "items": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "post": {
        "operationId": "createListing",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "create_listing",
        "tags": [
          "Listings"
        ],
        "summary": "Create a listing",
        "description": "Upload photos and create an AI-generated listing. Supports Idempotency-Key for safe retries.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "photos"
                ],
                "properties": {
                  "photos": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "uri"
                    },
                    "maxItems": 5
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Listing created — but check `data.inactive`. An over-the-active-limit item is NOT refused: it is saved with `status: 'inactive'` and the response carries `inactive: true`. An inactive item is not on the sale page, so reporting it as a live listing is wrong; tell the seller it is saved but needs an upgrade (or a freed slot) to go live.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "inactive": {
                          "type": "boolean",
                          "description": "Present and true only when the item was saved as status 'inactive' because the active-item limit is reached."
                        }
                      },
                      "additionalProperties": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Creation ceiling reached for the current tier (active-item limit or lifetime cap). The error message names the limit; the fix is an upgrade via POST /api/payments/checkout-link. Also `code: ADDRESS_IN_PUBLIC_LISTING` when an API key or OAuth token writes the seller's stored street address into public listing text (title, description, transport_notes, condition, category, or specifications). Nothing is saved. Remove the address; to give it to a buyer, use the address share flow. A signed-in seller's own session is not checked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "This account has no email on file, so an assistant cannot publish a listing (agent credentials only). Nothing was saved. Not retryable until the account has an email. Also returned with `code: idempotency_conflict` while a request with the same Idempotency-Key is still being processed; that one is worth retrying with the same key after a few seconds. A 409 without that code is the operation's own refusal: read `error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyKeyReuse"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/api/items/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getItem",
        "tags": [
          "Listings"
        ],
        "summary": "Get one item",
        "responses": {
          "200": {
            "description": "Item.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "put": {
        "operationId": "editListing",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "edit_listing",
        "tags": [
          "Listings"
        ],
        "summary": "Update a listing",
        "description": "Update any field on an item (price, title, description, status, etc.). This route does NOT accept an Idempotency-Key (check the parameters of an operation before sending one — this spec declares the header only on the six routes that honour it). Retry safety comes from the conflict check instead: on a timeout, re-read the item with GET and re-apply the change, and treat the \"changed while you were editing\" 409 below as your signal that a concurrent write landed first.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated. The body carries the full updated item document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Either the credential does not own this item (the item exists and belongs to another seller; never retry, no key rotation or backoff changes the answer), or `code: ADDRESS_IN_PUBLIC_LISTING` when an API key or OAuth token writes the seller's stored street address into public listing text (title, description, transport_notes, condition, category, or specifications). Nothing is saved. Remove the address; to give it to a buyer, use the address share flow. A signed-in seller's own session is not checked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Three distinct conflicts share this status; read `error` to tell them apart.\n\n1. **The item is deleted.** `error` is \"This item is deleted. Restore it first: POST /api/items/{id}/restore\". An edit cannot resurrect a tombstone, because that would skip the active-item capacity re-check that POST /api/items/{id}/restore runs in a transaction. Call restore, then retry the edit.\n\n2. **The item changed while you were editing it.** `error` is \"This item changed while you were editing it. Reload and try again.\" Your write was rejected rather than applied on top of someone else's. Re-read the item with GET and re-apply your change to the fresh document.\n\n3. **The seller account backing the item is missing or incomplete.** `error` is \"Cannot update: this seller account is missing or incomplete.\" An account-level inconsistency, not an edit conflict — retrying cannot fix it; surface it to the seller.\n\nNone is retryable as-is: the same request repeated returns the same 409.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      },
      "delete": {
        "operationId": "deleteListing",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "delete_listing",
        "tags": [
          "Listings"
        ],
        "summary": "Soft-delete a listing",
        "description": "Soft delete. The listing is hidden from the sale page, the dashboard, and every status-filtered read, but the document and its photos survive for 7 days and can be recovered with POST /api/items/{id}/restore. A purge job hard-deletes the document and its Storage blobs once the window closes, after which restore returns 410. Repeating the delete is a no-op success, not a 404.",
        "responses": {
          "200": {
            "description": "Deleted, or already deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "item_id": {
                          "type": "string"
                        },
                        "deleted": {
                          "type": "boolean",
                          "description": "Always true on a 200."
                        },
                        "already_deleted": {
                          "type": "boolean",
                          "description": "Present and true only when the item was ALREADY a tombstone, so this call changed nothing. Absent on the call that performed the delete. Treat it as success either way, but do not report a fresh deletion to the user when it is set, and do not use it to infer that the restore window just started: the 7-day clock runs from the ORIGINAL delete, which may be nearly expired."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The item has an active reservation. A buyer is holding it, so deleting would strand them. `error` is \"This item has an active reservation. Deactivate the item first to cancel reservations, then delete it.\" Set the item's status to `inactive` via PUT (which cancels the reservation and notifies the buyer), then delete.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/items/{id}/restore": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "restoreListing",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "restore_listing",
        "tags": [
          "Listings"
        ],
        "summary": "Restore a soft-deleted listing",
        "description": "Undoes DELETE /api/items/{id} inside the 7-day window. The item returns to the status it held before the delete, except that a previously `reserved` item comes back `available` (the reservation that justified it is long gone) and an item that no longer fits the seller's active-item limit comes back `inactive` rather than being refused. Pass an Idempotency-Key: this operation is NOT self-idempotent, and a retry without one reports 409 \"This item is not deleted\" for a restore that in fact succeeded.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Restored.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "item_id": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string",
                          "description": "The status the item was restored TO. Not necessarily the status it had before the delete.",
                          "enum": [
                            "available",
                            "taken",
                            "inactive"
                          ]
                        },
                        "downgraded_to_inactive": {
                          "type": "boolean",
                          "description": "True when the seller was at their active-item limit, so the item was restored as `inactive` instead of the status it had before the delete. The restore succeeded and the seller's work is back, but the listing is NOT visible on the sale page. Surface this: reporting a plain success here leaves the seller believing a hidden item is live. `message` carries seller-facing wording naming the limit."
                        },
                        "message": {
                          "type": "string",
                          "description": "Present only when downgraded_to_inactive is true."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Nothing to restore. Either the item is not deleted (`error` is \"This item is not deleted\") or the seller account that owns it is missing or incomplete. Not retryable: repeating the call returns the same 409. If you sent an Idempotency-Key on the original attempt, retry with that same key instead. A 409 here after a request that timed out usually means the first attempt committed and you are seeing the follow-up call, not a failure. Also returned with `code: idempotency_conflict` while a request with the same Idempotency-Key is still being processed; that one is worth retrying with the same key after a few seconds. A 409 without that code is the operation's own refusal: read `error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "The 7-day restore window has passed. `error` is \"The restore window for this item has passed.\" The listing and its photos are unrecoverable; there is no appeal path and no retry. Refused on the window rather than on the document still existing, so the answer does not depend on when the purge job last ran.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyKeyReuse"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/items/bulk-upload": {
      "post": {
        "operationId": "bulkCreateListings",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "bulk_create_listings",
        "tags": [
          "Listings"
        ],
        "summary": "Upload a photo batch (step 1 of bulk creation)",
        "description": "Uploads up to 50 base64 photos to Storage and returns their URLs plus a batchId — synchronously, HTTP 200, no AI. This is step 1 of the bulk chain: bulk-upload (photos → URLs) → POST /api/items/bulk-generate (URLs → AI listings via job polling) → POST /api/items (save). It does NOT group photos or generate listings itself. Supports Idempotency-Key for safe retries.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "photos"
                ],
                "properties": {
                  "photos": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "description": "Base64-encoded photo, with or without a data: URL prefix. Not a URL."
                    },
                    "maxItems": 50
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Photos stored.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "fullUrls": {
                          "type": "array",
                          "items": {
                            "type": "string",
                            "format": "uri"
                          }
                        },
                        "thumbnailUrls": {
                          "type": "array",
                          "items": {
                            "type": "string",
                            "format": "uri"
                          }
                        },
                        "batchId": {
                          "type": "string"
                        },
                        "count": {
                          "type": "number"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "description": "A photo is still uploading (sent with Retry-After: 5). Retry after the delay. Also returned with `code: idempotency_conflict` while a request with the same Idempotency-Key is still being processed; that one is worth retrying with the same key after a few seconds. A 409 without that code is the operation's own refusal: read `error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyKeyReuse"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/api/items/bulk-generate": {
      "post": {
        "operationId": "generateListingFromPhotos",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "tags": [
          "Listings"
        ],
        "summary": "Generate one listing from photos (async)",
        "description": "Runs AI generation over up to 10 photos of a SINGLE item (already-uploaded Storage URLs, not base64) and returns a job_id immediately; poll GET /api/ai-jobs/{jobId} for the listing. Pass an Idempotency-Key: a replay returns the ORIGINAL job_id, so the retry polls the generation already in flight instead of paying for a second one.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "photoUrls"
                ],
                "properties": {
                  "photoUrls": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "uri"
                    },
                    "minItems": 1,
                    "maxItems": 10,
                    "description": "Photos of one item, different angles. Only the first 5 are sent to the model."
                  },
                  "sellerContext": {
                    "type": "string",
                    "description": "Free-text notes from the seller. Photo evidence wins on conflict unless isCorrection is true."
                  },
                  "isCorrection": {
                    "type": "boolean",
                    "description": "True when sellerContext is an explicit correction of a previous result, which makes the model defer to the seller instead of the photo."
                  },
                  "groupLabel": {
                    "type": "string"
                  },
                  "isBundle": {
                    "type": "boolean"
                  },
                  "bundleComponents": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Job accepted (200, not 202). `status` is \"processing\" and `job_id` is the handle to poll.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "processing"
                      ]
                    },
                    "job_id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyKeyReuse"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/ai-jobs/{jobId}": {
      "parameters": [
        {
          "name": "jobId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getJobStatus",
        "tags": [
          "Jobs"
        ],
        "summary": "Poll an async job",
        "description": "Returns the status of an async AI job. Poll with exponential backoff (2s → 10s cap); jobs expire after 15 minutes.",
        "responses": {
          "200": {
            "description": "Job status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/pages/publish": {
      "post": {
        "operationId": "publishPage",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "publish_page",
        "tags": [
          "Publishing"
        ],
        "summary": "Publish sale page",
        "description": "Publishes the seller's sale page with location info and returns the shareable URL. Pass an Idempotency-Key. Re-publishing converges on the same page state, but a retry after a mid-flight failure short-circuits on the slug that already exists, so it will not repair a half-written page; replaying the stored first response is the reliable path. IMPORTANT: publishing requires `publish: true`. A page that has been unpublished stays offline unless you send it — the response still returns 200 and the URL either way.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "city": {
                    "type": "string"
                  },
                  "state": {
                    "type": "string"
                  },
                  "publish": {
                    "type": "boolean",
                    "description": "Set true to bring the page ONLINE. Required to re-publish a page that was previously unpublished: without it this endpoint updates location and payment details only, returns 200 with the page URL, and leaves the page offline. Omit it (or send false) for a settings-style update that must not change published state."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Published.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "url": {
                          "type": "string",
                          "format": "uri"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`code: ADDRESS_IN_PUBLIC_LISTING` when an API key or OAuth token sends payment_instructions containing the seller's street address (stored, or sent as street_address in the same request). The instructions are shown on the public sale page. Nothing is written. Not retryable as sent: remove the address; to give it to a buyer, use the address share flow. A signed-in seller's own session is not checked."
          },
          "409": {
            "description": "The requested custom URL is reserved or already taken. Not retryable: choose another URL. Also returned with `code: idempotency_conflict` while a request with the same Idempotency-Key is still being processed; that one is worth retrying with the same key after a few seconds. A 409 without that code is the operation's own refusal: read `error`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyKeyReuse"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/api/pages/renewal-options": {
      "get": {
        "operationId": "getPageRenewalOptions",
        "tags": [
          "Publishing",
          "Account"
        ],
        "summary": "Get sale-page renewal choices",
        "description": "Returns the authenticated seller's current page expiry and the renewal choices that can be fulfilled in full. Checkout URLs are omitted when the page is unpublished or no option is currently eligible.",
        "responses": {
          "200": {
            "description": "Current renewal state.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "can_renew": {
                          "type": "boolean"
                        },
                        "is_free": {
                          "type": "boolean"
                        },
                        "page_expires_at": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true
                        },
                        "days_until_page_expiry": {
                          "type": "integer",
                          "nullable": true
                        },
                        "is_expired": {
                          "type": "boolean"
                        },
                        "options": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "intent": {
                                "type": "string",
                                "enum": [
                                  "free_renew",
                                  "time_only",
                                  "renew_pass",
                                  "upgrade"
                                ]
                              },
                              "label": {
                                "type": "string"
                              },
                              "detail": {
                                "type": "string"
                              },
                              "priceUsd": {
                                "type": "number"
                              },
                              "durationDays": {
                                "type": "integer"
                              },
                              "addsCapacity": {
                                "type": "boolean"
                              },
                              "sku": {
                                "type": "string",
                                "enum": [
                                  "sale_pass",
                                  "big_move",
                                  "move_extension",
                                  "garage_extension"
                                ]
                              },
                              "checkout_url": {
                                "type": "string",
                                "format": "uri",
                                "nullable": true
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/pages/extend": {
      "post": {
        "operationId": "extendSalePage",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "extend_sale_page",
        "tags": [
          "Publishing",
          "Account"
        ],
        "summary": "Renew or begin renewing a sale page",
        "description": "Renews an eligible free page immediately. Paid or lapsed-paid accounts receive purchasable choices instead; this endpoint never grants paid time for free. Pass an Idempotency-Key when retrying.",
        "responses": {
          "200": {
            "description": "Page extended, or payment choices returned.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "extended": {
                          "type": "boolean"
                        },
                        "requires_payment": {
                          "type": "boolean"
                        },
                        "page_expires_at": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true
                        },
                        "days_until_page_expiry": {
                          "type": "integer",
                          "nullable": true
                        },
                        "url": {
                          "type": "string",
                          "format": "uri"
                        },
                        "options": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "description": "Page is unpublished, renewal is too early, or account state changed. Also returned with `code: idempotency_conflict` while a request with the same Idempotency-Key is still being processed; that one is worth retrying with the same key after a few seconds. A 409 without that code is the operation's own refusal: read `error`."
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyKeyReuse"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    },
    "/api/conversations": {
      "get": {
        "operationId": "getReservations",
        "x-tool-id": "get_reservations",
        "tags": [
          "Orders"
        ],
        "summary": "List conversations / reservations",
        "description": "Returns buyer conversations and reservations for the seller, newest first. Cursor-paginated; the response includes `next_cursor` when more pages exist.",
        "parameters": [
          {
            "$ref": "#/components/parameters/CursorParam"
          },
          {
            "$ref": "#/components/parameters/LimitParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Conversations page.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "conversations": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        },
                        "next_cursor": {
                          "type": "string",
                          "nullable": true
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/conversations/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "replyToBuyer",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "confirm_reply_to_buyer",
        "tags": [
          "Orders"
        ],
        "summary": "Reply to a buyer",
        "description": "Sends a message to the buyer. NOT naturally idempotent: a reply emails the buyer, so a retry after a timeout or 500 sends them a second copy of the same message. Always pass an Idempotency-Key here.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "content": {
                    "type": "string",
                    "description": "Message body. Trimmed and truncated to 2000 characters server-side. Required for the seller's own session. For an API key the text comes from confirmation_token; `content` may be sent to echo it, and must then match the confirmed text exactly (409 CONFIRMATION_MISMATCH otherwise)."
                  },
                  "type": {
                    "type": "string",
                    "default": "text",
                    "enum": [
                      "text",
                      "address_share",
                      "pickup_confirmed"
                    ],
                    "description": "An API key may only send `text`. `address_share` is refused (403 USE_ADDRESS_FLOW): an address travels the confirmed two-step flow (share_address then confirm_address_share) or not at all. `pickup_confirmed` is refused (403 USE_CONFIRM_PICKUP): close a pickup with POST /api/reservations/{id}/pickup-confirm."
                  },
                  "confirmation_token": {
                    "type": "string",
                    "description": "Required for an API key or OAuth token. From POST /api/conversations/{id}/reply/prepare, after the seller has seen the message and recipient. The message sent is the text frozen in the token. Single use, valid for 10 minutes."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Message sent.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Either the credential does not own this conversation, or `type` was `address_share` and the credential is an API key. An address cannot be sent as an ordinary message. Agents share one through the confirmed flow instead: POST /api/conversations/{id}/address/prepare names the recipient and returns a confirmation token, then POST /api/conversations/{id}/address sends it. No retry or scope change makes this route accept an address_share. Also `code: ADDRESS_USE_SHARE_FLOW` when an API key or OAuth token sends a text message containing the seller's stored street address, to any buyer, reserved or not. Nothing is written. Not retryable: share the address through the confirmed flow above, then send any other details as a separate message. Also `code: USE_CONFIRM_PICKUP` when an API key or OAuth token sends `type: pickup_confirmed`; close a pickup with POST /api/reservations/{id}/pickup-confirm instead. A signed-in seller's own session is not checked by either rule. Also `code: CONFIRMATION_REQUIRED` when an API key or OAuth token sends with no confirmation_token: prepare the message first. A confirmation token that is expired, not valid, or prepared for a different conversation or reservation is refused with `code: CONFIRMATION_EXPIRED`, `CONFIRMATION_NOT_FOUND` or `CONFIRMATION_WRONG_TARGET` (the last one says the token is still valid for its own target). Nothing is written.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`code: idempotency_conflict`: a request with this Idempotency-Key is still being processed; retry the same request with the same key after a few seconds. Also `code: CONFIRMATION_ALREADY_USED`: this confirmation token already sent its message. Do not send again; read the conversation to see it. Also `code: CONFIRMATION_MISMATCH`: `content` was sent and differs from the confirmed text; nothing was sent and the token is still usable. A 409 without that code is the operation's own refusal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyKeyReuse"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/scheduling/availability": {
      "put": {
        "operationId": "setAvailability",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "set_availability",
        "tags": [
          "Scheduling"
        ],
        "summary": "Set pickup availability",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Availability saved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/payments/status": {
      "get": {
        "operationId": "checkTierStatus",
        "x-tool-id": "check_tier_status",
        "tags": [
          "Account"
        ],
        "summary": "Check tier, capacity, and expiry",
        "responses": {
          "200": {
            "description": "Tier status.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "tier": {
                          "type": "string"
                        },
                        "items_remaining": {
                          "type": "integer"
                        },
                        "expires_at": {
                          "type": "string",
                          "nullable": true
                        },
                        "page_expires_at": {
                          "type": "string",
                          "format": "date-time",
                          "nullable": true
                        },
                        "days_until_page_expiry": {
                          "type": "integer",
                          "nullable": true
                        },
                        "can_renew": {
                          "type": "boolean"
                        },
                        "renewal": {
                          "type": "object",
                          "description": "Current eligible renewal choices. Empty outside the renewal window or when no full-duration purchase can be delivered."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/api/payments/checkout-link": {
      "post": {
        "operationId": "generatePaymentLink",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "generate_payment_link",
        "tags": [
          "Account"
        ],
        "summary": "Generate a checkout link",
        "description": "Returns a Stripe checkout URL for the requested plan. Send the URL to the user to pay in a browser; poll /api/payments/status to confirm. Naturally idempotent: no record is created; retries return the same payment link.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "plan"
                ],
                "properties": {
                  "plan": {
                    "type": "string",
                    "enum": [
                      "sale_pass",
                      "big_move",
                      "move_extension",
                      "garage_extension"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checkout link — or `already_paid`. When the account already holds an equal-or-higher active plan, the 200 carries `data.already_paid: true` with `plan`, `expires_at` and a human-readable `message` instead of a link; do NOT send the user to checkout in that case. Upgrades (sale_pass → big_move) still return a link.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "checkout_url": {
                          "type": "string",
                          "format": "uri",
                          "description": "Stripe-hosted checkout URL. Absent when already_paid is true."
                        },
                        "already_paid": {
                          "type": "boolean",
                          "description": "Present and true only when no link was issued because the current plan already covers the request."
                        },
                        "plan": {
                          "type": "string"
                        },
                        "plan_name": {
                          "type": "string"
                        },
                        "items_limit": {
                          "type": "number"
                        },
                        "duration_days": {
                          "type": "number"
                        },
                        "expires_at": {
                          "type": "string",
                          "format": "date-time",
                          "description": "Only on already_paid responses."
                        },
                        "suggested_action": {
                          "type": "string",
                          "description": "Only on already_paid responses, when a time extension is available. Currently always \"extend\"."
                        },
                        "suggested_sku": {
                          "type": "string",
                          "enum": [
                            "move_extension",
                            "garage_extension"
                          ],
                          "description": "Only on already_paid responses. Call this endpoint again with plan set to this value to get a checkout link for 30 more days at the same item limit."
                        },
                        "suggested_price_usd": {
                          "type": "number",
                          "description": "Price of suggested_sku in USD."
                        },
                        "message": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "x-payment-info": {
          "intent": "charge",
          "method": "stripe",
          "amount": 20,
          "currency": "USD",
          "description": "One-time pass purchase via Stripe Checkout (no subscription). Amount depends on the plan in the request body: sale_pass = 20.00 USD, big_move = 39.00 USD. The response returns a Stripe-hosted checkout URL; a human completes payment in the browser and the account upgrades via webhook."
        }
      }
    },
    "/api/health": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "System"
        ],
        "summary": "Liveness probe",
        "description": "Zero-dependency liveness check. Always 200 if the process is alive.",
        "security": [],
        "responses": {
          "200": {
            "description": "Alive.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "ok"
                      ]
                    },
                    "timestamp": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/status": {
      "get": {
        "operationId": "getStatus",
        "tags": [
          "System"
        ],
        "summary": "Dependency-aware status",
        "description": "Checks Firestore and Stripe connectivity. Returns 'ok' or 'degraded'.",
        "security": [],
        "responses": {
          "200": {
            "description": "Status.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "ok",
                        "degraded"
                      ]
                    },
                    "timestamp": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "version": {
                      "type": "string"
                    },
                    "checks": {
                      "type": "object",
                      "properties": {
                        "firestore": {
                          "type": "boolean"
                        },
                        "stripe": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/conversations/{id}/address/prepare": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Prepare an address share (step 1 of 2)",
        "description": "Resolves who would receive the seller's address in this conversation and returns a single-use confirmation token. DISCLOSES NOTHING: no message is written and the buyer cannot tell this was called. Show the seller the returned recipient name and email and get their explicit yes before calling the confirm endpoint. The address and recipient are frozen here, so nothing sent at step 2 can change either. Only a buyer who verified their email by reserving can receive an address; an enquiry-only conversation returns 403 BUYER_NOT_VERIFIED.",
        "tags": [
          "Orders"
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "address": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Only when the account has no street address on file, or the seller wants a different one for this pickup. Used for this share only and never saved to the account."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recipient resolved and a confirmation token minted. Nothing disclosed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "confirmation_token": {
                          "type": "string"
                        },
                        "expires_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "recipient": {
                          "type": "object",
                          "properties": {
                            "name": {
                              "type": "string"
                            },
                            "email": {
                              "type": "string",
                              "description": "The buyer's email, or an empty string when the conversation has none. Not guaranteed to be a valid address, so do not treat it as one."
                            }
                          }
                        },
                        "address": {
                          "type": "string"
                        },
                        "address_source": {
                          "type": "string",
                          "enum": [
                            "account",
                            "supplied"
                          ]
                        },
                        "conversation_id": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Address supplied but empty or over 500 characters.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid credential.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Read-only key, not your conversation, or BUYER_NOT_VERIFIED: the buyer has only sent an enquiry and has not verified their email, so an agent cannot share an address with them.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Conversation not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "NO_ADDRESS_ON_FILE: no street address stored for this account. Ask the seller and pass it as `address`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many address shares in the last hour.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer",
                  "example": 600
                }
              }
            }
          },
          "500": {
            "description": "Failed to prepare the address share.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "prepareAddressShare",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ]
      }
    },
    "/api/conversations/{id}/reply/prepare": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Prepare a reply to a buyer (step 1 of 2)",
        "description": "Checks the message the way the send would, names who would receive it, freezes the text, and returns a single-use confirmation token valid for 10 minutes. SENDS NOTHING: no message is written and the buyer cannot tell this was called. Show the seller the returned recipient and message, then send with POST /api/conversations/{id} and `confirmation_token`. The text sent is the text frozen here.",
        "tags": [
          "Orders"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "message"
                ],
                "properties": {
                  "message": {
                    "type": "string",
                    "description": "Message text. Truncated to 2000 characters."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Message checked and frozen. Nothing sent.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "confirmation_token": {
                          "type": "string"
                        },
                        "expires_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "conversation_id": {
                          "type": "string"
                        },
                        "recipient": {
                          "type": "object",
                          "properties": {
                            "name": {
                              "type": "string"
                            },
                            "email": {
                              "type": "string",
                              "description": "The buyer's email, or an empty string when the conversation has none."
                            }
                          }
                        },
                        "message": {
                          "type": "string",
                          "description": "The exact text that will be sent."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Message missing or empty.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The credential is read-only, or does not own this conversation, or `code: ADDRESS_USE_SHARE_FLOW`: the message contains the seller's stored street address. Use the address share flow for the address and send other details separately.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "description": "Too many messages prepared by this seller in the last hour.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "operationId": "prepareReplyToBuyer",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "reply_to_buyer"
      }
    },
    "/api/reservations/{id}/pickup-confirm": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Close out a pickup: sold or no-show (step 2 of 2 for an API key)",
        "description": "The seller's \"did this pickup happen?\" action. `sold` marks the reservation's items sold, completes it and emails the buyer. `noshow` cancels the reservation, returns the items to sale, frees any booked slot and advances the queue. Cannot be undone. An API key or OAuth token must send `confirmation_token` from POST /api/reservations/{id}/pickup-confirm/prepare, and the action is taken from that token. A partial close-out answers 500 with `retryable: true` and leaves the reservation active; re-run it with the same token. A reservation that is no longer active answers 200 with `outcome: already_processed` and changes nothing.",
        "tags": [
          "Orders"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "sold",
                      "noshow"
                    ],
                    "description": "Required for the seller's own session or email link. For an API key the action comes from confirmation_token; it may be echoed here and must then match (409 CONFIRMATION_MISMATCH otherwise)."
                  },
                  "confirmation_token": {
                    "type": "string",
                    "description": "Required for an API key or OAuth token. From the prepare step. Reusable for 10 minutes while the reservation is still active, so a partial close-out can be re-run."
                  },
                  "token": {
                    "type": "string",
                    "description": "The token from the seller's pickup-check email. Not used by agents."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Close-out applied, or `outcome: already_processed` when the reservation was no longer active.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "400": {
            "description": "action missing or not sold/noshow (non-agent callers).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Not this reservation's seller, or `code: CONFIRMATION_REQUIRED` when an API key sends no confirmation_token. A confirmation token that is expired, not valid, or prepared for a different conversation or reservation is refused with `code: CONFIRMATION_EXPIRED`, `CONFIRMATION_NOT_FOUND` or `CONFIRMATION_WRONG_TARGET` (the last one says the token is still valid for its own target). Nothing is written.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "`code: idempotency_conflict`: a request with this Idempotency-Key is still being processed; retry the same request with the same key after a few seconds. A 409 without that code is the operation's own refusal: this pickup was already handled from another device or by a concurrent request. Not retryable; read the reservation to see its state. Also `code: CONFIRMATION_MISMATCH` when an API key echoes an `action` that differs from the confirmed one; nothing changed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyKeyReuse"
          },
          "500": {
            "description": "Unexpected error, or a partial close-out with `retryable: true`: re-run with the same confirmation token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "confirmPickup",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "finalize_pickup"
      }
    },
    "/api/reservations/{id}/pickup-confirm/prepare": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Prepare a pickup close-out (step 1 of 2)",
        "description": "Names the buyer, the items and what the chosen outcome will do, and returns a confirmation token valid for 10 minutes. CHANGES NOTHING. Show the seller the buyer, items and `consequence`, then send the token to POST /api/reservations/{id}/pickup-confirm.",
        "tags": [
          "Orders"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "action"
                ],
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "sold",
                      "noshow"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Preview and token. Nothing changed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "confirmation_token": {
                          "type": "string"
                        },
                        "expires_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "reservation_id": {
                          "type": "string"
                        },
                        "action": {
                          "type": "string",
                          "enum": [
                            "sold",
                            "noshow"
                          ]
                        },
                        "buyer": {
                          "type": "object",
                          "properties": {
                            "name": {
                              "type": "string"
                            },
                            "email": {
                              "type": "string"
                            }
                          }
                        },
                        "items": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          },
                          "description": "Item titles, up to 20."
                        },
                        "more_items": {
                          "type": "integer",
                          "description": "How many items were not listed."
                        },
                        "consequence": {
                          "type": "string",
                          "description": "What the action will do, in plain words."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "action missing or not sold/noshow.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The credential is read-only.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such reservation, or not this seller's (the same answer for both).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`code: RESERVATION_NOT_ACTIVE`: the reservation already ended. Nothing to confirm.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many confirmations prepared by this seller in the last hour.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "operationId": "preparePickupConfirm",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "x-tool-id": "confirm_pickup"
      }
    },
    "/api/conversations/{id}/address": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "summary": "Share the address (step 2 of 2)",
        "description": "Sends the seller's address to the buyer. IRREVERSIBLE: the buyer receives it in the thread and by email, and the seller is emailed a record of every share. A first-party session sends `{ address }`. An API key must send `{ confirmation_token }` from the prepare endpoint and NO address — the string was frozen at step 1 and cannot be changed here. An API key arriving without a valid token is refused with CONFIRMATION_REQUIRED.",
        "tags": [
          "Orders"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "confirmation_token": {
                    "type": "string",
                    "description": "Required for API-key callers. From the prepare endpoint."
                  },
                  "address": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "First-party sessions only. Ignored when a confirmation_token is present."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Address sent. The buyer has it and the seller has been emailed a record.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "message_id": {
                          "type": "string"
                        },
                        "type": {
                          "type": "string",
                          "enum": [
                            "address_share"
                          ]
                        },
                        "created_at": {
                          "type": "string",
                          "format": "date-time"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Address missing or over 500 characters (first-party path).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid credential.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Read-only key, not your conversation, CONFIRMATION_REQUIRED (an API key with no token), or INVALID_CONFIRMATION (the token is unknown, expired, already used, minted by another seller, or minted for a different conversation). Note the first two carry no `code`. Only the wrong-conversation case sets `retryable: true`, leaving the token usable; every other reason is terminal and retrying it will not succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Conversation not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyConflict"
          },
          "422": {
            "$ref": "#/components/responses/IdempotencyKeyReuse"
          },
          "429": {
            "description": "Too many confirmation attempts in the last hour.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer",
                  "example": 600
                }
              }
            }
          },
          "500": {
            "description": "Failed to share the address.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "The seller account could not be read, so the share was refused rather than performed without a record.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "shareAddress",
        "security": [
          {
            "apiKey": []
          },
          {
            "oauth2": [
              "write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ]
      }
    }
  }
}
