← All docs
Raw

OpenAPI spec

openapi: 3.0.3
info:
  title: Unbase API
  description: >
    Unbase is a zero-config SQL (SQLite) database over HTTP, built on
    Cloudflare Workers + Durable Objects. `POST /v1/projects` returns a
    project and a bearer token in a single call; that token (the project's
    secret key) is the permanent credential for the project's data plane.


    Each project has two keys:

    - **Secret key** (a.k.a. "service role"), format `<projectId>.<sig>` —
      full SQL read/write plus admin. Send it as `Authorization: Bearer
      <secretKey>`. Never expose it in a browser.

    - **Anon key** (a.k.a. "publishable"), format `<projectId>.pk.<sig>` —
      safe to embed in a client app. It unlocks the project's per-project
      Auth endpoints (`/v1/projects/{id}/auth/*`) and read-only access to
      Storage objects (`/v1/projects/{id}/storage/*`), and is sent in an
      `apikey` header.


    Project ids look like `unbase_xxxxxxxxxxxx`, account ids like `acct_...`,
    and Auth end-user ids like `user_...`.
  version: "2.0.0"
  contact:
    url: https://api.unbase.dev
servers:
  - url: https://api.unbase.dev
    description: Production

tags:
  - name: projects
    description: Create, claim, and delete projects
  - name: sql
    description: Run SQL against a project
  - name: usage
    description: Usage and export
  - name: account
    description: Log in and manage projects
  - name: auth
    description: >
      Per-project Auth service — authenticate the end users of an app built
      on a project. Callers authenticate with the project's anon key in an
      `apikey` header (the secret key works too). Access tokens are HS256 JWTs
      signed with the project's own JWT secret.
  - name: storage
    description: >
      Per-project file storage, backed by Cloudflare R2. Uploads and deletes
      require the secret key; reads (download and list) accept the anon key
      too, sent as `Authorization: Bearer <key>`. Objects are private — there
      is no public/anonymous URL.

