openapi: 3.1.0

info:
  title: Facturia bootstrap API
  version: "1.0.0"
  summary: Create an account and mint the first `sk_` key. The cold start for the Facturia v1 API.
  description: |
    THE SIX OPERATIONS BETWEEN "NO ACCOUNT" AND "A WORKING KEY", and nothing else.

    Every operation in the main contract — https://docs.facturia.cl/openapi.yaml — authenticates
    with an `sk_test_` / `sk_live_` key, and a brand-new account has none. This document describes
    exactly the surface that produces the first one:

        POST /account/register   →  a 1-hour account session token
        POST /dashboard/api_keys →  an `sk_test_` key, with that token
        (then everything in the main contract)

    WHY THIS IS A SEPARATE DOCUMENT rather than a few extra paths in the main one. The two surfaces
    disagree about three things a single OpenAPI document cannot express twice:

      * BASE URL. `/v1` is served at `https://api.facturia.cl/v1`; this surface at
        `https://api.facturia.cl`. OpenAPI's root `servers` applies to EVERY path in the document,
        so one document with both would declare `https://api.facturia.cl/v1/account/register` and
        `https://api.facturia.cl/documents` — two URLs that 404 — as valid. Per-path `servers`
        overrides exist and are ignored or mishandled by a large share of generators, which is the
        same failure with an extra step.
      * CREDENTIAL. Every `/v1` operation declares `bearerAuth: sk_…`. These take an account JWT,
        or nothing at all. A document-level `security` cannot mean both.
      * ERROR ENVELOPE. `/v1` answers §5.5's `{"error": {...}}`. `/account/*` answers the engine's
        older `{"status", "message", "data", "errors"}`. `/dashboard/*` answers §5.5's, like `/v1`.
        That is documented below per path, because it is true per path.

    So: two contracts, published side by side, each honest about one surface. Generate a client
    from this one to get a key; generate a client from the main one to use the product.

    LANGUAGE, ONCE, FOR THE WHOLE DOCUMENT (#512). Two languages, and which one a field carries is
    a property of WHO READS IT:

      * ENGLISH — everything a program consumes: field names, enum values, `error.code`,
        `error.message` and `error.hint` on the `/dashboard/*` paths, and the `errors[]` codes on
        `/account/*`. Branch on those; do not branch on prose.
      * CHILEAN SPANISH — every string written for the PERSON: the envelope `message` on every
        `/account/*` response, and every field of `details.pending_action` («abre el correo que te
        mandamos…»). They are meant to be relayed VERBATIM to the human they address, not
        translated and not paraphrased.

    A field whose description says «Spanish» is in the second group. Anything unmarked is in the
    first.

    PUBLISHED AT https://docs.facturia.cl/openapi-bootstrap.yaml, and as JSON at
    https://docs.facturia.cl/openapi-bootstrap.json. Both are generated from the same committed
    file in the same build, so they cannot drift, and neither is published until every operation
    here has been probed against the real router.

    `info.version` follows the same rule as the main contract: released semver, MINOR for
    additions, MAJOR only alongside the twelve-month withdrawal notice of `public-api-spec.md` §5.8.

    WHAT IS DELIBERATELY NOT HERE:

      * `POST /dashboard/auth/session` exists, and is the Supabase magic-link exchange — it takes a
        Supabase access token, not an email and password. It is a browser door. An agent has no way
        to obtain that token, so describing it here would be an invitation to a dead end.
      * `DELETE /account` exists and erases the account (Ley 21.719 suppression). It is not part of
        getting a key, it requires the account password AND the account's own e-mail as a typed
        confirmation, and it is irreversible. It is specified in `public-api-spec.md`, not here.
      * The rest of `/dashboard/*` — empresas, documents, received documents, usage, certificates —
        is a narrow read surface for the browser panel. Everything it serves, `/v1` serves too,
        with a credential built for a server. Use the main contract.

    THE TWO REQUEST-ID HEADERS, because they are genuinely two. `/account/*` answers
    `X-Request-Id` (the engine's correlation id). `/dashboard/*` answers `X-Request-Id` AND
    `Facturia-Request-Id` (`req_…`), and it is the `Facturia-Request-Id` that support asks for,
    the same as on `/v1`. Quoting either is better than quoting neither.

    The prose for all of this is `public-api-spec.md` §3.7 and §3.8.
  termsOfService: https://facturia.cl/legal/terminos
  contact:
    name: Facturia developer support
    url: https://docs.facturia.cl
    email: dev@facturia.cl
  license:
    name: Documentation licensed CC BY 4.0
    url: https://creativecommons.org/licenses/by/4.0/

servers:
  - url: https://api.facturia.cl
    description: Production. NOTE the absence of `/v1` — that prefix belongs to the other contract.

tags:
  - name: Account
    description: |
      Create an account, sign in, and confirm the e-mail address. Unauthenticated except where
      noted. These answer the engine's envelope: `{"status", "message", "data", "errors"}` — the
      payload is under `data`, and `errors` is `null` on success and an array of strings on failure.
  - name: Keys
    description: |
      Mint, list, roll and revoke API keys with an account session. Same router as
      `/v1/api_keys`, so the objects and the error envelope are §5.5's, identical to the main
      contract. What differs is the credential: a session instead of a root key, which is the whole
      point — a root key is what you do not have yet.

paths:

  # ------------------------------------------------------------------ Account

  /account/register:
    post:
      tags: [Account]
      operationId: registerAccount
      security: []
      summary: Create an account and get a session token
      description: |
        THE FIRST CALL. No browser, no key, no prior state.

        `email` and `password` are the only required fields; `password` must be at least 8
        characters. The response carries a session token good for one hour — that token is what
        `POST /dashboard/api_keys` takes.

        Registering does NOT require confirming the e-mail. Verification gates exactly one thing:
        minting a **live** key (§3.8). Test mode, and everything in it, is open immediately — so an
        agent can go from this call to an emitted sandbox DTE without a human reading a mailbox.

        `verificationEmailSent: false` means the confirmation mail could not be sent and the account
        was created anyway. It is reported rather than thrown: a mail provider being down must not
        be able to fail a signup.

        THE ONE-CALL BOOTSTRAP. Send `bootstrap` and this SAME 201 also carries a minted
        `sk_test_…` key and, optionally, a sandbox empresa — so the whole cold start is one request
        instead of three, and no agent has to hold a human's password to re-mint after the session's
        hour is up:

        ```bash
        curl -sS https://api.facturia.cl/account/register \
          -H "Content-Type: application/json" \
          -d '{
                "email": "agente@ejemplo.cl",
                "password": "una-contraseña-larga",
                "bootstrap": {
                  "sandbox_key": {"name": "primera llave"},
                  "empresa": {"rut": "76000002-7", "legal_name": "Mi Empresa SpA"}
                }
              }'
        ```

        `data.api_key.secret` is the key. **It is returned exactly once.** It is always `sk_test_`:
        there is no `live` variant of this block and there never will be one, because minting a live
        key is the single chokepoint e-mail verification exists to hold (§3.8).

        `data.empresa` is `null` when `bootstrap.empresa` was not asked for — and also when it was
        asked for and refused, in which case `data.empresa_error` says why (a RUT already registered
        on another account is the realistic case). Reported rather than thrown: the account and the
        key are real, and re-sending `register` to fix an empresa would only answer `409`.

        `data.next` names the one call that follows, headers included, and it is computed from what
        the response ACTUALLY contains: `create_empresa` when `data.empresa` is null (whether it was
        refused or never asked for), `emit_sandbox_document` only when both the key and the empresa
        are there. Until 2026-09-04 a refused empresa still produced `emit_sandbox_document`, and
        following it answered `403 empresa_not_allowed` on an account that had no empresa (#457).

        `details.pending_action` names the human act that is still outstanding — confirming the
        address — with the endpoint that resends the mail and the one that says when it is done.
        Nothing in test mode waits on it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password:
                  type: string
                  minLength: 8
                  description: At least 8 characters. Stored as bcrypt; never returned.
                name: { type: string, description: The person's name. Optional. }
                companyName: { type: string, description: "Free text, NOT a RUT. Optional." }
                bootstrap: { $ref: "#/components/schemas/BootstrapRequest" }
      responses:
        "201":
          description: Account created. `data.token` is the session.
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [account, token, expiresIn, next]
                        properties:
                          account: { $ref: "#/components/schemas/RegisteredAccount" }
                          token: { $ref: "#/components/schemas/SessionToken" }
                          expiresIn: { type: string, const: "1h" }
                          emailVerified: { type: boolean, const: false }
                          verificationEmailSent:
                            type: boolean
                            description: Whether the confirmation mail actually left the building.
                          api_key:
                            description: |
                              The minted `sk_test_` key, `secret` included — present only when
                              `bootstrap` was sent, `null` otherwise. The secret appears here and
                              nowhere else, ever.
                            oneOf:
                              - type: "null"
                              - allOf:
                                  - $ref: "#/components/schemas/ApiKey"
                                  - type: object
                                    required: [secret]
                                    properties:
                                      secret:
                                        type: string
                                        examples: ["sk_test_9f2c1d4b7a6e5038c9b1d2e3f4a5b6c7"]
                          empresa:
                            description: |
                              The sandbox empresa, `mode: test`, born ready to emit — present only
                              when `bootstrap.empresa` was sent AND accepted. Same object the main
                              contract's `POST /v1/empresas` returns.
                            oneOf:
                              - type: "null"
                              - $ref: "#/components/schemas/Empresa"
                          empresa_error:
                            description: |
                              Why `empresa` is null despite being asked for. `null` otherwise.
                              `empresa_rut_claimed` means that RUT is registered on another account.
                            oneOf:
                              - type: "null"
                              - $ref: "#/components/schemas/BootstrapError"
                          next: { $ref: "#/components/schemas/Next" }
        "400":
          description: |
            Missing `email`/`password`, a password under 8 characters, a body that is not JSON, or a
            malformed `bootstrap` block (`errors: ["invalid_bootstrap"]`). A refused `bootstrap`
            creates NOTHING — no account, no key, no empresa — so the call is safe to fix and retry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "409":
          description: |
            An account with this e-mail already exists. `errors` is `["account_exists"]` and `next`
            is `{"method": "POST", "url": "/account/login"}` — sign in instead.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "429": { $ref: "#/components/responses/AuthThrottled" }

  /account/login:
    post:
      tags: [Account]
      operationId: loginAccount
      security: []
      summary: Exchange e-mail and password for a session token
      description: |
        The same 1-hour token `POST /account/register` returns, for an account that already exists.

        ONE REFUSAL FOR BOTH FAILURES. A wrong password and an unknown address answer the identical
        `401`, and take the identical time to do it. Do not read anything into either.

        Signing in is never blocked by an unverified e-mail (§3.8); `emailVerified` rides along so a
        client can show the state without a second call.

        `data.next` says what a session is FOR. It lives an hour and cannot be refreshed, so the
        only reason to hold one is to mint an `sk_` key — and the response that hands one out is the
        right place to say so, `Facturia-Mode` included.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
      responses:
        "200":
          description: Signed in.
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        required: [token, expiresIn, next]
                        properties:
                          token: { $ref: "#/components/schemas/SessionToken" }
                          expiresIn: { type: string, const: "1h" }
                          emailVerified: { type: boolean }
                          account: { $ref: "#/components/schemas/SignedInAccount" }
                          next: { $ref: "#/components/schemas/Next" }
        "400":
          description: Missing `email`/`password`, or a body that is not JSON.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "401":
          description: Wrong password, unknown address, or a deactivated account — indistinguishable on purpose.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "429": { $ref: "#/components/responses/AuthThrottled" }

  /account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Read the account behind this session
      description: |
        The question worth asking between registering and minting a LIVE key: is the address
        confirmed yet? `email_verified` answers it without signing in again, and
        `email_verification` answers the two follow-ups: WHEN the mail was sent and whether it was
        actually DELIVERED — so an agent can tell «they have not clicked» from «it never left the
        building» and resend without asking a person for anything (#485). While it is outstanding
        the whole handoff is repeated here, in `email_verification.pending_action` and in the
        envelope's own `details.pending_action`.

        `empresa_subscriptions` summarises billing: `billable` is how many live (non-sandbox)
        empresas exist on the account, `covered` how many of those may emit live right now. The
        plan is per EMPRESA, so those two numbers are the honest form of "am I paid up".
      responses:
        "200":
          description: The account.
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    required: [data]
                    properties:
                      data: { $ref: "#/components/schemas/AccountDetail" }
        "401":
          description: No session, an expired one, or a deactivated account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "404":
          description: The session is valid but its account row is gone.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }

  /account/verify:
    post:
      tags: [Account]
      operationId: verifyAccountEmail
      security: []
      summary: Confirm the e-mail address with the token from the link
      description: |
        PUBLIC ON PURPOSE, and it takes no session: whoever clicked has a link, possibly on another
        device. The token IS the credential — single-use, 24 hours, bound to the address it was
        sent to.

        Confirming twice is a SUCCESS, not an error: `alreadyVerified: true`, status `200`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  description: From the confirmation link. Not a session token.
      responses:
        "200":
          description: Confirmed (or already was).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        properties:
                          accountId: { type: string }
                          emailVerified: { type: boolean, const: true }
                          alreadyVerified: { type: boolean }
        "400":
          description: |
            The token is invalid, expired, already used, or the account's address changed after it
            was sent. The four have distinct messages, because the remedy differs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "429": { $ref: "#/components/responses/AuthThrottled" }

  /account/password/reset_request:
    post:
      tags: [Account]
      operationId: requestPasswordReset
      security: []
      summary: Ask for a password-reset link
      description: |
        THE DOOR THAT DID NOT EXIST (#524). `/entrar` offered «Entrar» and «Crea tu cuenta» and
        nothing else; a person who lost their password had no way back in and no support address on
        the screen. The session lives one tab, so the password is typed often.

        **ALWAYS `202`, WHATEVER THE ADDRESS IS.** A `404` — or a different message, or a
        measurably different latency — for an unknown address is a customer-list oracle that needs
        no credential at all. The status and the body are decided before we look anything up. So
        this response tells you the request was accepted and nothing about whether the account
        exists; that is the point, and no future version will make it more informative.

        When the account does exist, a mail goes to it carrying
        `https://app.facturia.cl/restablecer?token=…`. The link lives **60 minutes** and is single
        use, and issuing one invalidates every other live link for that account.

        Throttled on its own bucket (10/min per address, 5/min per e-mail): this endpoint makes us
        send mail on demand.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "202":
          description: |
            Accepted. Says nothing about whether an account exists — see the description.

            **This response is NOT the `/account/*` legacy envelope.** It is the flat object shape,
            like `/v1`: the pair is new, has no legacy consumer, and this document declares the
            envelope per path precisely because it differs per path.
          content:
            application/json:
              schema:
                type: object
                required: [object, status]
                properties:
                  object: { type: string, const: password_reset_request }
                  status: { type: string, const: accepted }
        "429": { $ref: "#/components/responses/AuthThrottled" }

  /account/password/reset:
    post:
      tags: [Account]
      operationId: resetPassword
      security: []
      summary: Set a new password with the token from the link
      description: |
        PUBLIC ON PURPOSE, and it takes no session: the caller is by construction someone who
        cannot sign in. The token IS the credential — single use, 60 minutes, pinned to the address
        it was mailed to, so an address change after the mail went out does not leave an old
        mailbox holding a way in.

        On success every OTHER live reset link for that account is invalidated. Existing session
        JWTs are stateless and stay valid until they expire, within the hour — stated rather than
        claimed otherwise.

        `password` follows the same minimum as `register`: 8 characters.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: { type: string, description: From the link. Not a session token. }
                password: { type: string, minLength: 8 }
      responses:
        "200":
          description: The password was changed. Sign in with it at `POST /account/login`.
          content:
            application/json:
              schema:
                type: object
                required: [object, id, email]
                properties:
                  object: { type: string, const: account }
                  id: { type: string }
                  email: { type: string, format: email }
        "400":
          description: |
            `password_reset_token_invalid` — expired, already used, never existed, or the
            account's address changed after the mail went out. **Which one is deliberately not
            published**: distinguishing them is a probe oracle over a bearer credential and the
            remedy is the same in all four — ask for a new link. Or `invalid_field` with
            `param: "password"` for a password under 8 characters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/AuthThrottled" }

  /account/verification/resend:
    post:
      tags: [Account]
      operationId: resendVerificationEmail
      summary: Send the confirmation mail again
      description: |
        SESSION-AUTHENTICATED, unlike the confirm above. A public resend keyed on an address would
        be a way to make us mail anyone on demand; keyed on the session, the only reachable address
        is the one already signed in.

        Unlike registration, this one FAILS when the mail fails (`502`). The caller pressed "send
        it again", and answering `200` while nothing left the building is the worst available reply.

        No body.
      responses:
        "200":
          description: |
            Sent (`sent: true`), or the address was already confirmed and nothing needed sending
            (`emailVerified: true, sent: false`).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: object
                        properties:
                          emailVerified: { type: boolean }
                          sent: { type: boolean }
        "401":
          description: No session, or an expired one.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "404":
          description: The session is valid but its account row is gone.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "429":
          description: Resends are cooled down. `Retry-After` says for how long.
          headers:
            Retry-After: { schema: { type: integer }, description: Seconds. }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }
        "502":
          description: The mail provider refused or is down. Retry in a few minutes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeError" }

  # --------------------------------------------------------------------- Keys

  /dashboard/api_keys:
    get:
      tags: [Keys]
      operationId: listBootstrapApiKeys
      summary: List the account's API keys
      description: |
        Metadata only — a secret is returned exactly once, at creation, and is never readable again.

        FILTERED BY `Facturia-Mode`. A session in `test` sees only test keys, a session in `live`
        only live ones, and the roll and revoke below apply the same filter — so the inventory and
        the two destructive buttons beside it cannot disagree about what this session can reach.
      parameters:
        - $ref: "#/components/parameters/ModeHeader"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A cursor-paginated list of keys.
          headers:
            Facturia-Request-Id: { $ref: "#/components/headers/FacturiaRequestId" }
            RateLimit-Limit: { schema: { type: integer } }
            RateLimit-Remaining: { schema: { type: integer } }
            RateLimit-Reset: { schema: { type: integer } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ApiKey" }
        "400": { $ref: "#/components/responses/V1BadRequest" }
        "401": { $ref: "#/components/responses/V1Unauthorized" }
        "429": { $ref: "#/components/responses/V1RateLimited" }
    post:
      tags: [Keys]
      operationId: createBootstrapApiKey
      summary: Mint the first API key
      description: |
        THE CALL THIS WHOLE DOCUMENT EXISTS FOR. With the session token from register or login:

        ```bash
        curl -sS https://api.facturia.cl/dashboard/api_keys \
          -H "Authorization: Bearer $TOKEN" \
          -H "Facturia-Mode: test" \
          -H "Content-Type: application/json" \
          -d '{
                "name": "primera llave",
                "scopes": ["documents:read","documents:write","empresas:read","empresas:write","webhooks:read","webhooks:write"],
                "empresas": ["*"]
              }'
        ```

        THOSE SIX SCOPES ARE THE CANONICAL FIRST-KEY SET, and the example carries all six on
        purpose. A first key minted with only `documents:read`/`documents:write` cannot reach
        `POST /v1/empresas` — so the very next step this document promises ("go from this call to an
        emitted sandbox DTE") dies on `403 insufficient_scope`, and fixing it needs the session
        again, which by then may have expired. Ask for what the next five minutes need.
        `POST /account/register`'s `bootstrap` block defaults to exactly this set.

        `secret` in the response is the `sk_test_…` (or `sk_live_…`) key. **It is returned exactly
        once.** Store it before doing anything else.

        THE RULES, all enforced, all answering `403 privilege_escalation` naming the offending
        value:

          * MODE IS INHERITED FROM THE HEADER, never chosen in the body. `Facturia-Mode: test`
            mints `sk_test_`, `live` mints `sk_live_`. Sending `mode` in the body is refused unless
            it already equals the header's. There is no path from a test credential to a live one.
          * `scopes` is REQUIRED and non-empty — except with `root: true`, below. `keys:write` can
            never be granted as a scope: that would create a second minting authority wearing no
            prefix at all, which is worse than a root key because nothing about the credential says
            so.
          * `empresas` defaults to the session's reach (every empresa on the account). `["*"]` says
            the same thing explicitly; a list of RUTs narrows the key to those.

        THE ROOT KEY. `{"root": true, "name": "…"}` mints a ROOT key — `sk_test_root_…` /
        `sk_live_root_…`, the credential `POST /v1/api_keys` takes (§3.6). It is the way an
        integrator stops holding a human's password: a root key does not expire in an hour, and it
        is the only long-lived credential that can mint keys per client company.

          * ONLY AN ACCOUNT SESSION MAY MINT ONE. A key — root or ordinary — asking for
            `root: true` is `403 privilege_escalation`: delegation stays one level deep and root
            keys cannot mint root keys.
          * ITS SHAPE IS FIXED, NOT COMPOSED. `scopes` is exactly `["keys:write"]`. Send that field
            with a different value and the call is `400 invalid_field` rather than a silent
            override — a key that does none of what you asked for is not something anyone reads a
            response closely enough to notice.
          * ITS EMPRESA REACH IS THE SESSION'S: `["*"]`, every empresa on the account, present and
            future. That is what makes it usable — the mint guardrail subset-checks a minted key's
            `empresas` against the MINTER's, so a root key that reached nothing could mint nothing
            that reaches anything. It cannot USE that reach (`keys:write` is its only scope); the
            keys it mints can. `empresas` on a root mint may be absent, `[]` or `["*"]`; a narrower
            list is `400 invalid_field` — narrow the keys it mints, not the root.
          * WHAT IT CAN THEN MINT: any scope but `keys:write`, for `["*"]` or for any RUT the
            account owns, in its own mode. That is the whole point of holding one.
          * IT KEEPS EVERY ROOT GUARD. A root key cannot emit, read documents or touch tax data
            (`403 root_key_scope_denied` on any other scope), and it cannot mint another root key.
            Revoking a leaked one is `DELETE /dashboard/api_keys/{key_id}`, from a session — which
            is why that operation exists.

        MINTING A LIVE KEY NEEDS A CONFIRMED E-MAIL — `403 email_verification_required` otherwise.
        This is the one and only gate verification applies (§3.8); test keys are unaffected, and the
        refusal carries `error.details.pending_action` with the endpoint that resends the mail and
        the one that reports when it is done.

        Idempotent under `Idempotency-Key`, like every mutating `/v1` call.
      parameters:
        - $ref: "#/components/parameters/ModeHeader"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  description: Required and non-empty. What the key inventory will call it.
                scopes:
                  type: array
                  minItems: 1
                  description: |
                    Required and non-empty for an ordinary key; omit it (or send exactly
                    `["keys:write"]`) with `root: true`. `keys:write` is never grantable to an
                    ordinary key. The canonical first-key set is the six in the example above.
                  items: { $ref: "#/components/schemas/Scope" }
                root:
                  type: boolean
                  default: false
                  description: |
                    Mint a ROOT key instead of an ordinary one. Accepted ONLY from an account
                    session; a key asking for it is `403 privilege_escalation`. A root key carries
                    exactly `["keys:write"]`, inherits the session's `["*"]` empresa reach so the
                    keys it mints can address the account's empresas, and is the credential
                    `POST /v1/api_keys` takes.
                empresas:
                  type: array
                  description: |
                    Allowlist. `["*"]` = every empresa on the account, present and future — the
                    default when the field is absent. Otherwise canonical RUTs (`76000002-7`).
                    With `root: true` it must be absent, `[]` or `["*"]`: a root key inherits the
                    whole account reach and cannot be narrowed.
                  items: { type: string }
                mode:
                  description: |
                    Accepted only when it equals `Facturia-Mode`. Present so a caller that sends it
                    is refused loudly rather than silently getting the header's mode.
                  $ref: "#/components/schemas/Mode"
      responses:
        "201":
          description: Key minted. `secret` appears here and nowhere else, ever.
          headers:
            Facturia-Request-Id: { $ref: "#/components/headers/FacturiaRequestId" }
            Facturia-Idempotent-Replay:
              schema: { type: boolean }
              description: "`true` when this is a replay of an earlier identical request."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ApiKey"
                  - type: object
                    required: [secret]
                    properties:
                      secret:
                        type: string
                        examples: ["sk_test_9f2c1d4b7a6e5038c9b1d2e3f4a5b6c7"]
                        description: The bearer token for the main contract. Returned once.
        "400":
          description: |
            Malformed input — `invalid_field` for a `root: true` request that also sent `scopes`
            other than `["keys:write"]` or an `empresas` list narrower than `["*"]`,
            `invalid_scope` for a scope that is not one, and `missing_mode` when a session omits
            `Facturia-Mode` (#485: it is not defaulted here, because a key's mode is immutable).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/V1Unauthorized" }
        "403":
          description: |
            `privilege_escalation` (a scope, mode or RUT beyond what a session may grant, or
            `root: true` asked for by a KEY rather than a session) or
            `email_verification_required` (a live key from an unconfirmed address). The second one
            carries `error.details.pending_action`: who has to act, how to resend the mail, and how
            to find out it is done.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VerificationRequiredError" }
        "409": { $ref: "#/components/responses/V1Conflict" }
        "429": { $ref: "#/components/responses/V1RateLimited" }

  /dashboard/api_keys/{key_id}:
    parameters:
      - $ref: "#/components/parameters/KeyId"
    delete:
      tags: [Keys]
      operationId: revokeBootstrapApiKey
      summary: Revoke a key
      description: |
        Immediate and unrecoverable — no grace window, unlike a roll. This is also the ONLY way to
        revoke a leaked root key, since a root key cannot mint or revoke another root key.

        Subject to the same `Facturia-Mode` filter as the list: a key this session cannot see, it
        cannot revoke, and the refusal is `404`.
      parameters:
        - $ref: "#/components/parameters/ModeHeader"
      responses:
        "204": { description: Revoked. }
        "401": { $ref: "#/components/responses/V1Unauthorized" }
        "404": { $ref: "#/components/responses/V1NotFound" }
        "429": { $ref: "#/components/responses/V1RateLimited" }

  /dashboard/api_keys/{key_id}/roll:
    parameters:
      - $ref: "#/components/parameters/KeyId"
    post:
      tags: [Keys]
      operationId: rollBootstrapApiKey
      summary: Roll a key's secret, keeping the old one alive for 24 hours
      description: |
        The replacement is returned as `secret`; the PREVIOUS secret keeps working for 24 hours, so
        a consumer can deploy the new one without a coordinated cutover.

        `409 api_key_roll_conflict` means somebody rolled this key concurrently. The secret from
        the OTHER roll is the live one — fetch the key again rather than retrying blindly.
      parameters:
        - $ref: "#/components/parameters/ModeHeader"
      responses:
        "200":
          description: Rolled.
          headers:
            Facturia-Request-Id: { $ref: "#/components/headers/FacturiaRequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ApiKey"
                  - type: object
                    required: [secret]
                    properties:
                      secret: { type: string, description: The new secret. Returned once. }
        "401": { $ref: "#/components/responses/V1Unauthorized" }
        "404": { $ref: "#/components/responses/V1NotFound" }
        "409":
          description: "`api_key_roll_conflict` — a concurrent roll won. Re-read the key."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/V1RateLimited" }

components:

  securitySchemes:
    accountSession:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        The 1-hour account session from `POST /account/register` or `POST /account/login`.

        NOT an `sk_` key, and the two are not interchangeable in either direction: an `sk_` key is
        refused here, and a session is refused by the main contract's `/v1`. Sessions are hourly
        and are the wrong thing to store in a server-side integration — that is what the key you
        mint below is for.

  headers:
    XRequestId:
      schema: { type: string }
      description: The engine's correlation id, on every `/account/*` response.
    FacturiaRequestId:
      schema: { type: string, examples: ["req_01K2R7Q9M4T2VB"] }
      description: |
        On every `/dashboard/*` response, success or failure. This is the one support asks for.

  parameters:
    ModeHeader:
      name: Facturia-Mode
      in: header
      required: true
      description: |
        `test` or `live`. A session has no `sk_test_`/`sk_live_` prefix to carry its mode, so the
        header does the job — and it is applied by exactly the same code that applies a key's mode.

        DECLARED REQUIRED, AND ENFORCED ON THIS ROUTE SINCE 2026-09-04 (#389, #485). It used to be
        declared required and defaulted to `live` at runtime, so omitting it got you either
        `403 email_verification_required` — on what you believed was a sandbox key — or, if the
        address was already confirmed, a silent `sk_live_` as your "first key". A key's mode is
        immutable (§3.1), so there is no fixing that afterwards. `POST /dashboard/api_keys` from a
        SESSION now answers `400 missing_mode` instead of guessing. Every other route keeps the
        `live` default, where a wrong guess is an empty list rather than a live credential.

        An unrecognised value is `400 invalid_mode`, NEVER a silent fall back to live: someone who
        wrote `sandbox` and believed they were in test is the one mistake here that can produce a
        real tax document.

        THIS IS THE ONLY SURFACE WITH THIS HEADER. It does not exist on `/v1`, and it is never a
        response header anywhere.
      schema: { $ref: "#/components/schemas/Mode" }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Same semantics as the main contract (§5.6). Replays return the original body.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous response's `next_cursor`. Do not construct one.
      schema: { type: string }
    KeyId:
      name: key_id
      in: path
      required: true
      schema: { type: string, examples: ["key_01K2R6ZC4P8QW1VN7T3MHDY9EK"] }

  responses:
    AuthThrottled:
      description: |
        Too many attempts from this address, or against this e-mail. `Retry-After` says for how
        long, and `errors` is the stable code `["auth_throttled"]` — branch on that, never on the
        Spanish `message`.

        THE TWO CEILINGS, PUBLISHED (#391), because a fleet of agents behind one NAT reaches the
        first of them on its very first call and is entitled to know the number rather than
        discover it:

          * **10 per minute per IP address**, across every account. Bounds the CPU — each of these
            doors costs a full bcrypt at cost 12, and the machine is one vCPU.
          * **5 per minute per e-mail address**, from anywhere. This is the half a botnet cannot
            dodge by rotating addresses.

        Both windows are 60 seconds and both are counted on EVERY attempt, successful ones
        included. The refusal names neither bucket nor count: saying "too many attempts for this
        e-mail" would make the throttle an account-enumeration oracle. There is no captcha, on
        purpose — this surface is meant for agents.

        If a fleet legitimately needs more than 10 registrations a minute from one egress address,
        talk to us before working around it: the answer is a platform arrangement, not a retry loop.
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds. }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/EnvelopeError" }
    V1BadRequest:
      description: Malformed or missing input.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    V1Unauthorized:
      description: "`missing_session` or `invalid_session` — sign in again."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    V1NotFound:
      description: No such key for this session, in this mode.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    V1Conflict:
      description: An `Idempotency-Key` conflict.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    V1RateLimited:
      description: Too many requests.
      headers:
        Retry-After: { schema: { type: integer } }
        RateLimit-Limit: { schema: { type: integer } }
        RateLimit-Remaining: { schema: { type: integer } }
        RateLimit-Reset: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }

  schemas:

    # ---- the /account/* envelope (the engine's, NOT §5.5's)

    Envelope:
      type: object
      required: [status, message, errors]
      description: |
        The `/account/*` response shape, MINUS `data`. The payload is under `data`; `errors` is
        `null` on success. `/dashboard/*` does NOT use this — it answers bare resources and §5.5
        errors, exactly like `/v1`.

        `data` IS ALWAYS PRESENT ON THE WIRE and is always required — but it is declared by each
        operation, next to the `allOf` that pulls this object in, and never here. This shape used
        to carry `data: [object, "null"]`, and every `/account/*` response then read
        `allOf: [Envelope, {properties: {data: <the real payload>}}]`: two incompatible
        declarations of one property, which JSON Schema merges to "an object that is also null"
        and no code generator can. `openapi-python-client` — the tool THIS API's own published
        recipe tells a Python agent to run — answered
        «`EnvelopeDataType0 | None` can't be merged with `RegisterAccountResponse201Data`,
        response will be omitted from generated client» and dropped the response type of ALL FIVE
        operations, `POST /account/register`'s included. A typed client generated from the
        document whose only job is to hand over a key could not read `data.api_key.secret`
        (#476). Leaving `data` out of the base is what makes the composition merge.
      properties:
        status: { type: integer, description: Mirrors the HTTP status. }
        message: { type: string, description: "Spanish, for a human. Do not program against it." }
        errors: { type: ["array", "null"], items: { type: string } }
        details:
          type: object
          description: |
            Machine-readable context beside the payload. Today it carries exactly one key,
            `pending_action` — a human act that is outstanding (#388). SAME KEY AND SAME NESTING
            as §5.5's `error.details`, so an agent that already knows how to relay the emission
            refusal's handoff relays this one with no second code path. Absent when there is none.
          properties:
            pending_action: { $ref: "#/components/schemas/PendingActionEmailVerification" }
        next: { $ref: "#/components/schemas/Next" }

    EnvelopeError:
      type: object
      required: [status, message, data, errors]
      properties:
        status: { type: integer }
        message: { type: string }
        data:
          type: [object, "null"]
          description: |
            ALWAYS `null` on a refusal — the key is present and empty, never a partial payload.
            Typed `[object, "null"]` rather than the bare `"null"` it used to be because
            `openapi-generator` aborts the whole document on a pure-null property
            (`RuntimeException: Codegen Property not yet supported in getPydanticType`, #476); the
            sentence above is the narrower promise, and it is the one to program against.
        errors:
          type: array
          items: { type: string }
          description: |
            A stable code where one exists — `["account_exists"]` on the register `409`,
            `["auth_throttled"]` on an auth `429`, `["invalid_bootstrap"]` on a malformed
            `bootstrap` — and otherwise a single element repeating `message`. On a `500` it is the
            literal `["internal_error"]` and the detail is in the log under the same request id.
            Branch on this, never on the Spanish `message`.
        request_id: { type: string, description: Present on `500`. }
        next:
          allOf:
            - $ref: "#/components/schemas/Next"
          description: |
            The way through, where there is one. The register `409` carries
            `{"method": "POST", "url": "/account/login"}`; most refusals carry nothing.

    SessionToken:
      type: string
      description: "A signed JWT, valid for one hour. Send it as `Authorization: Bearer <token>`."

    RegisteredAccount:
      type: object
      description: |
        NOTE the field naming. This object uses `company_name` (snake_case); the object
        `POST /account/login` returns uses `companyName` (camelCase) for the same value. That is
        the surface as built, and it is documented rather than tidied because a client generated
        from a tidied document would silently read `undefined`.
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: [string, "null"] }
        company_name: { type: [string, "null"] }
        plan: { type: string }
        created_at: { type: string, format: date-time }
        email_verified: { type: boolean }

    SignedInAccount:
      type: object
      description: The login variant. `companyName`, camelCase — see `RegisteredAccount`.
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: [string, "null"] }
        companyName: { type: [string, "null"] }
        plan: { type: string }
        email_verified: { type: boolean }

    AccountDetail:
      type: object
      description: What `GET /account` reports. Snake_case, plus three computed fields.
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: [string, "null"] }
        company_name: { type: [string, "null"] }
        plan: { type: string }
        active: { type: boolean }
        email_verified: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
        connections: { type: integer, description: How many empresas this account holds. }
        apiKeys: { type: integer, description: "How many active keys. camelCase, as built." }
        email_verification:
          type: object
          description: |
            THE `check` OF THE VERIFICATION HANDOFF, ANSWERED (#485).
            `pending_action.check` names this endpoint, and this endpoint used to report a bare
            `email_verified` boolean: no way to tell «the mail is in their inbox and they have not
            clicked» from «the mail never left the building», and no repetition of the act still
            outstanding. So an agent that relayed the handoff at registration had to remember it,
            and could not decide whether to resend.
          required: [verified, sent_at, delivered, pending_action]
          properties:
            verified: { type: boolean, description: "The same fact as `email_verified`, in this block." }
            sent_at:
              type: [string, "null"]
              format: date-time
              description: When the newest confirmation mail was issued. `null` if none ever was.
            delivered:
              type: [boolean, "null"]
              description: |
                Did the provider accept it? `null` means not known (an older row), never «failed».
            pending_action:
              oneOf:
                - $ref: "#/components/schemas/PendingActionEmailVerification"
                - type: "null"
              description: |
                The same handoff `POST /account/register` puts in `details.pending_action`, so one
                reader handles both. `null` once the address is confirmed — there is no act left.
                Also repeated in this response's own `details.pending_action`.
        empresa_subscriptions:
          type: object
          description: |
            The plan is per EMPRESA. `billable` counts live (non-sandbox) active empresas;
            `covered` counts those that may emit live right now — paid, in grace, or on the house.
            Sandbox empresas are free forever and appear in neither.
          properties:
            billable: { type: integer }
            covered: { type: integer }

    # ---- the /dashboard/* objects (identical to the main contract's)

    Mode:
      type: string
      enum: [test, live]

    Scope:
      type: string
      enum:
        - documents:read
        - documents:write
        - received:read
        - received:write
        - empresas:read
        - empresas:write
        - folios:write
        - tax:read
        - tax:write
        - webhooks:read
        - webhooks:write
        - usage:read
        - keys:write
        - mandato:write
        - mandato:attest
        - onboarding:write
        - onboarding:attest

    ApiKey:
      type: object
      description: Metadata only. The secret is never readable after creation.
      properties:
        id: { type: string, examples: ["key_01K2R6ZC4P8QW1VN7T3MHDY9EK"] }
        object: { type: string, const: api_key }
        name: { type: string }
        mode: { $ref: "#/components/schemas/Mode" }
        root:
          type: boolean
          description: |
            `true` when this is a ROOT key — minted by sending `{"root": true}` above, from an
            account session. A root key carries `keys:write` and nothing else, reads `empresas:
            ["*"]` because it inherits the minting session's reach, and is the credential
            `POST /v1/api_keys` takes.
        last4: { type: string }
        scopes:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
        empresas:
          type: array
          description: Allowlist. `["*"]` means every empresa on the account, present and future.
          items: { type: string }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }

    List:
      type: object
      description: The same cursor-paginated envelope the main contract uses.
      required: [object, url, data, has_more]
      properties:
        object: { type: string, const: list }
        url: { type: string }
        data: { type: array, items: {} }
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"], examples: ["cur_01K2R7Q9M4T2VB"] }

    Next:
      type: object
      description: |
        THE ONE OBVIOUS NEXT CALL (#389), on the wire rather than in prose.

        The engine's envelope carries a Spanish `message` for a human, and a generated client
        throws away the description in which "now call `POST /dashboard/api_keys` with
        `Facturia-Mode: test`" used to be the only statement of that fact. `url` is a PATH on this
        document's server (`https://api.facturia.cl`), so `/account/login` and `/v1/documents` are
        both expressible — the second one crosses into the main contract, which is exactly where a
        bootstrapped key is supposed to go.

        Every URL published here is probed against the real router by the API's own contract suite:
        a `next` pointing at a 404 is the class of lie this whole document exists not to tell.
      required: [method, url]
      properties:
        action:
          type: string
          enum: [mint_test_key, create_empresa, emit_sandbox_document, login]
          description: |
            A stable key, so a client can branch without parsing prose. It is COMPUTED FROM WHAT
            THE RESPONSE ACTUALLY CONTAINS (#457) — read `data.api_key` and `data.empresa` and
            you can predict it:

              * `mint_test_key` — no key yet (a login, or a register without `bootstrap`).
              * `create_empresa` — a key exists and `data.empresa` is `null`, which is the case
                whenever `bootstrap.empresa` was refused (see `data.empresa_error`) and whenever
                the caller never asked for one. Until 2026-09-04 this state was published as
                `emit_sandbox_document`, and following it answered
                `403 empresa_not_allowed` on an account with no empresa.
              * `emit_sandbox_document` — a key AND an empresa exist; `headers` then names
                `Facturia-Empresa`.
              * `login` — on the register `409`.
          examples: ["mint_test_key"]
        method:
          type: string
          enum: [GET, POST, PATCH, DELETE]
        url: { type: string, examples: ["/dashboard/api_keys"] }
        headers:
          type: object
          additionalProperties: { type: string }
          description: |
            Headers that are not optional for that call to do what it says. A value in angle
            brackets — `Bearer <data.token>` — names the field of THIS response to substitute.

    PendingActionEmailVerification:
      type: object
      description: |
        A HUMAN ACT THE CALLER HAS TO RELAY (#388): confirming the account e-mail. Same placement
        as the emission refusal's own handoff (`error.details.pending_action` on the main contract's
        `422 empresa_not_emitting`), so one reader handles both.

        Deliberately COMPACT, and deliberately without the main contract's `object:
        "pending_action"` tag: that object describes acts performed at the SII with a personal
        certificate and carries instructions, prerequisites and legal citations. This one is "open
        your inbox", and claiming the richer tag while carrying five fields would make a consumer
        that switches on it read `undefined` for everything else.

        NOTHING IN TEST MODE WAITS ON THIS. It gates exactly one thing — minting a live key (§3.8).
      required: [actor, what, resend, check, alternative, calls, delivery, next]
      properties:
        actor:
          type: string
          const: human
          description: Who has to act. A person, not the agent and not us.
        what:
          type: string
          description: |
            Spanish, one line. Relay it verbatim.

            IT TELLS THE TRUTH ABOUT THE MAIL (#485). When `delivery.sent` is `false` this reads
            «No pudimos enviar el correo… pide el reenvío» rather than «abre el correo que
            enviamos a …». It used to say the second on the very response that also reported
            `data.verificationEmailSent: false`, so an agent following the instruction to relay
            this field asked its human to open a message that had never been sent.
        resend:
          type: string
          const: "POST /account/verification/resend"
          description: |
            Prose. How the agent asks us to send the mail again, session-authenticated. Branch on
            `calls.resend`, which is the same endpoint as `{method, url}`.
        check:
          type: string
          const: "GET /account → email_verified"
          description: |
            Prose. How the agent finds out it happened, without asking the human. That response
            repeats this whole handoff under `data.email_verification.pending_action` and carries
            `delivered`, so a `check` answers «has it been confirmed» AND «did the mail go out».
            Branch on `calls.check`.
        alternative:
          type: string
          description: The way through when the human pastes the link instead of clicking it.
        calls:
          type: object
          description: |
            The three endpoints above, STRUCTURED (#485) — the `{method, url}` shape every other
            handoff in this product carries. Added beside the prose fields rather than replacing
            them: those three are `required` here and §5.8 promises twelve months before anything
            published disappears. Branch on these.
          required: [resend, check, verify]
          properties:
            resend: { $ref: "#/components/schemas/Call" }
            check: { $ref: "#/components/schemas/Call" }
            verify: { $ref: "#/components/schemas/Call" }
        delivery:
          type: object
          description: What actually happened with the mail.
          required: [sent, last_sent_at]
          properties:
            sent:
              type: [boolean, "null"]
              description: |
                `true` the provider accepted it, `false` we tried and failed, `null` NOT KNOWN
                here — the `403` gate is a pure function over a credential and reads no row.
                `null` is never «fine».
            last_sent_at:
              type: [string, "null"]
              format: date-time
              description: When the newest attempt was issued, on the surfaces that read the row.
        next:
          type: string
          enum: [open_email, resend]
          description: |
            Which of `calls` to make now. `resend` when the mail did not go out — the agent fixes
            that itself before asking a person for anything.

    Call:
      type: object
      description: An endpoint to invoke, absolute so the object alone is enough.
      required: [method, url]
      properties:
        method: { type: string, enum: [GET, POST, PATCH, DELETE] }
        url: { type: string, format: uri }

    BootstrapRequest:
      type: object
      description: |
        THE ONE-CALL COLD START (#390). Optional; omit it and `register` behaves exactly as it
        always has.

        Everything here is validated BEFORE the account row is written, so a malformed block
        creates nothing at all — the alternative is an account that exists, a `400` in your hand,
        and a retry that answers `409`.
      required: [sandbox_key]
      properties:
        sandbox_key:
          type: object
          description: The `sk_test_` key to mint alongside the account.
          properties:
            name:
              type: string
              description: "What the key inventory will call it. Defaults to `primera llave`."
            scopes:
              type: array
              minItems: 1
              description: |
                Defaults to the canonical bootstrap set — `documents:read`, `documents:write`,
                `empresas:read`, `empresas:write`, `webhooks:read`, `webhooks:write` — which is
                what the documented next five minutes actually need. `keys:write` is refused with
                `400` and `errors: ["invalid_bootstrap"]`: minting is a root-key power, and a
                sandbox bootstrap key must not be a minting authority.
              items: { $ref: "#/components/schemas/Scope" }
        empresa:
          type: object
          description: |
            Optional. Creates a SANDBOX empresa (`mode: test`), born ready to emit — no
            certificate, no mandato, no certification (§2). Same endpoint, same rules and same
            object as the main contract's `POST /v1/empresas`.
          required: [rut, legal_name]
          properties:
            rut:
              type: string
              description: Canonical RUT, check digit included.
              examples: ["76000002-7"]
            legal_name: { type: string, minLength: 1 }

    BootstrapError:
      type: object
      description: Why a piece of the bootstrap did not happen. The account and the key still did.
      required: [code, message]
      properties:
        code: { type: string, examples: ["empresa_rut_claimed"] }
        message: { type: string, description: "Spanish, for a human." }

    Empresa:
      type: object
      description: |
        The sandbox empresa, as `POST /v1/empresas` returns it. THE LOAD-BEARING FIELDS ONLY —
        the main contract (https://docs.facturia.cl/openapi.yaml) declares this object in full,
        and it is the same object. What is named here is what a bootstrapping caller needs in
        order to make its next call.
      properties:
        id: { type: string, examples: ["emp_01K2R6ZC4P8QW1VN7T3MHDY9EK"] }
        object: { type: string, const: empresa }
        mode:
          $ref: "#/components/schemas/Mode"
          description: "`test` for anything created here. Always."
        rut: { type: string, examples: ["76000002-7"] }
        legal_name: { type: string }
        ambiente: { type: string }
        doc_types: { type: array, items: { type: integer } }

    ErrorBody:
      type: object
      description: |
        Byte-for-byte the same property set as the main contract's `ErrorBody`, and a test asserts
        it: a `/dashboard/*` error goes through the very same `renderError` as a `/v1` one, so a
        client generated from THIS document that dropped `hint` or `retriable` would be discarding
        fields that are on the wire.
      required: [type, code, message]
      properties:
        type:
          type: string
          enum:
            - invalid_request_error
            - authentication_error
            - permission_error
            - not_found_error
            - conflict_error
            - unprocessable_error
            - rate_limit_error
            - sii_error
            - api_error
        code: { type: string, examples: ["privilege_escalation"] }
        message: { type: string }
        hint:
          type: string
          description: |
            The next action for this exact `code` — the same text `GET /v1/error_codes` publishes,
            inlined so a caller does not need a second request to learn what to do about it. Absent
            only for a code minted after your client shipped; treat that as "consult `message` and
            `doc_url`", never as a fault.
        retriable:
          type: boolean
          description: |
            Could the IDENTICAL request succeed later, unchanged? The same field
            `GET /v1/error_codes` publishes for this `code`. Absent under the same rare condition
            as `hint`.
        retry_after_seconds:
          type: integer
          description: |
            How long to wait, when `retriable` is `true` — the same value the `Retry-After` and
            `Facturia-Poll-After` headers carry, repeated in the body for a consumer that stored
            the JSON and no longer has the headers (#511).
        param: { type: string }
        doc_url: { type: string, format: uri }
        request_id: { type: string }
        empresa: { type: string, examples: ["76000002-7"] }
        existing_id:
          type: string
          description: On conflicts, the id of the object that already exists.
        sii:
          type: object
          description: Present on `sii_error` — the raw upstream estado.
          properties:
            estado: { type: string }
            glosa: { type: string }
            err_code: { type: string }
            track_id: { type: string }
        details:
          type: object
          additionalProperties: true
          description: |
            Machine-readable context for this `code`, and the only part of the envelope that says
            what to do and who does it — the `pending_action` on an emission refusal, the
            `checkout_url` on a `402`, the deadline on an acuse refusal. Its shape is a function of
            `code`, never of the status, so branch on `code` first.

            Left OPEN here and declared as a discriminated `oneOf` in the main contract
            (`https://docs.facturia.cl/openapi.yaml`, `ErrorBody.details`), which is where the
            typed variants belong: the codes that carry a NAMED shape are `/v1` codes, and a
            per-path `$ref` across two published documents is a coupling neither generator handles
            well. Read the variants there; treat an unrecognised `details` as data, never a fault.

    ErrorResponse:
      type: object
      required: [error]
      description: |
        §5.5's envelope, used by `/dashboard/*` and by all of `/v1`. Program against `error.code`,
        never against `error.message`. Every code has a page at
        `https://docs.facturia.cl/errors/<code>`, which is what `doc_url` points to.
      properties:
        error: { $ref: "#/components/schemas/ErrorBody" }

    VerificationRequiredError:
      type: object
      required: [error]
      description: |
        `403 email_verification_required`, which is the one refusal on this surface that names a
        HUMAN as the thing in the way. Same envelope as every other error; the handoff rides in
        `error.details.pending_action` (#388).
      properties:
        error:
          allOf:
            - $ref: "#/components/schemas/ErrorBody"
            - type: object
              properties:
                details:
                  type: object
                  properties:
                    pending_action:
                      $ref: "#/components/schemas/PendingActionEmailVerification"

security:
  - accountSession: []
