{
  "openapi": "3.1.0",
  "info": {
    "title": "PivotBank API",
    "version": "1.0.0",
    "summary": "Bank statement PDF to structured transactions, with the statement's own balance check.",
    "description": "POST a bank statement PDF and get back every transaction as JSON: date, description, money out, money in, running balance, the page it was printed on, and whether the statement's own running balance vouches for it. One endpoint. Requires a Pro subscription and an API key from https://pivotbank.app/account/api-keys. Every response carries X-Pages-Left, the pages remaining in the plan's monthly allowance. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Policy for the plan's monthly page allowance. The API is versioned in the path; a version is never changed in place, and a retiring version would carry Deprecation and Sunset headers for at least six months.",
    "termsOfService": "https://pivotbank.app/terms",
    "contact": {
      "name": "PivotBank support",
      "email": "contact@pivotbank.app",
      "url": "https://pivotbank.app/contact"
    }
  },
  "servers": [
    {
      "url": "https://pivotbank.app"
    }
  ],
  "externalDocs": {
    "description": "Developer index, versioning policy and pricing",
    "url": "https://pivotbank.app/developers/llms.txt"
  },
  "security": [
    {
      "apiKey": []
    }
  ],
  "paths": {
    "/api/v1/convert": {
      "post": {
        "operationId": "convertStatement",
        "summary": "Convert a bank statement PDF to transactions",
        "description": "Reads a digital (text-layer) bank statement PDF of up to 15 MB and returns its transactions in printed order. The reading is checked against the statement's own running balances; `reconciliation.verified` is true only when opening balance plus every row lands exactly on the closing balance, and each row carries its own `check`. Scanned statements are not supported on this endpoint. Each call spends the statement's page count from the plan's monthly allowance; the balance after the call is in the X-Pages-Left header. A call that fails before parsing costs nothing.",
        "tags": [
          "Statements"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The statement PDF."
                  }
                }
              }
            },
            "application/pdf": {
              "schema": {
                "type": "string",
                "format": "binary",
                "description": "The statement PDF as the raw request body."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The statement, read.",
            "headers": {
              "X-Pages-Left": {
                "$ref": "#/components/headers/X-Pages-Left"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              },
              "Idempotent-Replayed": {
                "description": "true when this is the kept answer to an earlier request with the same Idempotency-Key.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "true"
                  ]
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Statement"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "422": {
            "description": "Not a readable PDF (`unparseable`, nothing charged), or an Idempotency-Key reused with a different file (`idempotency_key_reused`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "default": {
            "$ref": "#/components/responses/Default"
          },
          "405": {
            "description": "A method other than POST. `code` is `method_not_allowed`; the Allow header says POST.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Any unique string up to 255 visible characters. A retry with the same key and the same file within 24 hours gets the first successful answer back, marked Idempotent-Replayed: true, and is not charged again. The same key with a different file is a 422 with code idempotency_key_reused. Errors are not kept: nothing is charged on an error, so a retry runs again.",
            "schema": {
              "type": "string",
              "maxLength": 255
            }
          }
        ]
      }
    },
    "/api/v1/": {
      "get": {
        "operationId": "apiIndex",
        "summary": "What the API offers and how to get in",
        "description": "The API's front door: the endpoints, the spec, and the auth walkthrough, as JSON.",
        "tags": [
          "Meta"
        ],
        "security": [],
        "responses": {
          "200": {
            "description": "The index.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiIndex"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key, `pvb_live_...`, created at https://pivotbank.app/account/api-keys and shown once. Send it as `Authorization: Bearer pvb_live_...` (an `x-api-key` header is also accepted). Keys belong to a Pro account; a key on any other account is refused with 403."
      }
    },
    "headers": {
      "X-Pages-Left": {
        "description": "Pages left in the plan's allowance for the current period after this call, plan pages plus any top-up. 0 on a 402.",
        "schema": {
          "type": "integer",
          "minimum": 0
        }
      },
      "RateLimit-Limit": {
        "description": "The plan's premium pages per period.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Remaining": {
        "description": "Pages left in the period after this call.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Policy": {
        "description": "The window, in the RateLimit-Policy form: `<limit>;w=<seconds>`.",
        "schema": {
          "type": "string"
        }
      }
    },
    "schemas": {
      "Statement": {
        "type": "object",
        "required": [
          "bankName",
          "accountNumber",
          "period",
          "pageCount",
          "transactionCount",
          "reconciliation",
          "transactions"
        ],
        "properties": {
          "bankName": {
            "type": [
              "string",
              "null"
            ],
            "description": "The bank, when recognised.",
            "examples": [
              "Bank of Ireland"
            ]
          },
          "accountNumber": {
            "type": [
              "string",
              "null"
            ],
            "description": "As printed, when found."
          },
          "period": {
            "type": [
              "string",
              "null"
            ],
            "description": "The statement period as printed, when found."
          },
          "pageCount": {
            "type": "integer",
            "description": "Pages in the PDF, which is what the call cost."
          },
          "transactionCount": {
            "type": "integer"
          },
          "reconciliation": {
            "$ref": "#/components/schemas/Reconciliation"
          },
          "transactions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Transaction"
            }
          }
        }
      },
      "Reconciliation": {
        "type": "object",
        "description": "The statement's own arithmetic applied to the reading. `verified` is the one field to gate on: true means opening balance plus every credit minus every debit lands exactly on the closing balance.",
        "required": [
          "verified",
          "status",
          "opening",
          "closing",
          "expectedClosing",
          "difference",
          "totalIn",
          "totalOut"
        ],
        "properties": {
          "verified": {
            "type": "boolean"
          },
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "mismatch",
              "unavailable"
            ],
            "description": "`unavailable` means the statement prints too few running balances to check; that is not verified."
          },
          "opening": {
            "type": [
              "number",
              "null"
            ]
          },
          "closing": {
            "type": [
              "number",
              "null"
            ]
          },
          "expectedClosing": {
            "type": [
              "number",
              "null"
            ]
          },
          "difference": {
            "type": [
              "number",
              "null"
            ],
            "description": "closing minus expectedClosing; 0 when it balances."
          },
          "totalIn": {
            "type": "number"
          },
          "totalOut": {
            "type": "number"
          }
        }
      },
      "Transaction": {
        "type": "object",
        "required": [
          "date",
          "description",
          "debit",
          "credit",
          "balance",
          "check"
        ],
        "properties": {
          "date": {
            "type": "string",
            "description": "As printed on the statement, e.g. `06 Mar 2025` or `03/01/2026`."
          },
          "description": {
            "type": "string"
          },
          "debit": {
            "type": [
              "number",
              "null"
            ],
            "description": "Money out, positive, else null."
          },
          "credit": {
            "type": [
              "number",
              "null"
            ],
            "description": "Money in, positive, else null."
          },
          "balance": {
            "type": [
              "number",
              "null"
            ],
            "description": "Running balance after this row, negative when overdrawn, null when the row prints none."
          },
          "page": {
            "type": "integer",
            "minimum": 1,
            "description": "The page of the PDF this row was printed on, 1-based. Inferred from the figures; absent when it could not be told."
          },
          "check": {
            "type": "string",
            "enum": [
              "ok",
              "gap",
              "uncovered"
            ],
            "description": "What the running balances say about this row: `ok` follows from the row before, `gap` does not (this row or a neighbour is missing, doubled or the wrong way round), `uncovered` sits above the first or below the last printed balance and is unchecked."
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error",
          "code"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "What went wrong, in a sentence, with the fix where there is one."
          },
          "code": {
            "type": "string",
            "enum": [
              "missing_key",
              "invalid_key",
              "pro_required",
              "no_pages_left",
              "no_file",
              "unreadable",
              "empty",
              "too_large",
              "unparseable",
              "internal",
              "method_not_allowed",
              "idempotency_key_reused",
              "too_many_pages"
            ]
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "description": "Where to read about the API."
          }
        }
      },
      "ApiIndex": {
        "type": "object",
        "required": [
          "name",
          "version",
          "endpoints",
          "openapi",
          "auth",
          "docs"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "version": {
            "type": "string"
          },
          "endpoints": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "method",
                "path",
                "operationId"
              ],
              "properties": {
                "method": {
                  "type": "string"
                },
                "path": {
                  "type": "string"
                },
                "operationId": {
                  "type": "string"
                }
              }
            }
          },
          "openapi": {
            "type": "string",
            "format": "uri"
          },
          "auth": {
            "type": "string",
            "format": "uri"
          },
          "docs": {
            "type": "string",
            "format": "uri"
          },
          "versioning": {
            "type": "string",
            "format": "uri"
          },
          "mcp": {
            "type": "object",
            "description": "The MCP servers: one to act, one for the docs, and the server card.",
            "properties": {
              "server": {
                "type": "string",
                "format": "uri"
              },
              "docs": {
                "type": "string",
                "format": "uri"
              },
              "card": {
                "type": "string",
                "format": "uri"
              }
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "No file in the `file` field, an unreadable body, or an empty one. `code` is `no_file`, `unreadable` or `empty`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing, invalid or revoked key. `code` is `missing_key` or `invalid_key`. Carries `WWW-Authenticate: Bearer realm=\"PivotBank API\"`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "PaymentRequired": {
        "description": "The plan has no pages left this period. `code` is `no_pages_left`; X-Pages-Left is 0. Top-ups at https://pivotbank.app/account/billing.",
        "headers": {
          "X-Pages-Left": {
            "$ref": "#/components/headers/X-Pages-Left"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "The key belongs to an account without an active Pro subscription. `code` is `pro_required`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooLarge": {
        "description": "Over 15 MB, judged from Content-Length before the body is read. `code` is `too_large`. A PDF of more than 500 pages is also a 413, with `code` `too_many_pages`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unparseable": {
        "description": "Not a readable PDF, or a scanned one with no text layer. `code` is `unparseable`. Nothing is charged.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServerError": {
        "description": "Something broke on our side. `code` is `internal`. Nothing is charged; try again.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Default": {
        "description": "Any other failure, in the same shape.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}