paths:
  /v1/projects:
    post:
      operationId: createProject
      tags: [projects]
      summary: Create a new project
      description: >
        Creates a new anonymous project and returns its id, URL, a bearer
        token (the secret key), and an anon key. The token is shown exactly
        once in this response and is not retrievable afterwards. No
        authentication required.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                turnstileToken:
                  type: string
                  description: >
                    Cloudflare Turnstile token from the browser creation
                    flow. Only relevant to web-based abuse prevention;
                    server-to-server/API callers can omit this field.
              additionalProperties: false
      responses:
        "201":
          description: Project created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateProjectResponse"
        "429":
          description: Too many projects created from this IP this hour
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: Shared free-tier capacity temporarily saturated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/claim:
    post:
      operationId: initiateClaim
      tags: [projects]
      summary: Initiate a magic-link claim (step 1 of 2)
      description: >
        Step 1 of the two-step claim flow. Confirms the project exists,
        then mints a signed, 30-minute magic-link token and emails a claim
        link ({SITE_URL}/claim/verify?token=...) to the given address via
        Resend. This does NOT claim the project — the recipient must
        open the link, which drives POST /v1/claim/verify. Requiring the
        email owner to open the link proves control of the address before
        the claim is committed. No authentication required (the caller must
        supply the projectId). When no Resend API key is configured (keyless
        dev mode) no email is sent and the full claim URL is returned inline
        as `devLink`; do not run keyless in production.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, email]
              properties:
                id:
                  type: string
                  description: The projectId to claim
                  example: unbase_7f3k9q2m1x8a
                email:
                  type: string
                  format: email
              additionalProperties: false
      responses:
        "200":
          description: >
            Claim link sent (or, in keyless dev mode, returned inline as
            devLink)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InitiateClaimResponse"
        "400":
          description: Missing id or email
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Project not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/claim/verify:
    post:
      operationId: verifyClaim
      tags: [projects]
      summary: Complete a magic-link claim (step 2 of 2)
      description: >
        Step 2 of the two-step claim flow. Verifies the magic-link token's
        HMAC signature and 30-minute expiry, then permanently attaches the
        anonymous project to the email-based account, upgrading its plan
        from "anonymous" to "free" and removing the 7-day expiry. Creates
        the account if it doesn't already exist (idempotent by email). The
        project's existing bearer token continues to work unchanged. No
        authentication required — the token itself is the proof.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  description: The token from the magic link.
              additionalProperties: false
      responses:
        "200":
          description: Claim completed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClaimResponse"
        "400":
          description: >
            Missing token, or the token is malformed, tampered, or expired
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/auth/login:
    post:
      operationId: requestLogin
      tags: [account]
      summary: Request an account login magic link
      description: >
        Sends a magic link to the given email for the Unbase dashboard /
        account. Verifying it (see /v1/auth/verify) logs the user in,
        creating their account on first login — so this doubles as sign-up.
        This is the account/console login, distinct from the per-project
        Auth service under /v1/projects/{id}/auth. No authentication
        required. In keyless dev mode (no Resend key) the link is returned
        inline as `devLink` instead of being emailed.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
              additionalProperties: false
      responses:
        "200":
          description: Link sent (or returned inline in dev mode)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MagicLinkResponse"
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/auth/verify:
    post:
      operationId: verifyLogin
      tags: [account]
      summary: Complete account login and receive a session token
      description: >
        Verifies an account login magic link's HMAC signature and 30-minute
        expiry, gets-or-creates the account for that email, and returns a
        long-lived (30-day) account session token. Present that token as a
        bearer credential on the /v1/account/* endpoints.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
              additionalProperties: false
      responses:
        "200":
          description: Logged in
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionResponse"
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/account:
    get:
      operationId: getAccount
      tags: [account]
      summary: Get the authenticated account
      security:
        - sessionAuth: []
      responses:
        "200":
          description: Account details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/account/projects:
    get:
      operationId: listAccountProjects
      tags: [account]
      summary: List the account's projects
      description: >
        Returns every project owned by the authenticated account, each with
        a freshly minted working bearer token and anon key (keys are
        deterministic, so this is safe — the session already proves
        ownership).
      security:
        - sessionAuth: []
      responses:
        "200":
          description: The account's projects
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountProjectList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createAccountProject
      tags: [account]
      summary: Create a new project
      description: >
        Creates a new project owned by the account, on the account's plan,
        and returns its endpoint, secret token, and anon key. Enforces the
        plan's max-projects cap.
      security:
        - sessionAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: Optional human-friendly project name (max 64 chars).
              additionalProperties: false
      responses:
        "201":
          description: Project created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreatedProjectResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Plan project limit reached
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/account/projects/{projectId}:
    patch:
      operationId: renameAccountProject
      tags: [account]
      summary: Rename a project
      description: >
        Updates the display name of a project the authenticated account owns.
        The name is trimmed and capped at 64 chars; an empty (or whitespace)
        name clears it back to null, and the dashboard then falls back to the
        project id. Scoped to the account — renaming a project the account
        does not own returns 404.
      security:
        - sessionAuth: []
      parameters:
        - name: projectId
          in: path
          required: true
          schema:
            type: string
          description: The project's id (e.g. `unbase_...`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: New project name (max 64 chars; empty clears it).
              additionalProperties: false
      responses:
        "200":
          description: The updated project id and name
          content:
            application/json:
              schema:
                type: object
                required: [id, name]
                properties:
                  id:
                    type: string
                  name:
                    type: string
                    nullable: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No such project owned by this account
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/stripe/webhook:
    post:
      operationId: stripeWebhook
      tags: [projects]
      summary: Apply paid-plan upgrades/downgrades from Stripe
      description: >
        Receives Stripe webhook events and applies paid-plan changes.
        Authenticated by the Stripe signature (NOT a bearer token): the
        Stripe-Signature header is verified via HMAC-SHA256 against
        STRIPE_WEBHOOK_SECRET with a 5-minute timestamp tolerance. The
        request body is the raw Stripe event JSON. On
        checkout.session.completed the customer email (customer_email or
        customer_details.email) and target plan (metadata.plan, "founder"
        or "pro", set on the Stripe Payment Link) are read and the account
        plus all its projects are upgraded to that plan. On
        customer.subscription.deleted the account is downgraded to "free",
        but only if metadata.email is present on the event.
      security: []
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema:
            type: string
          description: >
            Stripe's `t=...,v1=...` signature over the raw request body.
      requestBody:
        required: true
        description: Raw Stripe event JSON.
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        "200":
          description: >
            Event received. `applied` is true when a plan change was made,
            false for acknowledged-but-no-op events.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StripeWebhookResponse"
        "400":
          description: Bad or missing Stripe signature
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: STRIPE_WEBHOOK_SECRET is not configured
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/query:
    post:
      operationId: runQuery
      tags: [sql]
      summary: Run a single SQL statement
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Statement"
      responses:
        "200":
          description: Statement executed
          headers:
            X-Unbase-Rows-Read:
              schema:
                type: string
              description: Mirrors rowsRead in the response body
            X-Unbase-Rows-Written:
              schema:
                type: string
              description: Mirrors rowsWritten in the response body
            X-Unbase-Limit-Warning:
              schema:
                type: string
                enum: ["true"]
              description: >
                Present only when the account is at or over 100% of its
                plan's monthly quota (read, write, or storage).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QueryResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /v1/projects/{id}/batch:
    post:
      operationId: runBatch
      tags: [sql]
      summary: Run multiple SQL statements as one atomic transaction
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [statements]
              properties:
                statements:
                  type: array
                  minItems: 1
                  items:
                    $ref: "#/components/schemas/Statement"
              additionalProperties: false
      responses:
        "200":
          description: All statements executed (all-or-nothing)
          headers:
            X-Unbase-Rows-Read:
              schema:
                type: string
            X-Unbase-Rows-Written:
              schema:
                type: string
            X-Unbase-Limit-Warning:
              schema:
                type: string
                enum: ["true"]
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

  /v1/projects/{id}/usage:
    get:
      operationId: getUsage
      tags: [usage]
      summary: Get plan, status, and current-month usage
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: Usage snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/export:
    get:
      operationId: exportProject
      tags: [usage]
      summary: Download a plain-SQL logical dump of the project
      description: >
        Returns a plain-text SQL dump (CREATE TABLE + INSERT INTO
        statements wrapped in a transaction) — not a binary .sqlite file.
        Fully replayable with `sqlite3 restored.db < dump.sql`.
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: SQL dump
          headers:
            Content-Disposition:
              schema:
                type: string
                example: 'attachment; filename="unbase_7f3k9q2m1x8a.sql"'
          content:
            application/sql:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/projects/{id}/tables:
    get:
      operationId: listTables
      tags: [sql]
      summary: List the project's tables, columns, and row counts
      description: >
        Schema introspection for building a table browser. Returns user
        tables only — internal bookkeeping (_unbase_*), SQLite (sqlite_*),
        and Cloudflare (_cf_*) tables are excluded.
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: The project's tables
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TablesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}:
    delete:
      operationId: deleteProject
      tags: [projects]
      summary: Permanently delete a project and its exports
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "204":
          description: Deleted (no content)
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/storage/objects:
    get:
      operationId: listStorageObjects
      tags: [storage]
      summary: List a project's storage objects
      security:
        - secretKey: []
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: The project's storage objects
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StorageObjectList"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/storage/objects/{key}:
    put:
      operationId: uploadStorageObject
      tags: [storage]
      summary: Upload (or overwrite) an object
      description: >
        The request body is the raw file bytes — not multipart/form-data.
        `Content-Type` is stored and returned on download; it defaults to
        `application/octet-stream` if omitted. The key may contain `/` to
        namespace objects into folders, e.g. `avatars/user.png`. Capped at
        100 MB per object, subject to the plan's file-storage quota.
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/StorageKey"
      requestBody:
        required: true
        content:
          "*/*":
            schema:
              type: string
              format: binary
      responses:
        "201":
          description: Object stored
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StorageObject"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "413":
          $ref: "#/components/responses/PayloadTooLarge"
    get:
      operationId: downloadStorageObject
      tags: [storage]
      summary: Download an object
      security:
        - secretKey: []
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/StorageKey"
      responses:
        "200":
          description: Object bytes, with the Content-Type stored at upload time
          content:
            "*/*":
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteStorageObject
      tags: [storage]
      summary: Delete an object
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
        - $ref: "#/components/parameters/StorageKey"
      responses:
        "204":
          description: Deleted (no content)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/projects/{id}/auth/signup:
    post:
      operationId: authSignup
      tags: [auth]
      summary: Sign up an end user with email + password
      description: >
        Registers a new end user of the project and returns a Session.
        Authenticate the request with the project's anon key in the `apikey`
        header (the secret key also works). Password must be at least 8
        characters.
      security:
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
                  minLength: 8
              additionalProperties: false
      responses:
        "201":
          description: User created; Session returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Session"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: A user with that email already exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/auth/signin:
    post:
      operationId: authSignin
      tags: [auth]
      summary: Sign in an end user with email + password
      description: >
        Verifies an end user's email + password and returns a Session.
        Authenticate the request with the project's anon key in the `apikey`
        header (the secret key also works).
      security:
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
              additionalProperties: false
      responses:
        "200":
          description: Signed in; Session returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Session"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid credentials or missing/invalid apikey
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/auth/magiclink:
    post:
      operationId: authMagicLink
      tags: [auth]
      summary: Send an end user a passwordless magic link
      description: >
        Emails the end user a passwordless sign-in link pointing at
        `redirectTo?token=...` (falls back to
        `<SITE_URL>/auth/callback?token=...` when redirectTo is omitted). The
        project owner's app then calls .../auth/verify with the token to
        obtain a Session. Authenticate the request with the project's anon
        key in the `apikey` header. In keyless dev mode the link is returned
        inline as `devLink`.
      security:
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                redirectTo:
                  type: string
                  format: uri
                  description: >
                    URL the emailed link points at; the single-use token is
                    appended as a `token` query parameter. Defaults to
                    `<SITE_URL>/auth/callback`.
              additionalProperties: false
      responses:
        "200":
          description: Link sent (or returned inline in dev mode)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MagicLinkResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/auth/verify:
    post:
      operationId: authVerify
      tags: [auth]
      summary: Verify a magic-link token and receive a Session
      description: >
        Completes a passwordless sign-in by verifying the token from a magic
        link and returning a Session. Creates the end user on first verify.
        Authenticate the request with the project's anon key in the `apikey`
        header.
      security:
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
              additionalProperties: false
      responses:
        "200":
          description: Verified; Session returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Session"
        "400":
          description: Missing, malformed, or expired token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/auth/token:
    post:
      operationId: authRefreshToken
      tags: [auth]
      summary: Exchange a refresh token for a new Session
      description: >
        Rotates a refresh token for a fresh Session (new access + refresh
        token). Refresh tokens are single-use — each call invalidates the
        one presented. Authenticate the request with the project's anon key
        in the `apikey` header.
      security:
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken:
                  type: string
              additionalProperties: false
      responses:
        "200":
          description: New Session issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Session"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Invalid, expired, or already-used refresh token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/auth/password:
    post:
      operationId: authChangePassword
      tags: [auth]
      summary: Change the signed-in end user's password
      description: >
        Changes the password of the end user identified by their access token.
        Present the access token as `Authorization: Bearer <accessToken>`
        together with the project's anon key in the `apikey` header. A user who
        already has a password must supply the correct `currentPassword`;
        passwordless (magic-link-only) users omit it to set one for the first
        time. The new password must be at least 8 characters. The change revokes
        the user's other sessions and returns a fresh Session.
      security:
        - userAccessToken: []
          anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [newPassword]
              properties:
                currentPassword:
                  type: string
                  description: >
                    Required when the user already has a password; omitted by
                    passwordless users setting one for the first time.
                newPassword:
                  type: string
                  minLength: 8
              additionalProperties: false
      responses:
        "200":
          description: Password changed; a fresh Session is returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Session"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: Missing/invalid access token or incorrect current password
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /v1/projects/{id}/auth/recover:
    post:
      operationId: authRecover
      tags: [auth]
      summary: Send an end user a password-reset link
      description: >
        Starts a forgot-password flow: emails the end user a reset link
        pointing at `redirectTo?token=...` (falls back to
        `<SITE_URL>/auth/reset?token=...` when redirectTo is omitted). The
        project owner's app then calls .../auth/reset with the token and a new
        password. Authenticate the request with the project's anon key in the
        `apikey` header. Always reports success without revealing whether an
        account exists. In keyless dev mode the link is returned inline as
        `devLink`.
      security:
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                redirectTo:
                  type: string
                  format: uri
                  description: >
                    URL the emailed link points at; the single-use reset token
                    is appended as a `token` query parameter. Defaults to
                    `<SITE_URL>/auth/reset`.
              additionalProperties: false
      responses:
        "200":
          description: Reset link sent (or returned inline in dev mode)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MagicLinkResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/auth/reset:
    post:
      operationId: authResetPassword
      tags: [auth]
      summary: Complete a password reset with a reset token
      description: >
        Completes a forgot-password flow: verifies the token from a reset link,
        sets the new password on the matching account, revokes all prior
        sessions, and returns a fresh Session. The new password must be at
        least 8 characters. Authenticate the request with the project's anon
        key in the `apikey` header.
      security:
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, newPassword]
              properties:
                token:
                  type: string
                newPassword:
                  type: string
                  minLength: 8
              additionalProperties: false
      responses:
        "200":
          description: Password reset; Session returned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Session"
        "400":
          description: >
            Missing/short new password, or a missing, malformed, expired, or
            unknown-account reset token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/auth/user:
    post:
      operationId: authGetUser
      tags: [auth]
      summary: Get the current end user
      description: >
        Returns the end user identified by the access token. Present the
        access token as `Authorization: Bearer <accessToken>` together with
        the project's anon key in the `apikey` header. The request body is an
        empty JSON object.
      security:
        - userAccessToken: []
          anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        "200":
          description: The current end user
          content:
            application/json:
              schema:
                type: object
                required: [user]
                properties:
                  user:
                    $ref: "#/components/schemas/AuthUser"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/auth/logout:
    post:
      operationId: authLogout
      tags: [auth]
      summary: Revoke a refresh token (sign out)
      description: >
        Invalidates the given refresh token so it can no longer be exchanged.
        Authenticate the request with the project's anon key in the `apikey`
        header.
      security:
        - anonApiKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken:
                  type: string
              additionalProperties: false
      responses:
        "204":
          description: Logged out (no content)
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/auth/users:
    get:
      operationId: authListUsers
      tags: [auth]
      summary: List the project's end users (admin)
      description: >
        Admin-only. Returns every end user registered with the project.
        Requires the project's secret key as `Authorization: Bearer
        <secretKey>` — the anon key is rejected here.
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: The project's end users
          content:
            application/json:
              schema:
                type: object
                required: [users]
                properties:
                  users:
                    type: array
                    items:
                      $ref: "#/components/schemas/AuthUser"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/projects/{id}/auth/settings:
    get:
      operationId: authGetSettings
      tags: [auth]
      summary: Get the project's Auth settings (admin)
      description: >
        Admin-only. Returns the project's Auth configuration, including the
        JWT secret used to sign end-user access tokens — so the project owner
        can verify those tokens in their own backend. Requires the project's
        secret key as `Authorization: Bearer <secretKey>`.
      security:
        - secretKey: []
      parameters:
        - $ref: "#/components/parameters/ProjectId"
      responses:
        "200":
          description: Auth settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthSettings"
        "401":
          $ref: "#/components/responses/Unauthorized"

components:
  securitySchemes:
    secretKey:
      type: http
      scheme: bearer
      description: >
        The project's secret key (service role), of the form
        `${projectId}.${signature}`, returned once by POST /v1/projects. It
        is the permanent full-access credential for that project's data
        plane and admin Auth endpoints; a key only authorizes the projectId
        embedded in it. Never expose it in a browser.
    anonApiKey:
      type: apiKey
      in: header
      name: apikey
      description: >
        The project's anon (publishable) key, of the form
        `${projectId}.pk.${signature}`. Safe to embed in a client app. Sent
        in the `apikey` header, it unlocks the per-project Auth endpoints
        only. The secret key is also accepted in this header.
    userAccessToken:
      type: http
      scheme: bearer
      description: >
        An end user's access token — an HS256 JWT signed with the project's
        own JWT secret, returned in a Session by the Auth endpoints. Used on
        POST /v1/projects/{id}/auth/user. Claims:
        { sub: userId, iss: projectId, role: "authenticated", email, iat, exp }.
    sessionAuth:
      type: http
      scheme: bearer
      description: >
        Account session token returned by POST /v1/auth/verify. Long-lived
        (30 days) and scoped to the account, not a single project. Used on
        the /v1/account/* endpoints.

  parameters:
    ProjectId:
      name: id
      in: path
      required: true
      schema:
        type: string
        example: unbase_7f3k9q2m1x8a
      description: The projectId to operate on.
    StorageKey:
      name: key
      in: path
      required: true
      schema:
        type: string
        example: avatars/user.png
      description: >
        The object's key within the project. May contain `/` to namespace
        objects into folders; each path segment must be percent-encoded.

  responses:
    BadRequest:
      description: Malformed request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Unauthorized:
      description: Missing, invalid, or mismatched credential
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Forbidden:
      description: >
        Quota hard-limit exceeded (writes only), account suspended, or the
        statement referenced a reserved _unbase_* table
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: Unknown route or project
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    PayloadTooLarge:
      description: Response would exceed the 2 MB response cap
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    ServiceUnavailable:
      description: >
        Shared free-tier capacity temporarily saturated (circuit
        breaker); writes only, reads unaffected
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  schemas:
    CreateProjectResponse:
      type: object
      required: [projectId, url, token, anonKey]
      properties:
        projectId:
          type: string
          example: unbase_7f3k9q2m1x8a
        url:
          type: string
          format: uri
          example: https://api.unbase.dev/v1/projects/unbase_7f3k9q2m1x8a
        token:
          type: string
          description: >
            The project's secret key. Shown exactly once. Store it
            immediately.
          example: unbase_7f3k9q2m1x8a.AbCdEf123...
        anonKey:
          type: string
          description: >
            The project's anon (publishable) key. Safe to embed in a client
            app; unlocks the project's Auth endpoints only.
          example: unbase_7f3k9q2m1x8a.pk.AbCdEf123...

    InitiateClaimResponse:
      type: object
      required: [sent]
      properties:
        sent:
          type: boolean
          description: >
            True when a claim email was sent (Resend key configured);
            false in keyless dev mode.
        devLink:
          type: string
          format: uri
          description: >
            Present only in keyless dev mode — the full claim URL, returned
            inline because no email was sent.
          example: https://unbase.dev/claim/verify?token=eyJ...

    ClaimResponse:
      type: object
      required: [projectId, accountId, plan]
      properties:
        projectId:
          type: string
          example: unbase_7f3k9q2m1x8a
        accountId:
          type: string
          example: acct_9k2m1x8a7f3k
        plan:
          type: string
          enum: [free]

    MagicLinkResponse:
      type: object
      required: [sent]
      properties:
        sent:
          type: boolean
          description: True when the link was emailed.
        devLink:
          type: string
          description: >
            Present only in keyless dev mode — the link that would have been
            emailed, returned inline so the flow stays usable.

    SessionResponse:
      type: object
      required: [token, accountId, email, plan]
      properties:
        token:
          type: string
          description: The account session bearer token (30-day expiry).
        accountId:
          type: string
          example: acct_9k2m1x8a7f3k
        email:
          type: string
          format: email
        plan:
          type: string
          enum: [free, founder, pro]

    AccountResponse:
      type: object
      required: [accountId, email, plan]
      properties:
        accountId:
          type: string
          example: acct_9k2m1x8a7f3k
        email:
          type: string
          format: email
        plan:
          type: string
          enum: [free, founder, pro]

    AccountProjectSummary:
      type: object
      required: [id, plan, status, sizeBytes, url, token, anonKey]
      properties:
        id:
          type: string
          example: unbase_7f3k9q2m1x8a
        name:
          type: string
          nullable: true
        plan:
          type: string
          enum: [anonymous, free, founder, pro]
        status:
          type: string
          enum: [active, limited, suspended]
        createdAt:
          type: integer
          format: int64
          nullable: true
        lastUsedAt:
          type: integer
          format: int64
          nullable: true
        sizeBytes:
          type: integer
        url:
          type: string
          format: uri
          example: https://api.unbase.dev/v1/projects/unbase_7f3k9q2m1x8a
        token:
          type: string
          description: The project's secret key.
        anonKey:
          type: string
          description: The project's anon (publishable) key.

    AccountProjectList:
      type: object
      required: [projects]
      properties:
        projects:
          type: array
          items:
            $ref: "#/components/schemas/AccountProjectSummary"

    CreatedProjectResponse:
      type: object
      required: [projectId, url, token, anonKey, plan]
      properties:
        projectId:
          type: string
          example: unbase_7f3k9q2m1x8a
        name:
          type: string
          nullable: true
        url:
          type: string
          format: uri
          example: https://api.unbase.dev/v1/projects/unbase_7f3k9q2m1x8a
        token:
          type: string
          description: The project's secret key.
        anonKey:
          type: string
          description: The project's anon (publishable) key.
        plan:
          type: string
          enum: [free, founder, pro]

    TablesResponse:
      type: object
      required: [tables]
      properties:
        tables:
          type: array
          items:
            type: object
            required: [name, rowCount, columns]
            properties:
              name:
                type: string
              rowCount:
                type: integer
              columns:
                type: array
                items:
                  type: object
                  required: [name, type, notnull, pk]
                  properties:
                    name:
                      type: string
                    type:
                      type: string
                    notnull:
                      type: boolean
                    pk:
                      type: boolean

    StorageObject:
      type: object
      required: [key, size, contentType, createdAt, updatedAt]
      properties:
        key:
          type: string
          example: avatars/user.png
        size:
          type: integer
          description: Size in bytes.
        contentType:
          type: string
          example: image/png
        createdAt:
          type: integer
          description: Unix ms timestamp of first upload.
        updatedAt:
          type: integer
          description: Unix ms timestamp of the most recent upload (same as createdAt if never overwritten).

    StorageObjectList:
      type: object
      required: [objects, totalBytes, count]
      properties:
        objects:
          type: array
          items:
            $ref: "#/components/schemas/StorageObject"
        totalBytes:
          type: integer
          description: Sum of size across all of the project's objects.
        count:
          type: integer

    StripeWebhookResponse:
      type: object
      required: [received, applied]
      properties:
        received:
          type: boolean
          example: true
        applied:
          type: boolean
          description: >
            True when a plan change was applied; false for
            acknowledged-but-no-op events.
        plan:
          type: string
          description: The plan applied (present only when applied is true).
          enum: [free, founder, pro]
        projects:
          type: integer
          description: >
            Number of projects updated (present only when applied is true).

    Statement:
      type: object
      required: [sql]
      properties:
        sql:
          type: string
          example: "SELECT * FROM todos WHERE id = ?"
        params:
          type: array
          description: Positional values bound to `?` placeholders.
          items: {}
      additionalProperties: false

    QueryResult:
      type: object
      required: [rows, rowsRead, rowsWritten]
      properties:
        rows:
          type: array
          items:
            type: object
            additionalProperties: true
        rowsRead:
          type: integer
        rowsWritten:
          type: integer

    BatchResult:
      type: object
      required: [results, rowsRead, rowsWritten]
      properties:
        results:
          type: array
          items:
            $ref: "#/components/schemas/QueryResult"
        rowsRead:
          type: integer
          description: Sum of rowsRead across all statements
        rowsWritten:
          type: integer
          description: Sum of rowsWritten across all statements

    UsageResponse:
      type: object
      required:
        [plan, status, sizeBytes, usageMonth, rowsRead, rowsWritten]
      properties:
        plan:
          type: string
          enum: [anonymous, free, founder, pro]
        status:
          type: string
          enum: [active, limited, suspended]
        sizeBytes:
          type: integer
        usageMonth:
          type: string
          description: Calendar month these counters apply to, YYYY-MM.
          example: "2026-07"
        rowsRead:
          type: integer
          description: Row reads so far this calendar month.
        rowsWritten:
          type: integer
          description: Row writes so far this calendar month.

    AuthUser:
      type: object
      required: [id, email, createdAt]
      properties:
        id:
          type: string
          example: user_3m1x8a7f3k9q
        email:
          type: string
          format: email
        emailConfirmedAt:
          type: string
          format: date-time
          nullable: true
          description: >
            When the email was confirmed (e.g. via magic link). Null until
            confirmed.
        createdAt:
          type: string
          format: date-time
        lastSignInAt:
          type: string
          format: date-time
          nullable: true

    Session:
      type: object
      required:
        [accessToken, tokenType, expiresIn, expiresAt, refreshToken, user]
      description: >
        Returned by the Auth signup/signin/verify/token endpoints. The
        accessToken is an HS256 JWT signed with the project's own JWT secret
        (retrievable via .../auth/settings), so the project owner can verify
        end-user tokens in their own backend. Claims:
        { sub: userId, iss: projectId, role: "authenticated", email, iat, exp }.
      properties:
        accessToken:
          type: string
          description: HS256 JWT signed with the project's JWT secret.
        tokenType:
          type: string
          enum: [bearer]
        expiresIn:
          type: integer
          description: Access-token lifetime in seconds.
          example: 3600
        expiresAt:
          type: integer
          format: int64
          description: Unix epoch (seconds) at which the access token expires.
        refreshToken:
          type: string
          description: Single-use refresh token; rotated on each /auth/token call.
        user:
          $ref: "#/components/schemas/AuthUser"

    AuthSettings:
      type: object
      required: [jwtSecret, userCount, anonKey, authUrl]
      properties:
        jwtSecret:
          type: string
          description: >
            The project's JWT secret used to sign end-user access tokens.
            Use it to verify HS256 access-token signatures in your backend.
        userCount:
          type: integer
          description: Number of end users registered with the project.
        anonKey:
          type: string
          description: The project's anon (publishable) key.
        authUrl:
          type: string
          format: uri
          description: Base URL of the project's Auth service.
          example: https://api.unbase.dev/v1/projects/unbase_7f3k9q2m1x8a/auth

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - bad_request
                - unauthorized
                - forbidden
                - not_found
                - conflict
                - payload_too_large
                - rate_limited
                - service_unavailable
                - internal_error
            message:
              type: string