{
  "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.\n\nEvery operation in the main contract — https://docs.facturia.cl/openapi.yaml — authenticates\nwith an `sk_test_` / `sk_live_` key, and a brand-new account has none. This document describes\nexactly the surface that produces the first one:\n\n    POST /account/register   →  a 1-hour account session token\n    POST /dashboard/api_keys →  an `sk_test_` key, with that token\n    (then everything in the main contract)\n\nWHY THIS IS A SEPARATE DOCUMENT rather than a few extra paths in the main one. The two surfaces\ndisagree about three things a single OpenAPI document cannot express twice:\n\n  * BASE URL. `/v1` is served at `https://api.facturia.cl/v1`; this surface at\n    `https://api.facturia.cl`. OpenAPI's root `servers` applies to EVERY path in the document,\n    so one document with both would declare `https://api.facturia.cl/v1/account/register` and\n    `https://api.facturia.cl/documents` — two URLs that 404 — as valid. Per-path `servers`\n    overrides exist and are ignored or mishandled by a large share of generators, which is the\n    same failure with an extra step.\n  * CREDENTIAL. Every `/v1` operation declares `bearerAuth: sk_…`. These take an account JWT,\n    or nothing at all. A document-level `security` cannot mean both.\n  * ERROR ENVELOPE. `/v1` answers §5.5's `{\"error\": {...}}`. `/account/*` answers the engine's\n    older `{\"status\", \"message\", \"data\", \"errors\"}`. `/dashboard/*` answers §5.5's, like `/v1`.\n    That is documented below per path, because it is true per path.\n\nSo: two contracts, published side by side, each honest about one surface. Generate a client\nfrom this one to get a key; generate a client from the main one to use the product.\n\nLANGUAGE, ONCE, FOR THE WHOLE DOCUMENT (#512). Two languages, and which one a field carries is\na property of WHO READS IT:\n\n  * ENGLISH — everything a program consumes: field names, enum values, `error.code`,\n    `error.message` and `error.hint` on the `/dashboard/*` paths, and the `errors[]` codes on\n    `/account/*`. Branch on those; do not branch on prose.\n  * CHILEAN SPANISH — every string written for the PERSON: the envelope `message` on every\n    `/account/*` response, and every field of `details.pending_action` («abre el correo que te\n    mandamos…»). They are meant to be relayed VERBATIM to the human they address, not\n    translated and not paraphrased.\n\nA field whose description says «Spanish» is in the second group. Anything unmarked is in the\nfirst.\n\nPUBLISHED AT https://docs.facturia.cl/openapi-bootstrap.yaml, and as JSON at\nhttps://docs.facturia.cl/openapi-bootstrap.json. Both are generated from the same committed\nfile in the same build, so they cannot drift, and neither is published until every operation\nhere has been probed against the real router.\n\n`info.version` follows the same rule as the main contract: released semver, MINOR for\nadditions, MAJOR only alongside the twelve-month withdrawal notice of `public-api-spec.md` §5.8.\n\nWHAT IS DELIBERATELY NOT HERE:\n\n  * `POST /dashboard/auth/session` exists, and is the Supabase magic-link exchange — it takes a\n    Supabase access token, not an email and password. It is a browser door. An agent has no way\n    to obtain that token, so describing it here would be an invitation to a dead end.\n  * `DELETE /account` exists and erases the account (Ley 21.719 suppression). It is not part of\n    getting a key, it requires the account password AND the account's own e-mail as a typed\n    confirmation, and it is irreversible. It is specified in `public-api-spec.md`, not here.\n  * The rest of `/dashboard/*` — empresas, documents, received documents, usage, certificates —\n    is a narrow read surface for the browser panel. Everything it serves, `/v1` serves too,\n    with a credential built for a server. Use the main contract.\n\nTHE TWO REQUEST-ID HEADERS, because they are genuinely two. `/account/*` answers\n`X-Request-Id` (the engine's correlation id). `/dashboard/*` answers `X-Request-Id` AND\n`Facturia-Request-Id` (`req_…`), and it is the `Facturia-Request-Id` that support asks for,\nthe same as on `/v1`. Quoting either is better than quoting neither.\n\nThe prose for all of this is `public-api-spec.md` §3.7 and §3.8.\n",
    "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\nnoted. These answer the engine's envelope: `{\"status\", \"message\", \"data\", \"errors\"}` — the\npayload is under `data`, and `errors` is `null` on success and an array of strings on failure.\n"
    },
    {
      "name": "Keys",
      "description": "Mint, list, roll and revoke API keys with an account session. Same router as\n`/v1/api_keys`, so the objects and the error envelope are §5.5's, identical to the main\ncontract. What differs is the credential: a session instead of a root key, which is the whole\npoint — a root key is what you do not have yet.\n"
    }
  ],
  "paths": {
    "/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.\n\n`email` and `password` are the only required fields; `password` must be at least 8\ncharacters. The response carries a session token good for one hour — that token is what\n`POST /dashboard/api_keys` takes.\n\nRegistering does NOT require confirming the e-mail. Verification gates exactly one thing:\nminting a **live** key (§3.8). Test mode, and everything in it, is open immediately — so an\nagent can go from this call to an emitted sandbox DTE without a human reading a mailbox.\n\n`verificationEmailSent: false` means the confirmation mail could not be sent and the account\nwas created anyway. It is reported rather than thrown: a mail provider being down must not\nbe able to fail a signup.\n\nTHE ONE-CALL BOOTSTRAP. Send `bootstrap` and this SAME 201 also carries a minted\n`sk_test_…` key and, optionally, a sandbox empresa — so the whole cold start is one request\ninstead of three, and no agent has to hold a human's password to re-mint after the session's\nhour is up:\n\n```bash\ncurl -sS https://api.facturia.cl/account/register \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"email\": \"agente@ejemplo.cl\",\n        \"password\": \"una-contraseña-larga\",\n        \"bootstrap\": {\n          \"sandbox_key\": {\"name\": \"primera llave\"},\n          \"empresa\": {\"rut\": \"76000002-7\", \"legal_name\": \"Mi Empresa SpA\"}\n        }\n      }'\n```\n\n`data.api_key.secret` is the key. **It is returned exactly once.** It is always `sk_test_`:\nthere is no `live` variant of this block and there never will be one, because minting a live\nkey is the single chokepoint e-mail verification exists to hold (§3.8).\n\n`data.empresa` is `null` when `bootstrap.empresa` was not asked for — and also when it was\nasked for and refused, in which case `data.empresa_error` says why (a RUT already registered\non another account is the realistic case). Reported rather than thrown: the account and the\nkey are real, and re-sending `register` to fix an empresa would only answer `409`.\n\n`data.next` names the one call that follows, headers included, and it is computed from what\nthe response ACTUALLY contains: `create_empresa` when `data.empresa` is null (whether it was\nrefused or never asked for), `emit_sandbox_document` only when both the key and the empresa\nare there. Until 2026-09-04 a refused empresa still produced `emit_sandbox_document`, and\nfollowing it answered `403 empresa_not_allowed` on an account that had no empresa (#457).\n\n`details.pending_action` names the human act that is still outstanding — confirming the\naddress — with the endpoint that resends the mail and the one that says when it is done.\nNothing in test mode waits on it.\n",
        "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\n`bootstrap` was sent, `null` otherwise. The secret appears here and\nnowhere else, ever.\n",
                              "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\nwhen `bootstrap.empresa` was sent AND accepted. Same object the main\ncontract's `POST /v1/empresas` returns.\n",
                              "oneOf": [
                                {
                                  "type": "null"
                                },
                                {
                                  "$ref": "#/components/schemas/Empresa"
                                }
                              ]
                            },
                            "empresa_error": {
                              "description": "Why `empresa` is null despite being asked for. `null` otherwise.\n`empresa_rut_claimed` means that RUT is registered on another account.\n",
                              "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\nmalformed `bootstrap` block (`errors: [\"invalid_bootstrap\"]`). A refused `bootstrap`\ncreates NOTHING — no account, no key, no empresa — so the call is safe to fix and retry.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EnvelopeError"
                }
              }
            }
          },
          "409": {
            "description": "An account with this e-mail already exists. `errors` is `[\"account_exists\"]` and `next`\nis `{\"method\": \"POST\", \"url\": \"/account/login\"}` — sign in instead.\n",
            "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.\n\nONE REFUSAL FOR BOTH FAILURES. A wrong password and an unknown address answer the identical\n`401`, and take the identical time to do it. Do not read anything into either.\n\nSigning in is never blocked by an unverified e-mail (§3.8); `emailVerified` rides along so a\nclient can show the state without a second call.\n\n`data.next` says what a session is FOR. It lives an hour and cannot be refreshed, so the\nonly reason to hold one is to mint an `sk_` key — and the response that hands one out is the\nright place to say so, `Facturia-Mode` included.\n",
        "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\nconfirmed yet? `email_verified` answers it without signing in again, and\n`email_verification` answers the two follow-ups: WHEN the mail was sent and whether it was\nactually DELIVERED — so an agent can tell «they have not clicked» from «it never left the\nbuilding» and resend without asking a person for anything (#485). While it is outstanding\nthe whole handoff is repeated here, in `email_verification.pending_action` and in the\nenvelope's own `details.pending_action`.\n\n`empresa_subscriptions` summarises billing: `billable` is how many live (non-sandbox)\nempresas exist on the account, `covered` how many of those may emit live right now. The\nplan is per EMPRESA, so those two numbers are the honest form of \"am I paid up\".\n",
        "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\ndevice. The token IS the credential — single-use, 24 hours, bound to the address it was\nsent to.\n\nConfirming twice is a SUCCESS, not an error: `alreadyVerified: true`, status `200`.\n",
        "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\nwas sent. The four have distinct messages, because the remedy differs.\n",
            "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\nnothing else; a person who lost their password had no way back in and no support address on\nthe screen. The session lives one tab, so the password is typed often.\n\n**ALWAYS `202`, WHATEVER THE ADDRESS IS.** A `404` — or a different message, or a\nmeasurably different latency — for an unknown address is a customer-list oracle that needs\nno credential at all. The status and the body are decided before we look anything up. So\nthis response tells you the request was accepted and nothing about whether the account\nexists; that is the point, and no future version will make it more informative.\n\nWhen the account does exist, a mail goes to it carrying\n`https://app.facturia.cl/restablecer?token=…`. The link lives **60 minutes** and is single\nuse, and issuing one invalidates every other live link for that account.\n\nThrottled on its own bucket (10/min per address, 5/min per e-mail): this endpoint makes us\nsend mail on demand.\n",
        "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.\n\n**This response is NOT the `/account/*` legacy envelope.** It is the flat object shape,\nlike `/v1`: the pair is new, has no legacy consumer, and this document declares the\nenvelope per path precisely because it differs per path.\n",
            "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\ncannot sign in. The token IS the credential — single use, 60 minutes, pinned to the address\nit was mailed to, so an address change after the mail went out does not leave an old\nmailbox holding a way in.\n\nOn success every OTHER live reset link for that account is invalidated. Existing session\nJWTs are stateless and stay valid until they expire, within the hour — stated rather than\nclaimed otherwise.\n\n`password` follows the same minimum as `register`: 8 characters.\n",
        "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\naccount's address changed after the mail went out. **Which one is deliberately not\npublished**: distinguishing them is a probe oracle over a bearer credential and the\nremedy is the same in all four — ask for a new link. Or `invalid_field` with\n`param: \"password\"` for a password under 8 characters.\n",
            "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\nbe a way to make us mail anyone on demand; keyed on the session, the only reachable address\nis the one already signed in.\n\nUnlike registration, this one FAILS when the mail fails (`502`). The caller pressed \"send\nit again\", and answering `200` while nothing left the building is the worst available reply.\n\nNo body.\n",
        "responses": {
          "200": {
            "description": "Sent (`sent: true`), or the address was already confirmed and nothing needed sending\n(`emailVerified: true, sent: false`).\n",
            "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"
                }
              }
            }
          }
        }
      }
    },
    "/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.\n\nFILTERED BY `Facturia-Mode`. A session in `test` sees only test keys, a session in `live`\nonly live ones, and the roll and revoke below apply the same filter — so the inventory and\nthe two destructive buttons beside it cannot disagree about what this session can reach.\n",
        "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:\n\n```bash\ncurl -sS https://api.facturia.cl/dashboard/api_keys \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H \"Facturia-Mode: test\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n        \"name\": \"primera llave\",\n        \"scopes\": [\"documents:read\",\"documents:write\",\"empresas:read\",\"empresas:write\",\"webhooks:read\",\"webhooks:write\"],\n        \"empresas\": [\"*\"]\n      }'\n```\n\nTHOSE SIX SCOPES ARE THE CANONICAL FIRST-KEY SET, and the example carries all six on\npurpose. A first key minted with only `documents:read`/`documents:write` cannot reach\n`POST /v1/empresas` — so the very next step this document promises (\"go from this call to an\nemitted sandbox DTE\") dies on `403 insufficient_scope`, and fixing it needs the session\nagain, which by then may have expired. Ask for what the next five minutes need.\n`POST /account/register`'s `bootstrap` block defaults to exactly this set.\n\n`secret` in the response is the `sk_test_…` (or `sk_live_…`) key. **It is returned exactly\nonce.** Store it before doing anything else.\n\nTHE RULES, all enforced, all answering `403 privilege_escalation` naming the offending\nvalue:\n\n  * MODE IS INHERITED FROM THE HEADER, never chosen in the body. `Facturia-Mode: test`\n    mints `sk_test_`, `live` mints `sk_live_`. Sending `mode` in the body is refused unless\n    it already equals the header's. There is no path from a test credential to a live one.\n  * `scopes` is REQUIRED and non-empty — except with `root: true`, below. `keys:write` can\n    never be granted as a scope: that would create a second minting authority wearing no\n    prefix at all, which is worse than a root key because nothing about the credential says\n    so.\n  * `empresas` defaults to the session's reach (every empresa on the account). `[\"*\"]` says\n    the same thing explicitly; a list of RUTs narrows the key to those.\n\nTHE ROOT KEY. `{\"root\": true, \"name\": \"…\"}` mints a ROOT key — `sk_test_root_…` /\n`sk_live_root_…`, the credential `POST /v1/api_keys` takes (§3.6). It is the way an\nintegrator stops holding a human's password: a root key does not expire in an hour, and it\nis the only long-lived credential that can mint keys per client company.\n\n  * ONLY AN ACCOUNT SESSION MAY MINT ONE. A key — root or ordinary — asking for\n    `root: true` is `403 privilege_escalation`: delegation stays one level deep and root\n    keys cannot mint root keys.\n  * ITS SHAPE IS FIXED, NOT COMPOSED. `scopes` is exactly `[\"keys:write\"]`. Send that field\n    with a different value and the call is `400 invalid_field` rather than a silent\n    override — a key that does none of what you asked for is not something anyone reads a\n    response closely enough to notice.\n  * ITS EMPRESA REACH IS THE SESSION'S: `[\"*\"]`, every empresa on the account, present and\n    future. That is what makes it usable — the mint guardrail subset-checks a minted key's\n    `empresas` against the MINTER's, so a root key that reached nothing could mint nothing\n    that reaches anything. It cannot USE that reach (`keys:write` is its only scope); the\n    keys it mints can. `empresas` on a root mint may be absent, `[]` or `[\"*\"]`; a narrower\n    list is `400 invalid_field` — narrow the keys it mints, not the root.\n  * WHAT IT CAN THEN MINT: any scope but `keys:write`, for `[\"*\"]` or for any RUT the\n    account owns, in its own mode. That is the whole point of holding one.\n  * IT KEEPS EVERY ROOT GUARD. A root key cannot emit, read documents or touch tax data\n    (`403 root_key_scope_denied` on any other scope), and it cannot mint another root key.\n    Revoking a leaked one is `DELETE /dashboard/api_keys/{key_id}`, from a session — which\n    is why that operation exists.\n\nMINTING A LIVE KEY NEEDS A CONFIRMED E-MAIL — `403 email_verification_required` otherwise.\nThis is the one and only gate verification applies (§3.8); test keys are unaffected, and the\nrefusal carries `error.details.pending_action` with the endpoint that resends the mail and\nthe one that reports when it is done.\n\nIdempotent under `Idempotency-Key`, like every mutating `/v1` call.\n",
        "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\n`[\"keys:write\"]`) with `root: true`. `keys:write` is never grantable to an\nordinary key. The canonical first-key set is the six in the example above.\n",
                    "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\nsession; a key asking for it is `403 privilege_escalation`. A root key carries\nexactly `[\"keys:write\"]`, inherits the session's `[\"*\"]` empresa reach so the\nkeys it mints can address the account's empresas, and is the credential\n`POST /v1/api_keys` takes.\n"
                  },
                  "empresas": {
                    "type": "array",
                    "description": "Allowlist. `[\"*\"]` = every empresa on the account, present and future — the\ndefault when the field is absent. Otherwise canonical RUTs (`76000002-7`).\nWith `root: true` it must be absent, `[]` or `[\"*\"]`: a root key inherits the\nwhole account reach and cannot be narrowed.\n",
                    "items": {
                      "type": "string"
                    }
                  },
                  "mode": {
                    "description": "Accepted only when it equals `Facturia-Mode`. Present so a caller that sends it\nis refused loudly rather than silently getting the header's mode.\n",
                    "$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`\nother than `[\"keys:write\"]` or an `empresas` list narrower than `[\"*\"]`,\n`invalid_scope` for a scope that is not one, and `missing_mode` when a session omits\n`Facturia-Mode` (#485: it is not defaulted here, because a key's mode is immutable).\n",
            "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\n`root: true` asked for by a KEY rather than a session) or\n`email_verification_required` (a live key from an unconfirmed address). The second one\ncarries `error.details.pending_action`: who has to act, how to resend the mail, and how\nto find out it is done.\n",
            "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\nrevoke a leaked root key, since a root key cannot mint or revoke another root key.\n\nSubject to the same `Facturia-Mode` filter as the list: a key this session cannot see, it\ncannot revoke, and the refusal is `404`.\n",
        "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\na consumer can deploy the new one without a coordinated cutover.\n\n`409 api_key_roll_conflict` means somebody rolled this key concurrently. The secret from\nthe OTHER roll is the live one — fetch the key again rather than retrying blindly.\n",
        "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`.\n\nNOT an `sk_` key, and the two are not interchangeable in either direction: an `sk_` key is\nrefused here, and a session is refused by the main contract's `/v1`. Sessions are hourly\nand are the wrong thing to store in a server-side integration — that is what the key you\nmint below is for.\n"
      }
    },
    "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.\n"
      }
    },
    "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\nheader does the job — and it is applied by exactly the same code that applies a key's mode.\n\nDECLARED REQUIRED, AND ENFORCED ON THIS ROUTE SINCE 2026-09-04 (#389, #485). It used to be\ndeclared required and defaulted to `live` at runtime, so omitting it got you either\n`403 email_verification_required` — on what you believed was a sandbox key — or, if the\naddress was already confirmed, a silent `sk_live_` as your \"first key\". A key's mode is\nimmutable (§3.1), so there is no fixing that afterwards. `POST /dashboard/api_keys` from a\nSESSION now answers `400 missing_mode` instead of guessing. Every other route keeps the\n`live` default, where a wrong guess is an empty list rather than a live credential.\n\nAn unrecognised value is `400 invalid_mode`, NEVER a silent fall back to live: someone who\nwrote `sandbox` and believed they were in test is the one mistake here that can produce a\nreal tax document.\n\nTHIS IS THE ONLY SURFACE WITH THIS HEADER. It does not exist on `/v1`, and it is never a\nresponse header anywhere.\n",
        "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\nlong, and `errors` is the stable code `[\"auth_throttled\"]` — branch on that, never on the\nSpanish `message`.\n\nTHE TWO CEILINGS, PUBLISHED (#391), because a fleet of agents behind one NAT reaches the\nfirst of them on its very first call and is entitled to know the number rather than\ndiscover it:\n\n  * **10 per minute per IP address**, across every account. Bounds the CPU — each of these\n    doors costs a full bcrypt at cost 12, and the machine is one vCPU.\n  * **5 per minute per e-mail address**, from anywhere. This is the half a botnet cannot\n    dodge by rotating addresses.\n\nBoth windows are 60 seconds and both are counted on EVERY attempt, successful ones\nincluded. The refusal names neither bucket nor count: saying \"too many attempts for this\ne-mail\" would make the throttle an account-enumeration oracle. There is no captcha, on\npurpose — this surface is meant for agents.\n\nIf a fleet legitimately needs more than 10 registrations a minute from one egress address,\ntalk to us before working around it: the answer is a platform arrangement, not a retry loop.\n",
        "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": {
      "Envelope": {
        "type": "object",
        "required": [
          "status",
          "message",
          "errors"
        ],
        "description": "The `/account/*` response shape, MINUS `data`. The payload is under `data`; `errors` is\n`null` on success. `/dashboard/*` does NOT use this — it answers bare resources and §5.5\nerrors, exactly like `/v1`.\n\n`data` IS ALWAYS PRESENT ON THE WIRE and is always required — but it is declared by each\noperation, next to the `allOf` that pulls this object in, and never here. This shape used\nto carry `data: [object, \"null\"]`, and every `/account/*` response then read\n`allOf: [Envelope, {properties: {data: <the real payload>}}]`: two incompatible\ndeclarations of one property, which JSON Schema merges to \"an object that is also null\"\nand no code generator can. `openapi-python-client` — the tool THIS API's own published\nrecipe tells a Python agent to run — answered\n«`EnvelopeDataType0 | None` can't be merged with `RegisterAccountResponse201Data`,\nresponse will be omitted from generated client» and dropped the response type of ALL FIVE\noperations, `POST /account/register`'s included. A typed client generated from the\ndocument whose only job is to hand over a key could not read `data.api_key.secret`\n(#476). Leaving `data` out of the base is what makes the composition merge.\n",
        "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,\n`pending_action` — a human act that is outstanding (#388). SAME KEY AND SAME NESTING\nas §5.5's `error.details`, so an agent that already knows how to relay the emission\nrefusal's handoff relays this one with no second code path. Absent when there is none.\n",
            "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.\nTyped `[object, \"null\"]` rather than the bare `\"null\"` it used to be because\n`openapi-generator` aborts the whole document on a pure-null property\n(`RuntimeException: Codegen Property not yet supported in getPydanticType`, #476); the\nsentence above is the narrower promise, and it is the one to program against.\n"
          },
          "errors": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "A stable code where one exists — `[\"account_exists\"]` on the register `409`,\n`[\"auth_throttled\"]` on an auth `429`, `[\"invalid_bootstrap\"]` on a malformed\n`bootstrap` — and otherwise a single element repeating `message`. On a `500` it is the\nliteral `[\"internal_error\"]` and the detail is in the log under the same request id.\nBranch on this, never on the Spanish `message`.\n"
          },
          "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\n`{\"method\": \"POST\", \"url\": \"/account/login\"}`; most refusals carry nothing.\n"
          }
        }
      },
      "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\n`POST /account/login` returns uses `companyName` (camelCase) for the same value. That is\nthe surface as built, and it is documented rather than tidied because a client generated\nfrom a tidied document would silently read `undefined`.\n",
        "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).\n`pending_action.check` names this endpoint, and this endpoint used to report a bare\n`email_verified` boolean: no way to tell «the mail is in their inbox and they have not\nclicked» from «the mail never left the building», and no repetition of the act still\noutstanding. So an agent that relayed the handoff at registration had to remember it,\nand could not decide whether to resend.\n",
            "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».\n"
              },
              "pending_action": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/PendingActionEmailVerification"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "The same handoff `POST /account/register` puts in `details.pending_action`, so one\nreader handles both. `null` once the address is confirmed — there is no act left.\nAlso repeated in this response's own `details.pending_action`.\n"
              }
            }
          },
          "empresa_subscriptions": {
            "type": "object",
            "description": "The plan is per EMPRESA. `billable` counts live (non-sandbox) active empresas;\n`covered` counts those that may emit live right now — paid, in grace, or on the house.\nSandbox empresas are free forever and appear in neither.\n",
            "properties": {
              "billable": {
                "type": "integer"
              },
              "covered": {
                "type": "integer"
              }
            }
          }
        }
      },
      "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\naccount session. A root key carries `keys:write` and nothing else, reads `empresas:\n[\"*\"]` because it inherits the minting session's reach, and is the credential\n`POST /v1/api_keys` takes.\n"
          },
          "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.\n\nThe engine's envelope carries a Spanish `message` for a human, and a generated client\nthrows away the description in which \"now call `POST /dashboard/api_keys` with\n`Facturia-Mode: test`\" used to be the only statement of that fact. `url` is a PATH on this\ndocument's server (`https://api.facturia.cl`), so `/account/login` and `/v1/documents` are\nboth expressible — the second one crosses into the main contract, which is exactly where a\nbootstrapped key is supposed to go.\n\nEvery URL published here is probed against the real router by the API's own contract suite:\na `next` pointing at a 404 is the class of lie this whole document exists not to tell.\n",
        "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\nTHE RESPONSE ACTUALLY CONTAINS (#457) — read `data.api_key` and `data.empresa` and\nyou can predict it:\n\n  * `mint_test_key` — no key yet (a login, or a register without `bootstrap`).\n  * `create_empresa` — a key exists and `data.empresa` is `null`, which is the case\n    whenever `bootstrap.empresa` was refused (see `data.empresa_error`) and whenever\n    the caller never asked for one. Until 2026-09-04 this state was published as\n    `emit_sandbox_document`, and following it answered\n    `403 empresa_not_allowed` on an account with no empresa.\n  * `emit_sandbox_document` — a key AND an empresa exist; `headers` then names\n    `Facturia-Empresa`.\n  * `login` — on the register `409`.\n",
            "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\nbrackets — `Bearer <data.token>` — names the field of THIS response to substitute.\n"
          }
        }
      },
      "PendingActionEmailVerification": {
        "type": "object",
        "description": "A HUMAN ACT THE CALLER HAS TO RELAY (#388): confirming the account e-mail. Same placement\nas the emission refusal's own handoff (`error.details.pending_action` on the main contract's\n`422 empresa_not_emitting`), so one reader handles both.\n\nDeliberately COMPACT, and deliberately without the main contract's `object:\n\"pending_action\"` tag: that object describes acts performed at the SII with a personal\ncertificate and carries instructions, prerequisites and legal citations. This one is \"open\nyour inbox\", and claiming the richer tag while carrying five fields would make a consumer\nthat switches on it read `undefined` for everything else.\n\nNOTHING IN TEST MODE WAITS ON THIS. It gates exactly one thing — minting a live key (§3.8).\n",
        "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.\n\nIT TELLS THE TRUTH ABOUT THE MAIL (#485). When `delivery.sent` is `false` this reads\n«No pudimos enviar el correo… pide el reenvío» rather than «abre el correo que\nenviamos a …». It used to say the second on the very response that also reported\n`data.verificationEmailSent: false`, so an agent following the instruction to relay\nthis field asked its human to open a message that had never been sent.\n"
          },
          "resend": {
            "type": "string",
            "const": "POST /account/verification/resend",
            "description": "Prose. How the agent asks us to send the mail again, session-authenticated. Branch on\n`calls.resend`, which is the same endpoint as `{method, url}`.\n"
          },
          "check": {
            "type": "string",
            "const": "GET /account → email_verified",
            "description": "Prose. How the agent finds out it happened, without asking the human. That response\nrepeats this whole handoff under `data.email_verification.pending_action` and carries\n`delivered`, so a `check` answers «has it been confirmed» AND «did the mail go out».\nBranch on `calls.check`.\n"
          },
          "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\nhandoff in this product carries. Added beside the prose fields rather than replacing\nthem: those three are `required` here and §5.8 promises twelve months before anything\npublished disappears. Branch on these.\n",
            "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\nhere — the `403` gate is a pure function over a credential and reads no row.\n`null` is never «fine».\n"
              },
              "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\nthat itself before asking a person for anything.\n"
          }
        }
      },
      "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\nalways has.\n\nEverything here is validated BEFORE the account row is written, so a malformed block\ncreates nothing at all — the alternative is an account that exists, a `400` in your hand,\nand a retry that answers `409`.\n",
        "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`,\n`empresas:read`, `empresas:write`, `webhooks:read`, `webhooks:write` — which is\nwhat the documented next five minutes actually need. `keys:write` is refused with\n`400` and `errors: [\"invalid_bootstrap\"]`: minting is a root-key power, and a\nsandbox bootstrap key must not be a minting authority.\n",
                "items": {
                  "$ref": "#/components/schemas/Scope"
                }
              }
            }
          },
          "empresa": {
            "type": "object",
            "description": "Optional. Creates a SANDBOX empresa (`mode: test`), born ready to emit — no\ncertificate, no mandato, no certification (§2). Same endpoint, same rules and same\nobject as the main contract's `POST /v1/empresas`.\n",
            "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 —\nthe main contract (https://docs.facturia.cl/openapi.yaml) declares this object in full,\nand it is the same object. What is named here is what a bootstrapping caller needs in\norder to make its next call.\n",
        "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\nit: a `/dashboard/*` error goes through the very same `renderError` as a `/v1` one, so a\nclient generated from THIS document that dropped `hint` or `retriable` would be discarding\nfields that are on the wire.\n",
        "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,\ninlined so a caller does not need a second request to learn what to do about it. Absent\nonly for a code minted after your client shipped; treat that as \"consult `message` and\n`doc_url`\", never as a fault.\n"
          },
          "retriable": {
            "type": "boolean",
            "description": "Could the IDENTICAL request succeed later, unchanged? The same field\n`GET /v1/error_codes` publishes for this `code`. Absent under the same rare condition\nas `hint`.\n"
          },
          "retry_after_seconds": {
            "type": "integer",
            "description": "How long to wait, when `retriable` is `true` — the same value the `Retry-After` and\n`Facturia-Poll-After` headers carry, repeated in the body for a consumer that stored\nthe JSON and no longer has the headers (#511).\n"
          },
          "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\nwhat to do and who does it — the `pending_action` on an emission refusal, the\n`checkout_url` on a `402`, the deadline on an acuse refusal. Its shape is a function of\n`code`, never of the status, so branch on `code` first.\n\nLeft OPEN here and declared as a discriminated `oneOf` in the main contract\n(`https://docs.facturia.cl/openapi.yaml`, `ErrorBody.details`), which is where the\ntyped variants belong: the codes that carry a NAMED shape are `/v1` codes, and a\nper-path `$ref` across two published documents is a coupling neither generator handles\nwell. Read the variants there; treat an unrecognised `details` as data, never a fault.\n"
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "required": [
          "error"
        ],
        "description": "§5.5's envelope, used by `/dashboard/*` and by all of `/v1`. Program against `error.code`,\nnever against `error.message`. Every code has a page at\n`https://docs.facturia.cl/errors/<code>`, which is what `doc_url` points to.\n",
        "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\nHUMAN as the thing in the way. Same envelope as every other error; the handoff rides in\n`error.details.pending_action` (#388).\n",
        "properties": {
          "error": {
            "allOf": [
              {
                "$ref": "#/components/schemas/ErrorBody"
              },
              {
                "type": "object",
                "properties": {
                  "details": {
                    "type": "object",
                    "properties": {
                      "pending_action": {
                        "$ref": "#/components/schemas/PendingActionEmailVerification"
                      }
                    }
                  }
                }
              }
            ]
          }
        }
      }
    }
  },
  "security": [
    {
      "accountSession": []
    }
  ]
}
