{
  "openapi": "3.0.3",
  "info": {
    "title": "Finero Public API",
    "version": "1.0.0",
    "description": "Tenant-scoped public API for Finero — AR / cash-flow automation. Authenticate every request with `Authorization: Bearer <API key>` (keys are created in Settings → API and are tenant-owned). All data belongs to the key's tenant; no request input can address another tenant. Two permissions exist: **admin** keys can call every operation; **readonly** keys only operations without side effects (`x-permission: readonly`). Lists are cursor-paginated (limit 1–100, follow pagination.next_cursor). Request bodies are capped at 64 KiB. Rate limits: 120/min per key, 600/min per tenant (429 + Retry-After). Decimal money amounts are exact strings (never floats); *_minor amounts are integers in the currency's minor unit. Timestamps are ISO 8601 UTC.\n\n**There is a second way in.** This API is for systems and unattended automation, authenticated by a tenant API key that acts as the WORKSPACE. If instead a person is present and connecting an AI assistant to their own workspace, use the Finero MCP server at `https://api.getfinero.com/functions/v1/mcp` — Model Context Protocol over the Streamable HTTP transport (JSON-RPC 2.0, `MCP-Protocol-Version` 2025-06-18 or 2025-03-26), authorized with OAuth 2.1 so the session acts as THAT PERSON. Its `server.json` manifest — the MCP registry's published format — is at `https://api.getfinero.com/functions/v1/mcp/server.json`. It is read-mostly — 15 tools (list_invoices, summarize_invoices, get_invoice, list_payment_integrations, list_payments, list_workflows, list_workflow_executions, list_erp_connections, list_sync_runs, get_sync_run, start_erp_sync, list_payment_links, get_payment_link, create_payment_link, deactivate_payment_link). An agent connected to an ADMIN can additionally issue and withdraw payment links; it can never send email it has written, take or move money, or write to an ERP. Its OAuth scopes are declared in RFC 9728 protected-resource metadata at `https://api.getfinero.com/functions/v1/mcp/.well-known/oauth-protected-resource`, which also names the authorization server. The two are not interchangeable: if you hold an API key, use this API."
  },
  "externalDocs": {
    "description": "Agent-facing orientation, including the MCP server and how to connect one.",
    "url": "https://app.getfinero.com/llms.txt"
  },
  "servers": [
    {
      "url": "https://api.getfinero.com/functions/v1/api"
    }
  ],
  "security": [
    {
      "apiKey": []
    }
  ],
  "tags": [
    {
      "name": "API context"
    },
    {
      "name": "Invoices"
    },
    {
      "name": "Payment links"
    },
    {
      "name": "Payments"
    },
    {
      "name": "Integrations"
    },
    {
      "name": "ERP sync"
    },
    {
      "name": "Workflows"
    }
  ],
  "paths": {
    "/v1/erp-connections": {
      "get": {
        "operationId": "listErpConnections",
        "summary": "List ERP connections",
        "description": "Lists the tenant's ERP connections (safe status metadata only — never credentials, hosts, or configuration). Connecting a new ERP and configuring what it syncs are managed inside the Finero app; starting a pull and changing the pull cadence can be done here.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "ERP sync"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1–100. Default 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque pagination cursor from a previous response's pagination.next_cursor.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 400
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "Sort direction. Ordering is ALWAYS by when the record was CREATED in Finero, never by when it changed — `updated_since` narrows the set but does not reorder it, so the most recently updated row is not necessarily first. Creation time is immutable, which is what lets the cursor stay stable: ordering by a value that changes would move a row mid-walk and make a page skip or repeat it. Default desc (newest first by creation time).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "include_total",
            "in": "query",
            "required": false,
            "description": "Return pagination.total_count — the number of rows matching the filters, ignoring paging. It is the SAME number on every page, including alongside a cursor. Off by default: an exact count scans the whole filtered set, so asking on each page pays repeatedly for an answer that does not change — ask on the first request and keep it.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "description": "ERP connections.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "ERP connection id — use as connection_id when starting a sync run.",
                            "format": "uuid"
                          },
                          "provider": {
                            "type": "string",
                            "description": "ERP system this connection syncs from.",
                            "enum": [
                              "oracle_fusion"
                            ]
                          },
                          "status": {
                            "type": "string",
                            "description": "Connection health, and therefore whether the invoice data you are reading is CURRENT. `connected` means pulls are working. `requires_reauthentication` means the credential has expired or been revoked: every pull fails until a workspace admin reconnects it in the Finero app, so nothing new arrives and every figure is only as fresh as `last_successful_sync_at` - say so when you report numbers. `temporarily_unavailable` is the ERP being unreachable and usually clears itself. `disconnected` means the connection is switched off. In any state but `connected`, compare `last_successful_sync_at` against `last_sync_at`: a LATER `last_sync_at` means runs are still happening and failing.",
                            "enum": [
                              "connected",
                              "requires_reauthentication",
                              "temporarily_unavailable",
                              "disconnected"
                            ]
                          },
                          "last_sync_at": {
                            "type": "string",
                            "description": "When a sync run last finished, whatever its outcome. Use last_successful_sync_at to judge freshness (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "last_successful_sync_at": {
                            "type": "string",
                            "description": "When a sync run last finished successfully (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "auto_sync_enabled": {
                            "type": "boolean",
                            "description": "Whether scheduled auto-sync is on. False is the workspace's \"off\" choice - manual pulls still work, and `auto_sync_interval_minutes` keeps the cadence that applies when it is turned back on."
                          },
                          "auto_sync_interval_minutes": {
                            "type": "integer",
                            "description": "How often Finero pulls this connection automatically, in minutes. A workspace admin chooses it: 10 to 1440 (24 hours). Read it together with `auto_sync_enabled` - when that is false there is no automatic pull at all and this value is simply the setting kept for when it is turned back on. It is a FLOOR rather than an appointment: a connection is pulled on the first scheduled check after this much time has passed, typically within a minute of it."
                          },
                          "pull_invoice_date_from": {
                            "type": "string",
                            "description": "Oldest invoice date this connection imports, inclusive. null means no limit. This is why an invoice that exists in the ERP may be absent here: it is dated before the limit. Changing it never deletes invoices already imported (YYYY-MM-DD).",
                            "nullable": true,
                            "format": "date"
                          },
                          "created_at": {
                            "type": "string",
                            "description": "Creation time (ISO 8601 UTC).",
                            "format": "date-time"
                          }
                        },
                        "required": [
                          "id",
                          "provider",
                          "status",
                          "last_sync_at",
                          "last_successful_sync_at",
                          "auto_sync_enabled",
                          "auto_sync_interval_minutes",
                          "pull_invoice_date_from",
                          "created_at"
                        ],
                        "additionalProperties": false
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "next_cursor": {
                          "type": "string",
                          "description": "Opaque cursor for the next page — pass as ?cursor=. Null when there are no further results.",
                          "nullable": true
                        },
                        "has_more": {
                          "type": "boolean",
                          "description": "Whether another page exists."
                        },
                        "limit": {
                          "type": "integer",
                          "description": "The page size that was applied."
                        },
                        "total_count": {
                          "type": "integer",
                          "description": "Total rows matching the filters, ignoring paging. Present ONLY when the request passed ?include_total=true."
                        }
                      },
                      "required": [
                        "next_cursor",
                        "has_more",
                        "limit"
                      ],
                      "additionalProperties": false
                    }
                  },
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "data": [
                    {
                      "id": "1a2b3c4d-5e6f-4a80-91b2-c3d4e5f60718",
                      "provider": "oracle_fusion",
                      "status": "connected",
                      "last_sync_at": "2026-07-14T05:00:04+00:00",
                      "last_successful_sync_at": "2026-07-14T05:00:04+00:00",
                      "auto_sync_enabled": true,
                      "auto_sync_interval_minutes": 60,
                      "pull_invoice_date_from": "2024-01-01",
                      "created_at": "2026-06-20T11:00:00+00:00"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/erp-connections/{connectionId}/sync": {
      "post": {
        "operationId": "startErpSync",
        "summary": "Start a pull from the ERP",
        "description": "Starts an invoice pull for this connection and returns immediately with the id of the run it reserved — the pull itself continues in the background. Poll that run until `status` is terminal. Only ONE run can be in flight per connection: if a scheduled or app-triggered pull is already running, this is REFUSED as a conflict rather than queueing a second one — list the connection's runs to find the one in flight and poll that. A `running` answer means the run was RESERVED and dispatched, NOT that the connection authenticated: a connection whose credential has expired accepts this call and the run fails seconds later. Check the connection's `status`, and poll the run to a terminal status before reporting that anything refreshed. This does not change the connection's schedule; the automatic cadence continues unchanged alongside it.\n\nRequires an **admin** API key (has side effects).",
        "tags": [
          "ERP sync"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "admin",
        "x-side-effects": true,
        "parameters": [
          {
            "name": "connectionId",
            "in": "path",
            "required": true,
            "description": "ERP connection id (from GET /v1/erp-connections).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "Accepted. The pull runs in the background.",
                  "properties": {
                    "sync_run_id": {
                      "type": "string",
                      "description": "The run that was reserved — poll GET /v1/sync-runs/{syncRunId}.",
                      "format": "uuid"
                    },
                    "status": {
                      "type": "string",
                      "description": "Always `running`: the run is reserved and the pull has been dispatched.",
                      "enum": [
                        "running"
                      ]
                    }
                  },
                  "required": [
                    "sync_run_id",
                    "status"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "sync_run_id": "3f2e1d0c-9b8a-4756-8493-21f0e9d8c7b6",
                  "status": "running"
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`conflict` — State conflict — e.g. an active payment link already exists for the installment, or a concurrent identical request is in flight.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "`payload_too_large` — The request body exceeds the 64 KiB limit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`sync_disabled` — Invoice sync is switched off for this ERP connection, so there is nothing to pull. A workspace administrator turns it back on in the Finero app; retrying will not change the answer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/erp-connections/{connectionId}/sync-schedule": {
      "post": {
        "operationId": "setErpSyncSchedule",
        "summary": "Set the automatic pull cadence",
        "description": "Sets how often Finero pulls this connection automatically, and whether it does so at all. Returns the updated connection. `interval_minutes` is a FLOOR, not an appointment: the connection is pulled on the first scheduled check after that much time has passed, typically within a minute of it. Setting `enabled` to false stops automatic pulls and KEEPS the interval, so turning it back on resumes the same cadence — which is why `interval_minutes` is required either way. Manual pulls (POST .../sync) work regardless.\n\nRequires an **admin** API key (has side effects).",
        "tags": [
          "ERP sync"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "admin",
        "x-side-effects": true,
        "parameters": [
          {
            "name": "connectionId",
            "in": "path",
            "required": true,
            "description": "ERP connection id (from GET /v1/erp-connections).",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "ERP connection id — use as connection_id when starting a sync run.",
                      "format": "uuid"
                    },
                    "provider": {
                      "type": "string",
                      "description": "ERP system this connection syncs from.",
                      "enum": [
                        "oracle_fusion"
                      ]
                    },
                    "status": {
                      "type": "string",
                      "description": "Connection health, and therefore whether the invoice data you are reading is CURRENT. `connected` means pulls are working. `requires_reauthentication` means the credential has expired or been revoked: every pull fails until a workspace admin reconnects it in the Finero app, so nothing new arrives and every figure is only as fresh as `last_successful_sync_at` - say so when you report numbers. `temporarily_unavailable` is the ERP being unreachable and usually clears itself. `disconnected` means the connection is switched off. In any state but `connected`, compare `last_successful_sync_at` against `last_sync_at`: a LATER `last_sync_at` means runs are still happening and failing.",
                      "enum": [
                        "connected",
                        "requires_reauthentication",
                        "temporarily_unavailable",
                        "disconnected"
                      ]
                    },
                    "last_sync_at": {
                      "type": "string",
                      "description": "When a sync run last finished, whatever its outcome. Use last_successful_sync_at to judge freshness (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "last_successful_sync_at": {
                      "type": "string",
                      "description": "When a sync run last finished successfully (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "auto_sync_enabled": {
                      "type": "boolean",
                      "description": "Whether scheduled auto-sync is on. False is the workspace's \"off\" choice - manual pulls still work, and `auto_sync_interval_minutes` keeps the cadence that applies when it is turned back on."
                    },
                    "auto_sync_interval_minutes": {
                      "type": "integer",
                      "description": "How often Finero pulls this connection automatically, in minutes. A workspace admin chooses it: 10 to 1440 (24 hours). Read it together with `auto_sync_enabled` - when that is false there is no automatic pull at all and this value is simply the setting kept for when it is turned back on. It is a FLOOR rather than an appointment: a connection is pulled on the first scheduled check after this much time has passed, typically within a minute of it."
                    },
                    "pull_invoice_date_from": {
                      "type": "string",
                      "description": "Oldest invoice date this connection imports, inclusive. null means no limit. This is why an invoice that exists in the ERP may be absent here: it is dated before the limit. Changing it never deletes invoices already imported (YYYY-MM-DD).",
                      "nullable": true,
                      "format": "date"
                    },
                    "created_at": {
                      "type": "string",
                      "description": "Creation time (ISO 8601 UTC).",
                      "format": "date-time"
                    }
                  },
                  "required": [
                    "id",
                    "provider",
                    "status",
                    "last_sync_at",
                    "last_successful_sync_at",
                    "auto_sync_enabled",
                    "auto_sync_interval_minutes",
                    "pull_invoice_date_from",
                    "created_at"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "id": "1a2b3c4d-5e6f-4a80-91b2-c3d4e5f60718",
                  "provider": "oracle_fusion",
                  "status": "connected",
                  "last_sync_at": "2026-07-14T05:00:04+00:00",
                  "last_successful_sync_at": "2026-07-14T05:00:04+00:00",
                  "auto_sync_enabled": true,
                  "auto_sync_interval_minutes": 30,
                  "pull_invoice_date_from": "2024-01-01",
                  "created_at": "2026-06-20T11:00:00+00:00"
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`conflict` — State conflict — e.g. an active payment link already exists for the installment, or a concurrent identical request is in flight.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "`payload_too_large` — The request body exceeds the 64 KiB limit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "interval_minutes": {
                    "type": "integer",
                    "description": "Minutes between automatic pulls: 10 (the shortest cadence Finero commits to) to 1440 (24 hours). Required even when `enabled` is false — it is the cadence kept for when automatic syncing is switched back on.",
                    "minimum": 10,
                    "maximum": 1440
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "Whether automatic pulls run at all."
                  }
                },
                "required": [
                  "interval_minutes",
                  "enabled"
                ],
                "additionalProperties": false
              },
              "example": {
                "interval_minutes": 30,
                "enabled": true
              }
            }
          }
        }
      }
    },
    "/v1/invoices": {
      "get": {
        "operationId": "listInvoices",
        "summary": "List invoices",
        "description": "Lists the tenant's synced invoices, newest first by creation time. Cursor-paginated: pass ?limit= (1–100) and follow pagination.next_cursor until has_more is false. To learn HOW MANY rows match without walking every page, pass ?include_total=true once and read pagination.total_count. Filters are allowlisted; combine freely. collection_status filters like any other field — it is a stored, indexed value, so it pages and counts exactly as the rest do and combines with ?include_total=true.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Invoices"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1–100. Default 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque pagination cursor from a previous response's pagination.next_cursor.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 400
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "Sort direction. Ordering is ALWAYS by when the record was CREATED in Finero, never by when it changed — `updated_since` narrows the set but does not reorder it, so the most recently updated row is not necessarily first. Creation time is immutable, which is what lets the cursor stay stable: ordering by a value that changes would move a row mid-walk and make a page skip or repeat it. Default desc (newest first by creation time).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "include_total",
            "in": "query",
            "required": false,
            "description": "Return pagination.total_count — the number of rows matching the filters, ignoring paging. It is the SAME number on every page, including alongside a cursor. Off by default: an exact count scans the whole filtered set, so asking on each page pays repeatedly for an answer that does not change — ask on the first request and keep it.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "collection_status",
            "in": "query",
            "required": false,
            "description": "Return only invoices with this collection status — Finero's own payment record, so paid means FINERO collected it and an invoice settled elsewhere is inactive. Combines with every other filter, and with include_total.",
            "schema": {
              "type": "string",
              "enum": [
                "paid",
                "open",
                "inactive"
              ]
            }
          },
          {
            "name": "currency",
            "in": "query",
            "required": false,
            "description": "Filter by ISO 4217 currency code.",
            "schema": {
              "type": "string",
              "minLength": 3,
              "maxLength": 3
            }
          },
          {
            "name": "invoice_number",
            "in": "query",
            "required": false,
            "description": "Exact-match filter on the ERP invoice number.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 100
            }
          },
          {
            "name": "connection_id",
            "in": "query",
            "required": false,
            "description": "Filter by ERP connection.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "updated_since",
            "in": "query",
            "required": false,
            "description": "Only invoices updated at or after this ISO 8601 timestamp. The bound INCLUDES its own timestamp, so polling with the newest `updated_at` you have seen returns that row again every time - expected, and not a change.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "customer_id",
            "in": "query",
            "required": false,
            "description": "Only invoices for this customer, by the stable `customer.id` on the invoice. This is the ONLY safe way to total one customer's exposure — grouping by name merges two customers who share one, and grouping by email splits a customer across its billing addresses.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "erp_push_state",
            "in": "query",
            "required": false,
            "description": "Only invoices whose payment write-back to the ERP is in this state. `failed` and `reversed` are the ones to watch: under both, money Finero collected is not represented in the ERP, so its own balance overstates what is owed — and without this filter a stuck invoice is invisible unless you already know its number.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "processing",
                "posted",
                "applied",
                "failed",
                "reversed"
              ]
            }
          },
          {
            "name": "due_before",
            "in": "query",
            "required": false,
            "description": "Only invoices whose collection_due_date is on or before this date (YYYY-MM-DD), INCLUSIVE. The overdue worklist is due_before=<today> with collection_status=open. FILTERS ON collection_due_date, so an invoice that has none is excluded — and one has none precisely when nothing on it is collectible, which is also when it can still owe money. Date bands therefore never sum to the whole book: check a total against a call with no date filter, never against the bands. A PAID invoice has no `collection_due_date` either, so a date-filtered call reports `amount_collected` as 0 in every currency: these filters answer what is OWED, never what was collected.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "due_after",
            "in": "query",
            "required": false,
            "description": "Only invoices whose collection_due_date is on or after this date (YYYY-MM-DD), INCLUSIVE. Combine with due_before for one aging bucket — but because BOTH ends include their own date, adjacent bands must not share a boundary: use due_before=X then due_after=<X plus one day>, or every invoice dated exactly X is counted twice and the profile sums to more than the book. Invoices with no `collection_due_date` are excluded from EVERY date-filtered call, and one has none precisely when nothing on it is collectible — which is also when it can still owe money. Date bands never sum to the whole book; check a total against a call with no date filter. A PAID invoice has no `collection_due_date` either, so a date-filtered call reports `amount_collected` as 0 in every currency: these filters answer what is OWED, never what was collected.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "description": "Invoices, newest first by creation time.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Invoice id (stable Finero identifier).",
                            "format": "uuid"
                          },
                          "invoice_number": {
                            "type": "string",
                            "description": "Human-readable local or ERP invoice number.",
                            "nullable": true
                          },
                          "currency": {
                            "type": "string",
                            "description": "Invoice currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                            "nullable": true,
                            "minLength": 3,
                            "maxLength": 3
                          },
                          "total_amount": {
                            "type": "string",
                            "description": "Invoice total. Decimal amount as a string (exact, 4 dp) — never parse as float for arithmetic.",
                            "nullable": true,
                            "pattern": "^-?\\d+(\\.\\d+)?$"
                          },
                          "erp_invoice_open_balance": {
                            "type": "string",
                            "description": "Outstanding balance across the invoice, as YOUR ERP last reported it. It does NOT drop when Finero collects: settlement is reported to the ERP as a receipt, and this figure only moves once the ERP has processed it and the next sync reads it back. So an invoice can read `collection_status: \"paid\"` with `collection_outstanding: \"0.0000\"` and still show a balance here — that is the normal window, not a discrepancy. `erp_push_state` says where in that window the invoice is: `pending`/`processing` is in flight, `posted`/`applied` means the ERP has it and the balance will follow; `failed` means it never arrived, and `reversed` means it arrived and the ERP later took it back out. Under either of the last two this figure stays stale until somebody acts. Decimal amount as a string (exact, 4 dp) — never parse as float for arithmetic.",
                            "nullable": true,
                            "pattern": "^-?\\d+(\\.\\d+)?$"
                          },
                          "invoice_date": {
                            "type": "string",
                            "description": "Invoice date (YYYY-MM-DD).",
                            "nullable": true,
                            "format": "date"
                          },
                          "due_date": {
                            "type": "string",
                            "description": "Invoice due date (YYYY-MM-DD).",
                            "nullable": true,
                            "format": "date"
                          },
                          "collection_due_date": {
                            "type": "string",
                            "description": "THE DATE TO AGE THIS INVOICE BY — the earliest due date among the installments Finero can currently collect on. Use this for overdue lists and aging buckets, NOT `due_date` above: an ERP commonly carries dates on the payment schedule and leaves the invoice-level field empty, so `due_date` is null on most real data and aging by it silently omits most of the book. Null when nothing is collectible or the ERP dated none of it. Filter with `due_before` / `due_after`.",
                            "nullable": true,
                            "format": "date"
                          },
                          "last_synced_at": {
                            "type": "string",
                            "description": "When Finero last read this invoice FROM the ERP. Use it to ask whether a row is still current with your ERP — `updated_at` cannot answer that, because Finero's own writes move it too (recording a payment, marking an invoice no longer present), so a row can look freshly updated while the ERP has not been heard from in weeks. Null for invoices created in Finero, which have no ERP to read from, and for a few rows that predate the field. (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "collection_outstanding": {
                            "type": "string",
                            "description": "What Finero still has to collect on this invoice, as an exact decimal string. THIS IS THE FIELD THAT ACCOUNTS FOR PAYMENTS FINERO HAS ALREADY TAKEN, including before your ERP has been told about them. `erp_invoice_open_balance` above is the ERP's last word and goes stale the moment Finero collects - on a real invoice it read 1398.63 while this read 0.0000, because two installments had been paid through Finero and the push back to the ERP had failed. Neither number is wrong; they answer different questions. Use `erp_invoice_open_balance` when comparing with what your ERP shows, and this to answer \"what is left to collect\"."
                          },
                          "amount_collected": {
                            "type": "string",
                            "description": "How much Finero has confirmed collected on this invoice, as an exact decimal string. Money taken through a Finero payment link only - a payment made directly to you, or settled in your ERP, is not counted here and leaves this at 0.0000."
                          },
                          "erp_push_state": {
                            "type": "string",
                            "description": "How far the money Finero collected has got into your ERP, or null when Finero has collected nothing on this invoice. `pending` means the push is queued and `processing` that it is in flight - under both, the ERP has not been told yet. `applied` means the ERP accepted the receipt AND allocated it; `posted` means accepted but not yet allocated - the ERP owns that decision. `failed` and `reversed` are why an `erp_invoice_open_balance` can stay stale, and they are NOT the same event: `failed` means the receipt never reached the ERP, `reversed` means it did and the ERP undid it - reversed, stopped, or returned unpaid. Finero retries a `failed` push every 15 minutes for 24 hours when the ERP was simply unreachable; any other cause, and every `reversed` row, waits for a person, because retrying cannot fix it. The WORST state across the invoice's pushes is reported, because this field exists to surface a problem and a majority would hide one. NOT TO BE CONFUSED WITH a payment's `erp_push_status`: that one is a SINGLE payment's own push, while this rolls up every push on the invoice.",
                            "nullable": true,
                            "enum": [
                              "pending",
                              "processing",
                              "posted",
                              "applied",
                              "failed",
                              "reversed"
                            ]
                          },
                          "collection_status": {
                            "type": "string",
                            "description": "ANSWER \"HAS THIS INVOICE BEEN PAID?\" FROM THIS FIELD. `paid` MEANS THE INVOICE IS PAID - nothing is left to collect on it - and it is the most current answer available: Finero knows the moment it collects, and your ERP finds out afterwards. `paid` REQUIRES TWO THINGS: Finero collected on it, AND no installment has a balance outstanding. An invoice where Finero collected part and the remainder is disputed, excluded or otherwise blocked reads `inactive`, not `paid` — so `paid` can never hide money that is still owed. `open` means at least one installment on it is COLLECTIBLE by Finero — that, and nothing more. Whether a payment link or reminder actually goes out is a SEPARATE question, decided by which workflows this workspace has switched on, and no status on this invoice answers it. `inactive` is everything else. THE CONVERSE DOES NOT HOLD, and this is the one trap: NOT-`paid` does not mean unpaid. An invoice the customer settled directly with you, outside Finero, reads `inactive` - Finero did not take that money, so it cannot report having collected it. `paid` is conclusive; anything else means find out why. `paid` ALSO DOES NOT SAY HOW MUCH FINERO COLLECTED: an invoice settled by mixed paths - one installment direct to you, the rest through Finero - is still `paid`. Compare `amount_collected` against `total_amount` to attribute it. YOUR ERP'S OWN FIGURES LAG THIS FIELD, which has three consequences. ONE: `erp_invoice_open_balance` above zero on a `paid` invoice is that lag, your ERP not having applied the receipt yet, or a push that failed - NOT a contradiction and NOT a reason to doubt the status. Report both the paid status and the ERP lag, and read `erp_push_state` to tell ordinary lag from a push that will stay unreflected until someone acts. TWO: an invoice finalised with a balance still reads `inactive` when nothing on it can be collected - every installment disputed, excluded from collections, closed in your ERP, or the invoice has no billing email or a currency no connected payment provider can charge - and THE MONEY IS NOT WRITTEN OFF: `erp_invoice_open_balance` still carries it, as does each installment's `erp_installment_outstanding_amount` on the invoice detail. THREE: DO NOT SUM `erp_invoice_open_balance` FOR RECEIVABLES - it still counts money Finero has already collected but not yet pushed back, indefinitely so when a push has failed. Sum `collection_outstanding` for what is OWED - but it is populated on invoices nothing can be collected from, so filter to `collection_status=open` for what can actually be collected, and read each installment's `collectible` to see which parts. Other `inactive` causes: your ERP shows no balance outstanding, has not finalised the invoice, no longer has the invoice at all (Finero stops collecting an invoice a complete sync no longer finds, and resumes if a later sync finds it again), or has not generated a payment schedule - every way Finero collects is attached to an installment, so with none there is nothing to collect against however much is owed.",
                            "enum": [
                              "paid",
                              "open",
                              "inactive"
                            ]
                          },
                          "customer": {
                            "type": "object",
                            "description": "Bill-to customer details embedded on the invoice. There is no customer ENDPOINT, but there is a stable customer KEY — see `id`.",
                            "properties": {
                              "id": {
                                "type": "string",
                                "description": "STABLE CUSTOMER KEY — group by this, never by name or email. Finero resolves each ERP customer into one row per (workspace, connection, ERP customer id), so this is the same value across every invoice for that customer. Name and email are NOT keys and fail in opposite directions: two different customers can share a name (\"Globex Corporation #1\" twice), and one customer can bill from several addresses. Null only on invoices created in Finero with no ERP customer behind them.",
                                "nullable": true,
                                "format": "uuid"
                              },
                              "number": {
                                "type": "string",
                                "description": "Bill-to customer number from the ERP.",
                                "nullable": true
                              },
                              "name": {
                                "type": "string",
                                "description": "Bill-to customer name — the ACCOUNT (for example a company). Distinct from contact_name, which is a person.",
                                "nullable": true
                              },
                              "contact_name": {
                                "type": "string",
                                "description": "The person named on the invoice as the bill-to contact. Display only: it is never used to identify anyone and nothing in Finero branches on it, because an ERP can carry two people with the same name on one account. Null when the ERP sends none, or when the workspace has switched the Contact name pull field off for this connector. For a sole trader it may equal name.",
                                "nullable": true
                              },
                              "email": {
                                "type": "string",
                                "description": "Billing email the payment-link email is sent to.",
                                "nullable": true
                              }
                            },
                            "required": [
                              "id",
                              "number",
                              "name",
                              "contact_name",
                              "email"
                            ],
                            "additionalProperties": false
                          },
                          "origin": {
                            "type": "string",
                            "description": "Provenance / system of record: local = the invoice exists only in Finero; erp = an ERP connection owns (or will own) the record.",
                            "enum": [
                              "local",
                              "erp"
                            ]
                          },
                          "connection_id": {
                            "type": "string",
                            "description": "The exact ERP connection associated with this invoice.",
                            "nullable": true,
                            "format": "uuid"
                          },
                          "created_at": {
                            "type": "string",
                            "description": "Creation time (ISO 8601 UTC).",
                            "format": "date-time"
                          },
                          "updated_at": {
                            "type": "string",
                            "description": "Last update time (ISO 8601 UTC).",
                            "format": "date-time"
                          }
                        },
                        "required": [
                          "id",
                          "invoice_number",
                          "currency",
                          "total_amount",
                          "erp_invoice_open_balance",
                          "invoice_date",
                          "due_date",
                          "collection_due_date",
                          "last_synced_at",
                          "collection_status",
                          "collection_outstanding",
                          "amount_collected",
                          "erp_push_state",
                          "customer",
                          "origin",
                          "connection_id",
                          "created_at",
                          "updated_at"
                        ],
                        "additionalProperties": false
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "next_cursor": {
                          "type": "string",
                          "description": "Opaque cursor for the next page — pass as ?cursor=. Null when there are no further results.",
                          "nullable": true
                        },
                        "has_more": {
                          "type": "boolean",
                          "description": "Whether another page exists."
                        },
                        "limit": {
                          "type": "integer",
                          "description": "The page size that was applied."
                        },
                        "total_count": {
                          "type": "integer",
                          "description": "Total rows matching the filters, ignoring paging. Present ONLY when the request passed ?include_total=true."
                        }
                      },
                      "required": [
                        "next_cursor",
                        "has_more",
                        "limit"
                      ],
                      "additionalProperties": false
                    }
                  },
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "data": [
                    {
                      "id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                      "invoice_number": "INV-10421",
                      "currency": "USD",
                      "total_amount": "1250.0000",
                      "erp_invoice_open_balance": "1250.0000",
                      "invoice_date": "2026-07-01",
                      "due_date": "2026-08-01",
                      "last_synced_at": "2026-07-10T06:30:00+00:00",
                      "collection_status": "open",
                      "collection_outstanding": "1250.0000",
                      "amount_collected": "0.0000",
                      "erp_push_state": null,
                      "collection_due_date": "2026-08-20",
                      "customer": {
                        "id": "b7c8d9e0-1f2a-4b3c-8d4e-5f60718293a4",
                        "number": "CUST-2201",
                        "name": "Acme Industries Ltd",
                        "contact_name": "Marco Johnson",
                        "email": "ap@acme-industries.example"
                      },
                      "origin": "erp",
                      "connection_id": "1a2b3c4d-5e6f-4a80-91b2-c3d4e5f60718",
                      "created_at": "2026-07-01T09:15:00+00:00",
                      "updated_at": "2026-07-10T06:30:00+00:00"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/invoices/{invoiceId}": {
      "get": {
        "operationId": "getInvoice",
        "summary": "Get an invoice with its installments",
        "description": "Returns one invoice including its installments (ordered by sequence). Use an installment's id to create a payment link. Returns not_found for ids outside your tenant.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Invoices"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "invoiceId",
            "in": "path",
            "required": true,
            "description": "Invoice id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "Invoice id (stable Finero identifier).",
                      "format": "uuid"
                    },
                    "invoice_number": {
                      "type": "string",
                      "description": "Human-readable local or ERP invoice number.",
                      "nullable": true
                    },
                    "currency": {
                      "type": "string",
                      "description": "Invoice currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                      "nullable": true,
                      "minLength": 3,
                      "maxLength": 3
                    },
                    "total_amount": {
                      "type": "string",
                      "description": "Invoice total. Decimal amount as a string (exact, 4 dp) — never parse as float for arithmetic.",
                      "nullable": true,
                      "pattern": "^-?\\d+(\\.\\d+)?$"
                    },
                    "erp_invoice_open_balance": {
                      "type": "string",
                      "description": "Outstanding balance across the invoice, as YOUR ERP last reported it. It does NOT drop when Finero collects: settlement is reported to the ERP as a receipt, and this figure only moves once the ERP has processed it and the next sync reads it back. So an invoice can read `collection_status: \"paid\"` with `collection_outstanding: \"0.0000\"` and still show a balance here — that is the normal window, not a discrepancy. `erp_push_state` says where in that window the invoice is: `pending`/`processing` is in flight, `posted`/`applied` means the ERP has it and the balance will follow; `failed` means it never arrived, and `reversed` means it arrived and the ERP later took it back out. Under either of the last two this figure stays stale until somebody acts. Decimal amount as a string (exact, 4 dp) — never parse as float for arithmetic.",
                      "nullable": true,
                      "pattern": "^-?\\d+(\\.\\d+)?$"
                    },
                    "invoice_date": {
                      "type": "string",
                      "description": "Invoice date (YYYY-MM-DD).",
                      "nullable": true,
                      "format": "date"
                    },
                    "due_date": {
                      "type": "string",
                      "description": "Invoice due date (YYYY-MM-DD).",
                      "nullable": true,
                      "format": "date"
                    },
                    "collection_due_date": {
                      "type": "string",
                      "description": "THE DATE TO AGE THIS INVOICE BY — the earliest due date among the installments Finero can currently collect on. Use this for overdue lists and aging buckets, NOT `due_date` above: an ERP commonly carries dates on the payment schedule and leaves the invoice-level field empty, so `due_date` is null on most real data and aging by it silently omits most of the book. Null when nothing is collectible or the ERP dated none of it. Filter with `due_before` / `due_after`.",
                      "nullable": true,
                      "format": "date"
                    },
                    "last_synced_at": {
                      "type": "string",
                      "description": "When Finero last read this invoice FROM the ERP. Use it to ask whether a row is still current with your ERP — `updated_at` cannot answer that, because Finero's own writes move it too (recording a payment, marking an invoice no longer present), so a row can look freshly updated while the ERP has not been heard from in weeks. Null for invoices created in Finero, which have no ERP to read from, and for a few rows that predate the field. (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "collection_outstanding": {
                      "type": "string",
                      "description": "What Finero still has to collect on this invoice, as an exact decimal string. THIS IS THE FIELD THAT ACCOUNTS FOR PAYMENTS FINERO HAS ALREADY TAKEN, including before your ERP has been told about them. `erp_invoice_open_balance` above is the ERP's last word and goes stale the moment Finero collects - on a real invoice it read 1398.63 while this read 0.0000, because two installments had been paid through Finero and the push back to the ERP had failed. Neither number is wrong; they answer different questions. Use `erp_invoice_open_balance` when comparing with what your ERP shows, and this to answer \"what is left to collect\"."
                    },
                    "amount_collected": {
                      "type": "string",
                      "description": "How much Finero has confirmed collected on this invoice, as an exact decimal string. Money taken through a Finero payment link only - a payment made directly to you, or settled in your ERP, is not counted here and leaves this at 0.0000."
                    },
                    "erp_push_state": {
                      "type": "string",
                      "description": "How far the money Finero collected has got into your ERP, or null when Finero has collected nothing on this invoice. `pending` means the push is queued and `processing` that it is in flight - under both, the ERP has not been told yet. `applied` means the ERP accepted the receipt AND allocated it; `posted` means accepted but not yet allocated - the ERP owns that decision. `failed` and `reversed` are why an `erp_invoice_open_balance` can stay stale, and they are NOT the same event: `failed` means the receipt never reached the ERP, `reversed` means it did and the ERP undid it - reversed, stopped, or returned unpaid. Finero retries a `failed` push every 15 minutes for 24 hours when the ERP was simply unreachable; any other cause, and every `reversed` row, waits for a person, because retrying cannot fix it. The WORST state across the invoice's pushes is reported, because this field exists to surface a problem and a majority would hide one. NOT TO BE CONFUSED WITH a payment's `erp_push_status`: that one is a SINGLE payment's own push, while this rolls up every push on the invoice.",
                      "nullable": true,
                      "enum": [
                        "pending",
                        "processing",
                        "posted",
                        "applied",
                        "failed",
                        "reversed"
                      ]
                    },
                    "collection_status": {
                      "type": "string",
                      "description": "ANSWER \"HAS THIS INVOICE BEEN PAID?\" FROM THIS FIELD. `paid` MEANS THE INVOICE IS PAID - nothing is left to collect on it - and it is the most current answer available: Finero knows the moment it collects, and your ERP finds out afterwards. `paid` REQUIRES TWO THINGS: Finero collected on it, AND no installment has a balance outstanding. An invoice where Finero collected part and the remainder is disputed, excluded or otherwise blocked reads `inactive`, not `paid` — so `paid` can never hide money that is still owed. `open` means at least one installment on it is COLLECTIBLE by Finero — that, and nothing more. Whether a payment link or reminder actually goes out is a SEPARATE question, decided by which workflows this workspace has switched on, and no status on this invoice answers it. `inactive` is everything else. THE CONVERSE DOES NOT HOLD, and this is the one trap: NOT-`paid` does not mean unpaid. An invoice the customer settled directly with you, outside Finero, reads `inactive` - Finero did not take that money, so it cannot report having collected it. `paid` is conclusive; anything else means find out why. `paid` ALSO DOES NOT SAY HOW MUCH FINERO COLLECTED: an invoice settled by mixed paths - one installment direct to you, the rest through Finero - is still `paid`. Compare `amount_collected` against `total_amount` to attribute it. YOUR ERP'S OWN FIGURES LAG THIS FIELD, which has three consequences. ONE: `erp_invoice_open_balance` above zero on a `paid` invoice is that lag, your ERP not having applied the receipt yet, or a push that failed - NOT a contradiction and NOT a reason to doubt the status. Report both the paid status and the ERP lag, and read `erp_push_state` to tell ordinary lag from a push that will stay unreflected until someone acts. TWO: an invoice finalised with a balance still reads `inactive` when nothing on it can be collected - every installment disputed, excluded from collections, closed in your ERP, or the invoice has no billing email or a currency no connected payment provider can charge - and THE MONEY IS NOT WRITTEN OFF: `erp_invoice_open_balance` still carries it, as does each installment's `erp_installment_outstanding_amount` on the invoice detail. THREE: DO NOT SUM `erp_invoice_open_balance` FOR RECEIVABLES - it still counts money Finero has already collected but not yet pushed back, indefinitely so when a push has failed. Sum `collection_outstanding` for what is OWED - but it is populated on invoices nothing can be collected from, so filter to `collection_status=open` for what can actually be collected, and read each installment's `collectible` to see which parts. Other `inactive` causes: your ERP shows no balance outstanding, has not finalised the invoice, no longer has the invoice at all (Finero stops collecting an invoice a complete sync no longer finds, and resumes if a later sync finds it again), or has not generated a payment schedule - every way Finero collects is attached to an installment, so with none there is nothing to collect against however much is owed.",
                      "enum": [
                        "paid",
                        "open",
                        "inactive"
                      ]
                    },
                    "customer": {
                      "type": "object",
                      "description": "Bill-to customer details embedded on the invoice. There is no customer ENDPOINT, but there is a stable customer KEY — see `id`.",
                      "properties": {
                        "id": {
                          "type": "string",
                          "description": "STABLE CUSTOMER KEY — group by this, never by name or email. Finero resolves each ERP customer into one row per (workspace, connection, ERP customer id), so this is the same value across every invoice for that customer. Name and email are NOT keys and fail in opposite directions: two different customers can share a name (\"Globex Corporation #1\" twice), and one customer can bill from several addresses. Null only on invoices created in Finero with no ERP customer behind them.",
                          "nullable": true,
                          "format": "uuid"
                        },
                        "number": {
                          "type": "string",
                          "description": "Bill-to customer number from the ERP.",
                          "nullable": true
                        },
                        "name": {
                          "type": "string",
                          "description": "Bill-to customer name — the ACCOUNT (for example a company). Distinct from contact_name, which is a person.",
                          "nullable": true
                        },
                        "contact_name": {
                          "type": "string",
                          "description": "The person named on the invoice as the bill-to contact. Display only: it is never used to identify anyone and nothing in Finero branches on it, because an ERP can carry two people with the same name on one account. Null when the ERP sends none, or when the workspace has switched the Contact name pull field off for this connector. For a sole trader it may equal name.",
                          "nullable": true
                        },
                        "email": {
                          "type": "string",
                          "description": "Billing email the payment-link email is sent to.",
                          "nullable": true
                        }
                      },
                      "required": [
                        "id",
                        "number",
                        "name",
                        "contact_name",
                        "email"
                      ],
                      "additionalProperties": false
                    },
                    "origin": {
                      "type": "string",
                      "description": "Provenance / system of record: local = the invoice exists only in Finero; erp = an ERP connection owns (or will own) the record.",
                      "enum": [
                        "local",
                        "erp"
                      ]
                    },
                    "connection_id": {
                      "type": "string",
                      "description": "The exact ERP connection associated with this invoice.",
                      "nullable": true,
                      "format": "uuid"
                    },
                    "created_at": {
                      "type": "string",
                      "description": "Creation time (ISO 8601 UTC).",
                      "format": "date-time"
                    },
                    "updated_at": {
                      "type": "string",
                      "description": "Last update time (ISO 8601 UTC).",
                      "format": "date-time"
                    },
                    "installments": {
                      "type": "array",
                      "description": "The invoice's installments, ordered by sequence.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Installment id — use as installment_id when creating a payment link.",
                            "format": "uuid"
                          },
                          "sequence": {
                            "type": "integer",
                            "description": "1-based installment sequence within the invoice."
                          },
                          "original_amount": {
                            "type": "string",
                            "description": "Original installment amount. Decimal amount as a string (exact, 4 dp) — never parse as float for arithmetic.",
                            "pattern": "^-?\\d+(\\.\\d+)?$"
                          },
                          "erp_installment_outstanding_amount": {
                            "type": "string",
                            "description": "Your ERP's outstanding figure for THIS INSTALLMENT — its own share, not the invoice total. The invoice-level equivalent is `erp_invoice_open_balance`: the same kind of number one level up, which an invoice's installments sum to. Like every `erp_` field it is your ERP's last word, so it does NOT account for money Finero has collected but not yet written back — it overstates what is owed until the push lands, and stays that way indefinitely if the push failed. `collection_outstanding` beside it is Finero's own figure and already accounts for that; prefer it for anything you are going to act on. Decimal amount as a string (exact, 4 dp) — never parse as float for arithmetic.",
                            "pattern": "^-?\\d+(\\.\\d+)?$"
                          },
                          "disputed_amount": {
                            "type": "string",
                            "description": "Amount your ERP records as under dispute. Any value above zero blocks collection entirely — no payment link is created for this installment. A dispute does not reduce what is owed, so outstanding_amount is unchanged by it. Clearing a resolved dispute is usually a manual step in the ERP; until it is cleared this stays above zero and the installment stays blocked. Decimal amount as a string (exact, 4 dp) — never parse as float for arithmetic.",
                            "pattern": "^-?\\d+(\\.\\d+)?$"
                          },
                          "due_date": {
                            "type": "string",
                            "description": "Installment due date (YYYY-MM-DD).",
                            "nullable": true,
                            "format": "date"
                          },
                          "source_status": {
                            "type": "string",
                            "description": "Raw installment status from the ERP. EMPTY when there is no provider installment behind this row: an invoice your ERP has finalised without generating a payment schedule gets one installment carrying the whole amount, and it has no status of its own to report. Never parse this to decide whether money is owed - `collection_status` answers that in Finero's own vocabulary, for every connector."
                          },
                          "collection_status": {
                            "type": "string",
                            "description": "What Finero is doing about collecting THIS installment. Same three values as the invoice's `collection_status`, and it inherits that verdict: an installment of an `inactive` invoice is `inactive`. `paid` MEANS THIS INSTALLMENT IS PAID - nothing is left on it - and it is the current answer even while your ERP still shows a balance, which lags. The converse does not hold: an installment settled directly in your ERP reads `inactive`, because Finero did not take that money, so NOT-`paid` is not the same as unpaid. `open` requires THREE things together: an outstanding balance, `collectible` being true, and the ERP's own status saying the installment is still open - each connector declares which of its status values mean that. Where there is no provider status to judge (an invoice created in Finero) the first two decide alone. IT REPORTS WHAT FINERO IS DOING, NOT WHAT IS OWED: an installment that is genuinely outstanding but blocked - disputed, excluded, no billing email, a currency no connected provider can charge - reads `inactive`, because Finero is not collecting it. The money has not been written off; `erp_installment_outstanding_amount` still carries it, and the invoice explains why in plain words.",
                            "enum": [
                              "paid",
                              "open",
                              "inactive"
                            ]
                          },
                          "collection_outstanding": {
                            "type": "string",
                            "description": "What is still outstanding on this installment, as an exact decimal string. Accounts for Finero payments the ERP has not been told about yet, which `erp_installment_outstanding_amount` above cannot - that is the ERP's own figure. It is what is OWED, not what is collectible: it stays non-zero when `collectible` is false, and it does not subtract `disputed_amount`. Check `collectible` and `block_reasons` before treating it as money you can ask for."
                          },
                          "amount_collected": {
                            "type": "string",
                            "description": "How much Finero has confirmed collected on this installment, as an exact decimal string. Money taken through a Finero payment link only; an installment settled directly in your ERP leaves this at 0.0000."
                          },
                          "collectible": {
                            "type": "boolean",
                            "description": "Whether Finero considers this installment collectible right now. False once there is nothing left to collect, and also for installments blocked by a dispute, an exclusion, a non-final invoice, a missing billing email, or an invoice that is no longer present in your ERP."
                          },
                          "block_reasons": {
                            "type": "array",
                            "description": "WHY this installment is not collectible, as stable codes — empty when it is. Invoice-level blocks propagate here, so this answers the question without a second lookup. Plain-English meanings for every code are published at https://app.getfinero.com/guide, and the workflows catalogue returns the same dictionary in full - every code with its label, what it means and what resolves it.",
                            "items": {
                              "type": "string"
                            }
                          },
                          "excluded_from_collections": {
                            "type": "boolean",
                            "description": "Manually excluded from collections."
                          },
                          "created_at": {
                            "type": "string",
                            "description": "Creation time (ISO 8601 UTC).",
                            "format": "date-time"
                          },
                          "updated_at": {
                            "type": "string",
                            "description": "Last update time (ISO 8601 UTC).",
                            "format": "date-time"
                          }
                        },
                        "required": [
                          "id",
                          "sequence",
                          "original_amount",
                          "erp_installment_outstanding_amount",
                          "disputed_amount",
                          "due_date",
                          "source_status",
                          "collection_status",
                          "collection_outstanding",
                          "amount_collected",
                          "collectible",
                          "block_reasons",
                          "excluded_from_collections",
                          "created_at",
                          "updated_at"
                        ],
                        "additionalProperties": false
                      }
                    }
                  },
                  "required": [
                    "id",
                    "invoice_number",
                    "currency",
                    "total_amount",
                    "erp_invoice_open_balance",
                    "invoice_date",
                    "due_date",
                    "collection_due_date",
                    "last_synced_at",
                    "collection_status",
                    "collection_outstanding",
                    "amount_collected",
                    "erp_push_state",
                    "customer",
                    "origin",
                    "connection_id",
                    "created_at",
                    "updated_at",
                    "installments"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                  "invoice_number": "INV-10421",
                  "currency": "USD",
                  "total_amount": "1250.0000",
                  "erp_invoice_open_balance": "1250.0000",
                  "invoice_date": "2026-07-01",
                  "due_date": "2026-08-01",
                  "last_synced_at": "2026-07-10T06:30:00+00:00",
                  "collection_status": "open",
                  "collection_outstanding": "1250.0000",
                  "amount_collected": "0.0000",
                  "erp_push_state": null,
                  "collection_due_date": "2026-08-20",
                  "customer": {
                    "id": "b7c8d9e0-1f2a-4b3c-8d4e-5f60718293a4",
                    "number": "CUST-2201",
                    "name": "Acme Industries Ltd",
                    "contact_name": "Marco Johnson",
                    "email": "ap@acme-industries.example"
                  },
                  "origin": "erp",
                  "connection_id": "1a2b3c4d-5e6f-4a80-91b2-c3d4e5f60718",
                  "created_at": "2026-07-01T09:15:00+00:00",
                  "updated_at": "2026-07-10T06:30:00+00:00",
                  "installments": [
                    {
                      "id": "a3c9d2e1-55b4-4c8e-9f01-2b3c4d5e6f70",
                      "sequence": 1,
                      "original_amount": "1250.0000",
                      "erp_installment_outstanding_amount": "1250.0000",
                      "disputed_amount": "0.0000",
                      "due_date": "2026-08-01",
                      "source_status": "OPEN",
                      "collection_status": "open",
                      "collection_outstanding": "1250.0000",
                      "amount_collected": "0.0000",
                      "collectible": true,
                      "block_reasons": [],
                      "excluded_from_collections": false,
                      "created_at": "2026-07-01T09:15:00+00:00",
                      "updated_at": "2026-07-10T06:30:00+00:00"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/invoices/summary": {
      "get": {
        "operationId": "summarizeInvoices",
        "summary": "Count invoices by collection status, and total the money",
        "description": "Answers 'how many, and how much' in ONE request. Returns counts per collection_status plus outstanding and collected money per currency, over the same filters listInvoices accepts. Use this instead of paging every invoice and adding it up: collection_status is derived from Finero's own payment record and is not something a caller can compute from the fields it receives, and money must never be summed across currencies. Exact at any workspace size: the counts come from an indexed column, so nothing is capped and nothing is sampled. Every filter listInvoices accepts works here too — pair `due_after` with `due_before` and one call answers a whole aging band.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Invoices"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "collection_status",
            "in": "query",
            "required": false,
            "description": "Count and total only invoices with this collection status. NOT derivable from the unfiltered response: `by_collection_status` already gives the counts, but `by_currency` aggregates money across every status — so 'how much is still open, per currency' needs this filter. An `inactive` invoice can carry a real balance it will never collect, and summing across statuses buries that in the same number.",
            "schema": {
              "type": "string",
              "enum": [
                "paid",
                "open",
                "inactive"
              ]
            }
          },
          {
            "name": "currency",
            "in": "query",
            "required": false,
            "description": "Filter by ISO 4217 currency code.",
            "schema": {
              "type": "string",
              "minLength": 3,
              "maxLength": 3
            }
          },
          {
            "name": "connection_id",
            "in": "query",
            "required": false,
            "description": "Filter by ERP connection.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "updated_since",
            "in": "query",
            "required": false,
            "description": "Only invoices updated at or after this ISO 8601 timestamp. The bound INCLUDES its own timestamp, so polling with the newest `updated_at` you have seen returns that row again every time - expected, and not a change.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "customer_id",
            "in": "query",
            "required": false,
            "description": "Only count invoices for this customer, by the stable `customer.id`. One call gives that customer's whole exposure, per currency — the safe alternative to paging the book and grouping by name.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "erp_push_state",
            "in": "query",
            "required": false,
            "description": "Only count invoices whose payment write-back to the ERP is in this state. `erp_push_state=failed` and `erp_push_state=reversed` each answer part of 'how much money has Finero collected that the ERP does not know about?' in one call.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "processing",
                "posted",
                "applied",
                "failed",
                "reversed"
              ]
            }
          },
          {
            "name": "due_before",
            "in": "query",
            "required": false,
            "description": "Only invoices whose collection_due_date is on or before this date (YYYY-MM-DD), INCLUSIVE. The overdue worklist is due_before=<today> with collection_status=open. FILTERS ON collection_due_date, so an invoice that has none is excluded — and one has none precisely when nothing on it is collectible, which is also when it can still owe money. Date bands therefore never sum to the whole book: check a total against a call with no date filter, never against the bands. A PAID invoice has no `collection_due_date` either, so a date-filtered call reports `amount_collected` as 0 in every currency: these filters answer what is OWED, never what was collected.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "due_after",
            "in": "query",
            "required": false,
            "description": "Only invoices whose collection_due_date is on or after this date (YYYY-MM-DD), INCLUSIVE. Combine with due_before for one aging bucket — but because BOTH ends include their own date, adjacent bands must not share a boundary: use due_before=X then due_after=<X plus one day>, or every invoice dated exactly X is counted twice and the profile sums to more than the book. Invoices with no `collection_due_date` are excluded from EVERY date-filtered call, and one has none precisely when nothing on it is collectible — which is also when it can still owe money. Date bands never sum to the whole book; check a total against a call with no date filter. A PAID invoice has no `collection_due_date` either, so a date-filtered call reports `amount_collected` as 0 in every currency: these filters answer what is OWED, never what was collected.",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "total": {
                      "type": "integer",
                      "description": "Invoices matching the filters. An exact count, never an estimate — and the number to trust for 'how many are there'."
                    },
                    "by_collection_status": {
                      "type": "object",
                      "description": "Count per collection status, over every invoice matching the filters. The three are exhaustive and sum to `total`. `paid` counts invoices with nothing left to collect that Finero collected on — including one settled by MIXED paths, part in your ERP and part through Finero. The full rule is on the invoice's own `collection_status`; it is not restated here so the two cannot disagree.",
                      "properties": {
                        "paid": {
                          "type": "integer",
                          "description": "Invoices with nothing left to collect that Finero collected on. See `collection_status` on the invoice for the full rule."
                        },
                        "open": {
                          "type": "integer",
                          "description": "Invoices with at least one installment Finero can collect. See `collection_status` on the invoice for the full rule."
                        },
                        "inactive": {
                          "type": "integer",
                          "description": "Invoices Finero is not collecting and did not collect — settled entirely outside Finero, cancelled, or with nothing collectible left. NOT the same as paid, and NOT the same as unpaid."
                        }
                      },
                      "required": [
                        "paid",
                        "open",
                        "inactive"
                      ],
                      "additionalProperties": false
                    },
                    "inactive_nothing_to_collect": {
                      "type": "integer",
                      "description": "The subset of `inactive` with nothing left to collect — settled outside Finero, cancelled, or empty. `inactive` minus this is the count with money STUCK on it: owed, and not collectible right now. This is Finero's own answer (its `collection_outstanding` at zero), not the ERP's balance, so it stays correct while the ERP lags."
                    },
                    "by_currency": {
                      "type": "array",
                      "description": "Money per ISO 4217 currency, over every invoice matching the filters. NEVER summed across currencies — a single total over a mixed-currency workspace is a wrong number, not a shorter one.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "currency": {
                            "type": "string",
                            "description": "ISO 4217 code."
                          },
                          "collection_outstanding": {
                            "type": "string",
                            "description": "What is still outstanding after Finero's own collections, in this currency. Decimal string, 4 dp. Use this rather than summing open_balance, which is the ERP's last word and goes stale the moment Finero collects. CAUTION: this is what is OWED, not what is collectible — it includes invoices Finero is blocked from collecting (see an installment's block_reasons) and does not subtract a disputed portion. Filtering to collection_status=open is the right total of what Finero can act on. It can still include an OPEN invoice's blocked installment — when one specific invoice matters, its detail says per installment what is collectible; that is a per-invoice check, never a workspace walk."
                          },
                          "amount_collected": {
                            "type": "string",
                            "description": "What Finero has collected, in this currency. Decimal string, 4 dp."
                          }
                        },
                        "required": [
                          "currency",
                          "collection_outstanding",
                          "amount_collected"
                        ],
                        "additionalProperties": false
                      }
                    }
                  },
                  "required": [
                    "total",
                    "by_collection_status",
                    "inactive_nothing_to_collect",
                    "by_currency"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "total": 386,
                  "by_collection_status": {
                    "paid": 41,
                    "open": 302,
                    "inactive": 43
                  },
                  "inactive_nothing_to_collect": 39,
                  "by_currency": [
                    {
                      "currency": "USD",
                      "collection_outstanding": "184320.5000",
                      "amount_collected": "20450.0000"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/me": {
      "get": {
        "operationId": "getApiContext",
        "summary": "Identify the calling API key",
        "description": "Returns the tenant and key metadata for the presented credential. Useful as a connectivity/permission check before other calls. No side effects.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "API context"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tenant_id": {
                      "type": "string",
                      "description": "The tenant this API key belongs to. All data is scoped to it.",
                      "format": "uuid"
                    },
                    "tenant_name": {
                      "type": "string",
                      "description": "Tenant display name."
                    },
                    "api_key": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "string",
                          "description": "API key id.",
                          "format": "uuid"
                        },
                        "name": {
                          "type": "string",
                          "description": "Key name given at creation."
                        },
                        "permission": {
                          "type": "string",
                          "description": "admin can call every operation; readonly only side-effect-free ones.",
                          "enum": [
                            "admin",
                            "readonly"
                          ]
                        },
                        "created_at": {
                          "type": "string",
                          "description": "Key creation time (ISO 8601 UTC).",
                          "format": "date-time"
                        },
                        "last_used_at": {
                          "type": "string",
                          "description": "Previous use of this key (ISO 8601 UTC).",
                          "nullable": true,
                          "format": "date-time"
                        }
                      },
                      "required": [
                        "id",
                        "name",
                        "permission",
                        "created_at",
                        "last_used_at"
                      ],
                      "additionalProperties": false
                    }
                  },
                  "required": [
                    "tenant_id",
                    "tenant_name",
                    "api_key"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "tenant_id": "5b0f6a7e-2f5d-4d24-9e6b-3c1a2b4d5e6f",
                  "tenant_name": "Acme Industries",
                  "api_key": {
                    "id": "4d3c2b1a-0f9e-48d7-b6c5-a4b3c2d1e0f9",
                    "name": "Zapier integration",
                    "permission": "readonly",
                    "created_at": "2026-07-01T08:00:00+00:00",
                    "last_used_at": "2026-07-14T06:59:31+00:00"
                  }
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payment-integrations": {
      "get": {
        "operationId": "listPaymentIntegrations",
        "summary": "List the payment processors that collect for this workspace",
        "description": "Which payment service provider Finero charges through, and how it is configured — environment, account and checkout methods. CONFIGURATION ONLY: credentials, credential masks, provider metadata, publishable keys and provider account ids are deliberately never returned. Read `environment` before reporting any collected figure: a test integration takes no real money.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Integrations"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1–100. Default 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque pagination cursor from a previous response's pagination.next_cursor.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 400
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "Sort direction. Ordering is ALWAYS by when the record was CREATED in Finero, never by when it changed — `updated_since` narrows the set but does not reorder it, so the most recently updated row is not necessarily first. Creation time is immutable, which is what lets the cursor stay stable: ordering by a value that changes would move a row mid-walk and make a page skip or repeat it. Default desc (newest first by creation time).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "include_total",
            "in": "query",
            "required": false,
            "description": "Return pagination.total_count — the number of rows matching the filters, ignoring paging. It is the SAME number on every page, including alongside a cursor. Off by default: an exact count scans the whole filtered set, so asking on each page pays repeatedly for an answer that does not change — ask on the first request and keep it.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "description": "Payment processors, newest first by creation time.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Integration id."
                          },
                          "provider": {
                            "type": "string",
                            "description": "Which processor collects the money — the payment service provider Finero charges through."
                          },
                          "status": {
                            "type": "string",
                            "description": "Whether Finero can currently charge through it.",
                            "enum": [
                              "connected",
                              "error",
                              "disconnected"
                            ]
                          },
                          "environment": {
                            "type": "string",
                            "description": "TEST or LIVE. Read this before reporting any collected figure: a test integration takes no real money, and a number sourced from one is not revenue.",
                            "nullable": true,
                            "enum": [
                              "test",
                              "live"
                            ]
                          },
                          "account_name": {
                            "type": "string",
                            "description": "The processor account's own name, as the provider reports it.",
                            "nullable": true
                          },
                          "checkout_payment_methods": {
                            "type": "string",
                            "description": "Which methods the hosted checkout offers: card_only, or whatever the provider dashboard is configured to allow.",
                            "nullable": true
                          },
                          "last_verified_at": {
                            "type": "string",
                            "description": "When Finero last confirmed the credentials work.",
                            "nullable": true
                          },
                          "connected_at": {
                            "type": "string",
                            "description": "When the integration was created."
                          }
                        },
                        "required": [
                          "id",
                          "provider",
                          "status",
                          "environment",
                          "account_name",
                          "checkout_payment_methods",
                          "last_verified_at",
                          "connected_at"
                        ],
                        "additionalProperties": false
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "next_cursor": {
                          "type": "string",
                          "description": "Opaque cursor for the next page — pass as ?cursor=. Null when there are no further results.",
                          "nullable": true
                        },
                        "has_more": {
                          "type": "boolean",
                          "description": "Whether another page exists."
                        },
                        "limit": {
                          "type": "integer",
                          "description": "The page size that was applied."
                        },
                        "total_count": {
                          "type": "integer",
                          "description": "Total rows matching the filters, ignoring paging. Present ONLY when the request passed ?include_total=true."
                        }
                      },
                      "required": [
                        "next_cursor",
                        "has_more",
                        "limit"
                      ],
                      "additionalProperties": false
                    }
                  },
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "data": [
                    {
                      "id": "aa11bb22-cc33-4d44-8e55-ff6677889900",
                      "provider": "stripe",
                      "status": "connected",
                      "environment": "test",
                      "account_name": "Acme Industries",
                      "checkout_payment_methods": "stripe_dashboard",
                      "last_verified_at": "2026-07-14T06:59:31+00:00",
                      "connected_at": "2026-06-02T10:00:00+00:00"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payment-links": {
      "get": {
        "operationId": "listPaymentLinks",
        "summary": "List payment links",
        "description": "Lists the tenant's payment links, newest first by creation time. Cursor-paginated: pass ?limit= (1–100) and follow pagination.next_cursor until has_more is false. To learn HOW MANY rows match without walking every page, pass ?include_total=true once and read pagination.total_count. The url field is the live hosted checkout page.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Payment links"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1–100. Default 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque pagination cursor from a previous response's pagination.next_cursor.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 400
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "Sort direction. Ordering is ALWAYS by when the record was CREATED in Finero, never by when it changed — `updated_since` narrows the set but does not reorder it, so the most recently updated row is not necessarily first. Creation time is immutable, which is what lets the cursor stay stable: ordering by a value that changes would move a row mid-walk and make a page skip or repeat it. Default desc (newest first by creation time).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "include_total",
            "in": "query",
            "required": false,
            "description": "Return pagination.total_count — the number of rows matching the filters, ignoring paging. It is the SAME number on every page, including alongside a cursor. Off by default: an exact count scans the whole filtered set, so asking on each page pays repeatedly for an answer that does not change — ask on the first request and keep it.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filter by lifecycle status.",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "processing",
                "paid",
                "inactive",
                "needs_replacement",
                "action_required"
              ]
            }
          },
          {
            "name": "invoice_id",
            "in": "query",
            "required": false,
            "description": "Filter by invoice.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "installment_id",
            "in": "query",
            "required": false,
            "description": "Filter by installment.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "description": "Payment links, newest first by creation time.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Payment link id.",
                            "format": "uuid"
                          },
                          "invoice_id": {
                            "type": "string",
                            "description": "Invoice the link collects.",
                            "format": "uuid"
                          },
                          "installment_id": {
                            "type": "string",
                            "description": "Installment the link collects. An installment may carry SEVERAL live links (0280) — their amounts plus what is already collected may not exceed what is owed, which the database enforces under a row lock.",
                            "format": "uuid"
                          },
                          "status": {
                            "type": "string",
                            "description": "Lifecycle status. active = payable now; processing = a payment is being confirmed; paid = settled; inactive = deactivated or superseded; needs_replacement = the provider-side link stopped being usable (expired or removed at the provider), so a new link must be created; action_required = frozen pending a human decision — Finero will not act on it automatically. A payment whose provider report does not match the request is frozen in processing, not here.",
                            "enum": [
                              "active",
                              "processing",
                              "paid",
                              "inactive",
                              "needs_replacement",
                              "action_required"
                            ]
                          },
                          "provider": {
                            "type": "string",
                            "description": "Payment provider hosting the checkout.",
                            "enum": [
                              "stripe",
                              "wix"
                            ]
                          },
                          "mode": {
                            "type": "string",
                            "description": "Provider environment. `test` means a TEST processor handled this money: it is not revenue and must never be reported as cash collected, however real the amount looks. `live` is real money.",
                            "enum": [
                              "test",
                              "live"
                            ]
                          },
                          "amount_minor": {
                            "type": "integer",
                            "description": "Amount the link collects. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display."
                          },
                          "currency": {
                            "type": "string",
                            "description": "Link currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                            "minLength": 3,
                            "maxLength": 3
                          },
                          "url": {
                            "type": "string",
                            "description": "Hosted customer-facing pay-page URL. Treat as a capability: anyone with the URL can open the checkout.",
                            "nullable": true
                          },
                          "paid_at": {
                            "type": "string",
                            "description": "When the link was paid (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "paid_amount_minor": {
                            "type": "integer",
                            "description": "Settled amount. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display.",
                            "nullable": true
                          },
                          "paid_currency": {
                            "type": "string",
                            "description": "Settled currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                            "nullable": true,
                            "minLength": 3,
                            "maxLength": 3
                          },
                          "deactivated_at": {
                            "type": "string",
                            "description": "When the link was deactivated (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "created_at": {
                            "type": "string",
                            "description": "Creation time (ISO 8601 UTC).",
                            "format": "date-time"
                          },
                          "updated_at": {
                            "type": "string",
                            "description": "Last update time (ISO 8601 UTC).",
                            "format": "date-time"
                          }
                        },
                        "required": [
                          "id",
                          "invoice_id",
                          "installment_id",
                          "status",
                          "provider",
                          "mode",
                          "amount_minor",
                          "currency",
                          "url",
                          "paid_at",
                          "paid_amount_minor",
                          "paid_currency",
                          "deactivated_at",
                          "created_at",
                          "updated_at"
                        ],
                        "additionalProperties": false
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "next_cursor": {
                          "type": "string",
                          "description": "Opaque cursor for the next page — pass as ?cursor=. Null when there are no further results.",
                          "nullable": true
                        },
                        "has_more": {
                          "type": "boolean",
                          "description": "Whether another page exists."
                        },
                        "limit": {
                          "type": "integer",
                          "description": "The page size that was applied."
                        },
                        "total_count": {
                          "type": "integer",
                          "description": "Total rows matching the filters, ignoring paging. Present ONLY when the request passed ?include_total=true."
                        }
                      },
                      "required": [
                        "next_cursor",
                        "has_more",
                        "limit"
                      ],
                      "additionalProperties": false
                    }
                  },
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "data": [
                    {
                      "id": "0d9c8b7a-6e5f-4d3c-b2a1-908f7e6d5c4b",
                      "invoice_id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                      "installment_id": "a3c9d2e1-55b4-4c8e-9f01-2b3c4d5e6f70",
                      "status": "active",
                      "provider": "stripe",
                      "mode": "live",
                      "amount_minor": 125000,
                      "currency": "USD",
                      "url": "https://app.getfinero.com/pay/EXAMPLEtokenEXAMPLEtokenEXAMPLEtokenEXAMPL",
                      "paid_at": null,
                      "paid_amount_minor": null,
                      "paid_currency": null,
                      "deactivated_at": null,
                      "created_at": "2026-07-10T06:31:00+00:00",
                      "updated_at": "2026-07-10T06:31:00+00:00"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createPaymentLink",
        "summary": "Create a payment link for an installment",
        "description": "Creates a hosted payment link for one collectible installment through the tenant's connected payment provider — the same validation and creation path the Finero app uses. An installment may carry SEVERAL links at once, each collecting part of it: an installment of 100.00 can be offered as four links of 25.00. Their amounts may not exceed what is still outstanding. When live links already cover the installment the request is refused as a conflict, exactly as a duplicate link always was; when there is room but less than was asked for, it is refused as an invalid amount and names what was available. Requires the Idempotency-Key header; retrying with the same key and body returns the original result. If the tenant's email-notification workflow is enabled, the customer payment-link email is sent as a consequence.\n\nRequires an **admin** API key (has side effects).",
        "tags": [
          "Payment links"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "admin",
        "x-side-effects": true,
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "description": "Unique key (1–255 printable ASCII chars) scoping safe retries. Same key + same body replays the original result; same key + different body returns 409 idempotency_conflict.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "Payment link id.",
                      "format": "uuid"
                    },
                    "invoice_id": {
                      "type": "string",
                      "description": "Invoice the link collects.",
                      "format": "uuid"
                    },
                    "installment_id": {
                      "type": "string",
                      "description": "Installment the link collects. An installment may carry SEVERAL live links (0280) — their amounts plus what is already collected may not exceed what is owed, which the database enforces under a row lock.",
                      "format": "uuid"
                    },
                    "status": {
                      "type": "string",
                      "description": "Lifecycle status. active = payable now; processing = a payment is being confirmed; paid = settled; inactive = deactivated or superseded; needs_replacement = the provider-side link stopped being usable (expired or removed at the provider), so a new link must be created; action_required = frozen pending a human decision — Finero will not act on it automatically. A payment whose provider report does not match the request is frozen in processing, not here.",
                      "enum": [
                        "active",
                        "processing",
                        "paid",
                        "inactive",
                        "needs_replacement",
                        "action_required"
                      ]
                    },
                    "provider": {
                      "type": "string",
                      "description": "Payment provider hosting the checkout.",
                      "enum": [
                        "stripe",
                        "wix"
                      ]
                    },
                    "mode": {
                      "type": "string",
                      "description": "Provider environment. `test` means a TEST processor handled this money: it is not revenue and must never be reported as cash collected, however real the amount looks. `live` is real money.",
                      "enum": [
                        "test",
                        "live"
                      ]
                    },
                    "amount_minor": {
                      "type": "integer",
                      "description": "Amount the link collects. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display."
                    },
                    "currency": {
                      "type": "string",
                      "description": "Link currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                      "minLength": 3,
                      "maxLength": 3
                    },
                    "url": {
                      "type": "string",
                      "description": "Hosted customer-facing pay-page URL. Treat as a capability: anyone with the URL can open the checkout.",
                      "nullable": true
                    },
                    "paid_at": {
                      "type": "string",
                      "description": "When the link was paid (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "paid_amount_minor": {
                      "type": "integer",
                      "description": "Settled amount. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display.",
                      "nullable": true
                    },
                    "paid_currency": {
                      "type": "string",
                      "description": "Settled currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                      "nullable": true,
                      "minLength": 3,
                      "maxLength": 3
                    },
                    "deactivated_at": {
                      "type": "string",
                      "description": "When the link was deactivated (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "created_at": {
                      "type": "string",
                      "description": "Creation time (ISO 8601 UTC).",
                      "format": "date-time"
                    },
                    "updated_at": {
                      "type": "string",
                      "description": "Last update time (ISO 8601 UTC).",
                      "format": "date-time"
                    }
                  },
                  "required": [
                    "id",
                    "invoice_id",
                    "installment_id",
                    "status",
                    "provider",
                    "mode",
                    "amount_minor",
                    "currency",
                    "url",
                    "paid_at",
                    "paid_amount_minor",
                    "paid_currency",
                    "deactivated_at",
                    "created_at",
                    "updated_at"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "id": "0d9c8b7a-6e5f-4d3c-b2a1-908f7e6d5c4b",
                  "invoice_id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                  "installment_id": "a3c9d2e1-55b4-4c8e-9f01-2b3c4d5e6f70",
                  "status": "active",
                  "provider": "stripe",
                  "mode": "live",
                  "amount_minor": 125000,
                  "currency": "USD",
                  "url": "https://app.getfinero.com/pay/EXAMPLEtokenEXAMPLEtokenEXAMPLEtokenEXAMPL",
                  "paid_at": null,
                  "paid_amount_minor": null,
                  "paid_currency": null,
                  "deactivated_at": null,
                  "created_at": "2026-07-10T06:31:00+00:00",
                  "updated_at": "2026-07-10T06:31:00+00:00"
                }
              }
            }
          },
          "400": {
            "description": "`idempotency_key_required` — This operation requires the Idempotency-Key header (1–255 characters). `validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`conflict` — State conflict — e.g. an active payment link already exists for the installment, or a concurrent identical request is in flight. `idempotency_conflict` — The Idempotency-Key was already used with a DIFFERENT request body. Use a fresh key for a new request.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "`payload_too_large` — The request body exceeds the 64 KiB limit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`currency_not_supported` — The selected payment provider cannot collect the invoice's currency (single-currency providers can only charge in their store currency). Use a provider that supports it, or collect the invoice outside Finero. `installment_not_collectible` — The installment cannot be collected right now — it is already paid, disputed, excluded from collections, has no outstanding balance, its invoice is not collection-ready, or its invoice is no longer present in your ERP. GET /v1/invoices/{invoiceId} reports the invoice's collection_status and each installment's collectible flag. `integration_unavailable` — The tenant has no connected payments integration able to mint this link, or the one selected is not ready. Connect or repair a payment provider in the Finero app, then retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "invoice_id": {
                    "type": "string",
                    "description": "Invoice to collect.",
                    "format": "uuid"
                  },
                  "installment_id": {
                    "type": "string",
                    "description": "Installment to collect (from GET /v1/invoices/{invoiceId}).",
                    "format": "uuid"
                  },
                  "integration_id": {
                    "type": "string",
                    "description": "Optional: a specific connected payments integration. Defaults to the tenant's connected provider.",
                    "format": "uuid"
                  },
                  "amount_minor": {
                    "type": "integer",
                    "description": "Optional: how much this link collects, in minor units (cents). Omit to collect everything still outstanding on the installment, which is the usual case. Supply it to split an installment across several links — the amounts of all live links plus anything already collected may not exceed what is owed.",
                    "minimum": 1
                  }
                },
                "required": [
                  "invoice_id",
                  "installment_id"
                ],
                "additionalProperties": false
              },
              "example": {
                "invoice_id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                "installment_id": "a3c9d2e1-55b4-4c8e-9f01-2b3c4d5e6f70"
              }
            }
          }
        }
      }
    },
    "/v1/payment-links/{paymentLinkId}": {
      "get": {
        "operationId": "getPaymentLink",
        "summary": "Get a payment link",
        "description": "Returns one payment link, including its hosted checkout URL and settlement state.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Payment links"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "paymentLinkId",
            "in": "path",
            "required": true,
            "description": "Payment link id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "Payment link id.",
                      "format": "uuid"
                    },
                    "invoice_id": {
                      "type": "string",
                      "description": "Invoice the link collects.",
                      "format": "uuid"
                    },
                    "installment_id": {
                      "type": "string",
                      "description": "Installment the link collects. An installment may carry SEVERAL live links (0280) — their amounts plus what is already collected may not exceed what is owed, which the database enforces under a row lock.",
                      "format": "uuid"
                    },
                    "status": {
                      "type": "string",
                      "description": "Lifecycle status. active = payable now; processing = a payment is being confirmed; paid = settled; inactive = deactivated or superseded; needs_replacement = the provider-side link stopped being usable (expired or removed at the provider), so a new link must be created; action_required = frozen pending a human decision — Finero will not act on it automatically. A payment whose provider report does not match the request is frozen in processing, not here.",
                      "enum": [
                        "active",
                        "processing",
                        "paid",
                        "inactive",
                        "needs_replacement",
                        "action_required"
                      ]
                    },
                    "provider": {
                      "type": "string",
                      "description": "Payment provider hosting the checkout.",
                      "enum": [
                        "stripe",
                        "wix"
                      ]
                    },
                    "mode": {
                      "type": "string",
                      "description": "Provider environment. `test` means a TEST processor handled this money: it is not revenue and must never be reported as cash collected, however real the amount looks. `live` is real money.",
                      "enum": [
                        "test",
                        "live"
                      ]
                    },
                    "amount_minor": {
                      "type": "integer",
                      "description": "Amount the link collects. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display."
                    },
                    "currency": {
                      "type": "string",
                      "description": "Link currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                      "minLength": 3,
                      "maxLength": 3
                    },
                    "url": {
                      "type": "string",
                      "description": "Hosted customer-facing pay-page URL. Treat as a capability: anyone with the URL can open the checkout.",
                      "nullable": true
                    },
                    "paid_at": {
                      "type": "string",
                      "description": "When the link was paid (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "paid_amount_minor": {
                      "type": "integer",
                      "description": "Settled amount. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display.",
                      "nullable": true
                    },
                    "paid_currency": {
                      "type": "string",
                      "description": "Settled currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                      "nullable": true,
                      "minLength": 3,
                      "maxLength": 3
                    },
                    "deactivated_at": {
                      "type": "string",
                      "description": "When the link was deactivated (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "created_at": {
                      "type": "string",
                      "description": "Creation time (ISO 8601 UTC).",
                      "format": "date-time"
                    },
                    "updated_at": {
                      "type": "string",
                      "description": "Last update time (ISO 8601 UTC).",
                      "format": "date-time"
                    }
                  },
                  "required": [
                    "id",
                    "invoice_id",
                    "installment_id",
                    "status",
                    "provider",
                    "mode",
                    "amount_minor",
                    "currency",
                    "url",
                    "paid_at",
                    "paid_amount_minor",
                    "paid_currency",
                    "deactivated_at",
                    "created_at",
                    "updated_at"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "id": "0d9c8b7a-6e5f-4d3c-b2a1-908f7e6d5c4b",
                  "invoice_id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                  "installment_id": "a3c9d2e1-55b4-4c8e-9f01-2b3c4d5e6f70",
                  "status": "active",
                  "provider": "stripe",
                  "mode": "live",
                  "amount_minor": 125000,
                  "currency": "USD",
                  "url": "https://app.getfinero.com/pay/EXAMPLEtokenEXAMPLEtokenEXAMPLEtokenEXAMPL",
                  "paid_at": null,
                  "paid_amount_minor": null,
                  "paid_currency": null,
                  "deactivated_at": null,
                  "created_at": "2026-07-10T06:31:00+00:00",
                  "updated_at": "2026-07-10T06:31:00+00:00"
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payment-links/{paymentLinkId}/deactivate": {
      "post": {
        "operationId": "deactivatePaymentLink",
        "summary": "Deactivate a payment link",
        "description": "Immediately deactivates an active payment link so it can no longer be paid. Naturally idempotent: deactivating an already-inactive link succeeds without change. A paid link cannot be deactivated, and is refused as a conflict.\n\nRequires an **admin** API key (has side effects).",
        "tags": [
          "Payment links"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "admin",
        "x-side-effects": true,
        "parameters": [
          {
            "name": "paymentLinkId",
            "in": "path",
            "required": true,
            "description": "Payment link id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "Payment link id.",
                      "format": "uuid"
                    },
                    "invoice_id": {
                      "type": "string",
                      "description": "Invoice the link collects.",
                      "format": "uuid"
                    },
                    "installment_id": {
                      "type": "string",
                      "description": "Installment the link collects. An installment may carry SEVERAL live links (0280) — their amounts plus what is already collected may not exceed what is owed, which the database enforces under a row lock.",
                      "format": "uuid"
                    },
                    "status": {
                      "type": "string",
                      "description": "Lifecycle status. active = payable now; processing = a payment is being confirmed; paid = settled; inactive = deactivated or superseded; needs_replacement = the provider-side link stopped being usable (expired or removed at the provider), so a new link must be created; action_required = frozen pending a human decision — Finero will not act on it automatically. A payment whose provider report does not match the request is frozen in processing, not here.",
                      "enum": [
                        "active",
                        "processing",
                        "paid",
                        "inactive",
                        "needs_replacement",
                        "action_required"
                      ]
                    },
                    "provider": {
                      "type": "string",
                      "description": "Payment provider hosting the checkout.",
                      "enum": [
                        "stripe",
                        "wix"
                      ]
                    },
                    "mode": {
                      "type": "string",
                      "description": "Provider environment. `test` means a TEST processor handled this money: it is not revenue and must never be reported as cash collected, however real the amount looks. `live` is real money.",
                      "enum": [
                        "test",
                        "live"
                      ]
                    },
                    "amount_minor": {
                      "type": "integer",
                      "description": "Amount the link collects. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display."
                    },
                    "currency": {
                      "type": "string",
                      "description": "Link currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                      "minLength": 3,
                      "maxLength": 3
                    },
                    "url": {
                      "type": "string",
                      "description": "Hosted customer-facing pay-page URL. Treat as a capability: anyone with the URL can open the checkout.",
                      "nullable": true
                    },
                    "paid_at": {
                      "type": "string",
                      "description": "When the link was paid (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "paid_amount_minor": {
                      "type": "integer",
                      "description": "Settled amount. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display.",
                      "nullable": true
                    },
                    "paid_currency": {
                      "type": "string",
                      "description": "Settled currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                      "nullable": true,
                      "minLength": 3,
                      "maxLength": 3
                    },
                    "deactivated_at": {
                      "type": "string",
                      "description": "When the link was deactivated (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "created_at": {
                      "type": "string",
                      "description": "Creation time (ISO 8601 UTC).",
                      "format": "date-time"
                    },
                    "updated_at": {
                      "type": "string",
                      "description": "Last update time (ISO 8601 UTC).",
                      "format": "date-time"
                    }
                  },
                  "required": [
                    "id",
                    "invoice_id",
                    "installment_id",
                    "status",
                    "provider",
                    "mode",
                    "amount_minor",
                    "currency",
                    "url",
                    "paid_at",
                    "paid_amount_minor",
                    "paid_currency",
                    "deactivated_at",
                    "created_at",
                    "updated_at"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "id": "0d9c8b7a-6e5f-4d3c-b2a1-908f7e6d5c4b",
                  "invoice_id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                  "installment_id": "a3c9d2e1-55b4-4c8e-9f01-2b3c4d5e6f70",
                  "status": "inactive",
                  "provider": "stripe",
                  "mode": "live",
                  "amount_minor": 125000,
                  "currency": "USD",
                  "url": null,
                  "paid_at": null,
                  "paid_amount_minor": null,
                  "paid_currency": null,
                  "deactivated_at": "2026-07-14T07:10:00+00:00",
                  "created_at": "2026-07-10T06:31:00+00:00",
                  "updated_at": "2026-07-10T06:31:00+00:00"
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`conflict` — State conflict — e.g. an active payment link already exists for the installment, or a concurrent identical request is in flight.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "`payload_too_large` — The request body exceeds the 64 KiB limit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payments": {
      "get": {
        "operationId": "listPayments",
        "summary": "List confirmed payments",
        "description": "Lists confirmed payments, newest first by creation time. A payment appears here only after the payment provider verifies settlement; payments cannot be created through the API. Each payment also reports whether it has reached the ERP as a receipt (`erp_push_status` and the other `erp_*` fields), so money Finero holds but the ERP has not recorded is visible without a second call. There is no `erp_push_status` query filter — read the field on each returned payment. Cursor-paginated: pass ?limit= (1–100) and follow pagination.next_cursor until has_more is false. To learn HOW MANY rows match without walking every page, pass ?include_total=true once and read pagination.total_count.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Payments"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1–100. Default 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque pagination cursor from a previous response's pagination.next_cursor.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 400
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "Sort direction. Ordering is ALWAYS by when the record was CREATED in Finero, never by when it changed — `updated_since` narrows the set but does not reorder it, so the most recently updated row is not necessarily first. Creation time is immutable, which is what lets the cursor stay stable: ordering by a value that changes would move a row mid-walk and make a page skip or repeat it. Default desc (newest first by creation time).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "include_total",
            "in": "query",
            "required": false,
            "description": "Return pagination.total_count — the number of rows matching the filters, ignoring paging. It is the SAME number on every page, including alongside a cursor. Off by default: an exact count scans the whole filtered set, so asking on each page pays repeatedly for an answer that does not change — ask on the first request and keep it.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "invoice_id",
            "in": "query",
            "required": false,
            "description": "Filter by invoice.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "payment_link_id",
            "in": "query",
            "required": false,
            "description": "Filter by payment link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "provider",
            "in": "query",
            "required": false,
            "description": "Filter by provider.",
            "schema": {
              "type": "string",
              "enum": [
                "stripe",
                "wix"
              ]
            }
          },
          {
            "name": "confirmed_since",
            "in": "query",
            "required": false,
            "description": "Only payments confirmed at or after this ISO 8601 timestamp.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "description": "Confirmed payments, newest first by creation time.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Payment id.",
                            "format": "uuid"
                          },
                          "invoice_id": {
                            "type": "string",
                            "description": "Invoice the payment settles.",
                            "format": "uuid"
                          },
                          "installment_id": {
                            "type": "string",
                            "description": "Installment the payment settles.",
                            "format": "uuid"
                          },
                          "payment_link_id": {
                            "type": "string",
                            "description": "The payment link the payer used.",
                            "format": "uuid"
                          },
                          "provider": {
                            "type": "string",
                            "description": "Payment provider.",
                            "enum": [
                              "stripe",
                              "wix"
                            ]
                          },
                          "provider_payment_id": {
                            "type": "string",
                            "description": "The provider's payment/transaction identifier."
                          },
                          "amount_minor": {
                            "type": "integer",
                            "description": "Settled amount. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display."
                          },
                          "currency": {
                            "type": "string",
                            "description": "Settled currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                            "minLength": 3,
                            "maxLength": 3
                          },
                          "mode": {
                            "type": "string",
                            "description": "Provider environment. `test` means a TEST processor handled this money: it is not revenue and must never be reported as cash collected, however real the amount looks. `live` is real money.",
                            "enum": [
                              "test",
                              "live"
                            ]
                          },
                          "status": {
                            "type": "string",
                            "description": "Payments appear here only once verified/confirmed by the provider.",
                            "enum": [
                              "confirmed"
                            ]
                          },
                          "provider_paid_at": {
                            "type": "string",
                            "description": "Provider-reported payment time (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "confirmed_at": {
                            "type": "string",
                            "description": "When Finero confirmed the settlement (ISO 8601 UTC).",
                            "format": "date-time"
                          },
                          "erp_push_status": {
                            "type": "string",
                            "description": "Whether this payment has reached the ERP as a receipt. `posted` = the ERP accepted it; `applied` = the ERP also allocated it against the receivable — the ERP owns allocation, so `posted` is a normal steady state and not a fault. `reversed` means it DID reach the ERP and was undone there - reversed, stopped, or returned unpaid - which no retry can repair. `failed` means it did not reach the ERP — retried automatically only while the cause is the ERP being unreachable. `null` when there is no push at all: a payment on a Finero-created invoice (there is no ERP to report to), a tenant not syncing payments to an ERP, or a payment that settled before ERP payment sync was switched on — enabling it never posts history backwards. NOT TO BE CONFUSED WITH an invoice's `erp_push_state`: that one rolls up every push on the invoice and reports the worst, while this is THIS payment's own.",
                            "nullable": true,
                            "enum": [
                              "pending",
                              "processing",
                              "posted",
                              "applied",
                              "failed",
                              "reversed"
                            ]
                          },
                          "erp_receipt_number": {
                            "type": "string",
                            "description": "The receipt identifier Finero sends to the ERP (`FIN-…`), derived deterministically from the payment id. This is the value that identifies the receipt inside the ERP.",
                            "nullable": true
                          },
                          "erp_external_receipt_id": {
                            "type": "string",
                            "description": "The ERP's own identifier for the receipt, once it has created one.",
                            "nullable": true
                          },
                          "erp_pushed_at": {
                            "type": "string",
                            "description": "When the ERP first accepted the receipt (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "erp_push_error_category": {
                            "type": "string",
                            "description": "Coarse reason the push has not gone through, when it has not. Cleared by the database the moment a push succeeds, so it is never a stale error beside a `posted`/`applied` status. Provider error text is deliberately not exposed.",
                            "nullable": true
                          }
                        },
                        "required": [
                          "id",
                          "invoice_id",
                          "installment_id",
                          "payment_link_id",
                          "provider",
                          "provider_payment_id",
                          "amount_minor",
                          "currency",
                          "mode",
                          "status",
                          "provider_paid_at",
                          "confirmed_at",
                          "erp_push_status",
                          "erp_receipt_number",
                          "erp_external_receipt_id",
                          "erp_pushed_at",
                          "erp_push_error_category"
                        ],
                        "additionalProperties": false
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "next_cursor": {
                          "type": "string",
                          "description": "Opaque cursor for the next page — pass as ?cursor=. Null when there are no further results.",
                          "nullable": true
                        },
                        "has_more": {
                          "type": "boolean",
                          "description": "Whether another page exists."
                        },
                        "limit": {
                          "type": "integer",
                          "description": "The page size that was applied."
                        },
                        "total_count": {
                          "type": "integer",
                          "description": "Total rows matching the filters, ignoring paging. Present ONLY when the request passed ?include_total=true."
                        }
                      },
                      "required": [
                        "next_cursor",
                        "has_more",
                        "limit"
                      ],
                      "additionalProperties": false
                    }
                  },
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "data": [
                    {
                      "id": "7e6d5c4b-3a29-4180-9f8e-7d6c5b4a3928",
                      "invoice_id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                      "installment_id": "a3c9d2e1-55b4-4c8e-9f01-2b3c4d5e6f70",
                      "payment_link_id": "0d9c8b7a-6e5f-4d3c-b2a1-908f7e6d5c4b",
                      "provider": "stripe",
                      "provider_payment_id": "pi_ExampleOnly000000000000",
                      "amount_minor": 125000,
                      "currency": "USD",
                      "mode": "live",
                      "status": "confirmed",
                      "provider_paid_at": "2026-07-11T14:02:11+00:00",
                      "confirmed_at": "2026-07-11T14:02:14+00:00",
                      "erp_push_status": "posted",
                      "erp_receipt_number": "FIN-8ZQ4N2VP7KMX",
                      "erp_external_receipt_id": "300100123456789",
                      "erp_pushed_at": "2026-07-11T14:03:02+00:00",
                      "erp_push_error_category": null
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/payments/{paymentId}": {
      "get": {
        "operationId": "getPayment",
        "summary": "Get a payment",
        "description": "Returns one confirmed payment by id, including the provider reference, the settlement timestamps, and whether the payment has reached the ERP as a receipt.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Payments"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "paymentId",
            "in": "path",
            "required": true,
            "description": "Payment id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "Payment id.",
                      "format": "uuid"
                    },
                    "invoice_id": {
                      "type": "string",
                      "description": "Invoice the payment settles.",
                      "format": "uuid"
                    },
                    "installment_id": {
                      "type": "string",
                      "description": "Installment the payment settles.",
                      "format": "uuid"
                    },
                    "payment_link_id": {
                      "type": "string",
                      "description": "The payment link the payer used.",
                      "format": "uuid"
                    },
                    "provider": {
                      "type": "string",
                      "description": "Payment provider.",
                      "enum": [
                        "stripe",
                        "wix"
                      ]
                    },
                    "provider_payment_id": {
                      "type": "string",
                      "description": "The provider's payment/transaction identifier."
                    },
                    "amount_minor": {
                      "type": "integer",
                      "description": "Settled amount. Integer amount in the currency's MINOR unit (e.g. cents), as defined by the PAYMENT PROVIDER's own table, which is not always ISO 4217 — the two disagree on ISK and MGA among others, so converting with an ISO exponent misprices those. Invoice and installment amounts are exact decimal strings and are the safe basis for arithmetic and display."
                    },
                    "currency": {
                      "type": "string",
                      "description": "Settled currency. ISO 4217 alphabetic code, e.g. \"USD\".",
                      "minLength": 3,
                      "maxLength": 3
                    },
                    "mode": {
                      "type": "string",
                      "description": "Provider environment. `test` means a TEST processor handled this money: it is not revenue and must never be reported as cash collected, however real the amount looks. `live` is real money.",
                      "enum": [
                        "test",
                        "live"
                      ]
                    },
                    "status": {
                      "type": "string",
                      "description": "Payments appear here only once verified/confirmed by the provider.",
                      "enum": [
                        "confirmed"
                      ]
                    },
                    "provider_paid_at": {
                      "type": "string",
                      "description": "Provider-reported payment time (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "confirmed_at": {
                      "type": "string",
                      "description": "When Finero confirmed the settlement (ISO 8601 UTC).",
                      "format": "date-time"
                    },
                    "erp_push_status": {
                      "type": "string",
                      "description": "Whether this payment has reached the ERP as a receipt. `posted` = the ERP accepted it; `applied` = the ERP also allocated it against the receivable — the ERP owns allocation, so `posted` is a normal steady state and not a fault. `reversed` means it DID reach the ERP and was undone there - reversed, stopped, or returned unpaid - which no retry can repair. `failed` means it did not reach the ERP — retried automatically only while the cause is the ERP being unreachable. `null` when there is no push at all: a payment on a Finero-created invoice (there is no ERP to report to), a tenant not syncing payments to an ERP, or a payment that settled before ERP payment sync was switched on — enabling it never posts history backwards. NOT TO BE CONFUSED WITH an invoice's `erp_push_state`: that one rolls up every push on the invoice and reports the worst, while this is THIS payment's own.",
                      "nullable": true,
                      "enum": [
                        "pending",
                        "processing",
                        "posted",
                        "applied",
                        "failed",
                        "reversed"
                      ]
                    },
                    "erp_receipt_number": {
                      "type": "string",
                      "description": "The receipt identifier Finero sends to the ERP (`FIN-…`), derived deterministically from the payment id. This is the value that identifies the receipt inside the ERP.",
                      "nullable": true
                    },
                    "erp_external_receipt_id": {
                      "type": "string",
                      "description": "The ERP's own identifier for the receipt, once it has created one.",
                      "nullable": true
                    },
                    "erp_pushed_at": {
                      "type": "string",
                      "description": "When the ERP first accepted the receipt (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "erp_push_error_category": {
                      "type": "string",
                      "description": "Coarse reason the push has not gone through, when it has not. Cleared by the database the moment a push succeeds, so it is never a stale error beside a `posted`/`applied` status. Provider error text is deliberately not exposed.",
                      "nullable": true
                    }
                  },
                  "required": [
                    "id",
                    "invoice_id",
                    "installment_id",
                    "payment_link_id",
                    "provider",
                    "provider_payment_id",
                    "amount_minor",
                    "currency",
                    "mode",
                    "status",
                    "provider_paid_at",
                    "confirmed_at",
                    "erp_push_status",
                    "erp_receipt_number",
                    "erp_external_receipt_id",
                    "erp_pushed_at",
                    "erp_push_error_category"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "id": "7e6d5c4b-3a29-4180-9f8e-7d6c5b4a3928",
                  "invoice_id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                  "installment_id": "a3c9d2e1-55b4-4c8e-9f01-2b3c4d5e6f70",
                  "payment_link_id": "0d9c8b7a-6e5f-4d3c-b2a1-908f7e6d5c4b",
                  "provider": "stripe",
                  "provider_payment_id": "pi_ExampleOnly000000000000",
                  "amount_minor": 125000,
                  "currency": "USD",
                  "mode": "live",
                  "status": "confirmed",
                  "provider_paid_at": "2026-07-11T14:02:11+00:00",
                  "confirmed_at": "2026-07-11T14:02:14+00:00",
                  "erp_push_status": "posted",
                  "erp_receipt_number": "FIN-8ZQ4N2VP7KMX",
                  "erp_external_receipt_id": "300100123456789",
                  "erp_pushed_at": "2026-07-11T14:03:02+00:00",
                  "erp_push_error_category": null
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/sync-runs": {
      "get": {
        "operationId": "listSyncRuns",
        "summary": "List sync runs",
        "description": "Lists ERP sync runs, newest first by creation time. Cursor-paginated: pass ?limit= (1–100) and follow pagination.next_cursor until has_more is false. To learn HOW MANY rows match without walking every page, pass ?include_total=true once and read pagination.total_count.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "ERP sync"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1–100. Default 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque pagination cursor from a previous response's pagination.next_cursor.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 400
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "Sort direction. Ordering is ALWAYS by when the record was CREATED in Finero, never by when it changed — `updated_since` narrows the set but does not reorder it, so the most recently updated row is not necessarily first. Creation time is immutable, which is what lets the cursor stay stable: ordering by a value that changes would move a row mid-walk and make a page skip or repeat it. Default desc (newest first by creation time).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "include_total",
            "in": "query",
            "required": false,
            "description": "Return pagination.total_count — the number of rows matching the filters, ignoring paging. It is the SAME number on every page, including alongside a cursor. Off by default: an exact count scans the whole filtered set, so asking on each page pays repeatedly for an answer that does not change — ask on the first request and keep it.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "connection_id",
            "in": "query",
            "required": false,
            "description": "Filter by ERP connection.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filter by run status.",
            "schema": {
              "type": "string",
              "enum": [
                "running",
                "success",
                "partial",
                "failed"
              ]
            }
          },
          {
            "name": "direction",
            "in": "query",
            "required": false,
            "description": "Filter by sync direction. Only `pull` matches new runs; `push` matches historical runs recorded before outbound sync was removed.",
            "schema": {
              "type": "string",
              "enum": [
                "pull",
                "push"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "description": "Sync runs, newest first by creation time.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Sync run id — poll this run until status is terminal.",
                            "format": "uuid"
                          },
                          "connection_id": {
                            "type": "string",
                            "description": "The ERP connection that ran.",
                            "format": "uuid"
                          },
                          "direction": {
                            "type": "string",
                            "description": "Sync direction. Every new run is `pull` — Finero no longer writes invoices back to an ERP. `push` remains in the enum because historical runs carry it.",
                            "enum": [
                              "pull",
                              "push"
                            ]
                          },
                          "trigger": {
                            "type": "string",
                            "description": "How the run started: `manual` (a person, in the Finero app), `scheduled` (the connection's own cadence), or `api` (POST /v1/erp-connections/{connectionId}/sync). Until 2026-08-11 API-started runs were recorded as `manual`; they are `api` now, because reporting a machine-started run as a human one misattributes it.",
                            "enum": [
                              "manual",
                              "scheduled",
                              "api"
                            ]
                          },
                          "status": {
                            "type": "string",
                            "description": "running is non-terminal; success/partial/failed are terminal. `partial` means the run ended with some invoices processed and some not: what WAS processed is applied and trustworthy, and `error_summary` says why the rest was not — a failure on some items, or a pause at a per-run limit that the next sync resumes from.",
                            "enum": [
                              "running",
                              "success",
                              "partial",
                              "failed"
                            ]
                          },
                          "started_at": {
                            "type": "string",
                            "description": "Run start time (ISO 8601 UTC).",
                            "format": "date-time"
                          },
                          "finished_at": {
                            "type": "string",
                            "description": "Run finish time (null while running) (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          },
                          "items_processed": {
                            "type": "integer",
                            "description": "Invoices this run processed. A pull is INCREMENTAL: it reads only invoices your ERP reports changed since the last successful run, so a small number - even 1 - on a large workspace is the normal, healthy result of a quiet hour, NOT a partial read. A run that processes the whole book is the one that had no saved position to resume from: the first pull, or recovery after certain failures. Judge a run by `status`; judge freshness by the connection's `last_successful_sync_at` - never by whether this number looks big enough."
                          },
                          "items_failed": {
                            "type": "integer",
                            "description": "Invoices this run read but could not process."
                          },
                          "error_summary": {
                            "type": "string",
                            "description": "Categorical failure summary (never raw provider payloads).",
                            "nullable": true
                          }
                        },
                        "required": [
                          "id",
                          "connection_id",
                          "direction",
                          "trigger",
                          "status",
                          "started_at",
                          "finished_at",
                          "items_processed",
                          "items_failed",
                          "error_summary"
                        ],
                        "additionalProperties": false
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "next_cursor": {
                          "type": "string",
                          "description": "Opaque cursor for the next page — pass as ?cursor=. Null when there are no further results.",
                          "nullable": true
                        },
                        "has_more": {
                          "type": "boolean",
                          "description": "Whether another page exists."
                        },
                        "limit": {
                          "type": "integer",
                          "description": "The page size that was applied."
                        },
                        "total_count": {
                          "type": "integer",
                          "description": "Total rows matching the filters, ignoring paging. Present ONLY when the request passed ?include_total=true."
                        }
                      },
                      "required": [
                        "next_cursor",
                        "has_more",
                        "limit"
                      ],
                      "additionalProperties": false
                    }
                  },
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "data": [
                    {
                      "id": "3f2e1d0c-9b8a-4756-8493-21f0e9d8c7b6",
                      "connection_id": "1a2b3c4d-5e6f-4a80-91b2-c3d4e5f60718",
                      "direction": "pull",
                      "trigger": "manual",
                      "status": "success",
                      "started_at": "2026-07-14T05:00:04+00:00",
                      "finished_at": "2026-07-14T05:00:41+00:00",
                      "items_processed": 36,
                      "items_failed": 0,
                      "error_summary": null
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/sync-runs/{syncRunId}": {
      "get": {
        "operationId": "getSyncRun",
        "summary": "Get a sync run",
        "description": "Returns one ERP sync run (read-only operational visibility). Poll it after starting a pull, to follow that pull to completion.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "ERP sync"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "syncRunId",
            "in": "path",
            "required": true,
            "description": "Sync run id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "description": "Sync run id — poll this run until status is terminal.",
                      "format": "uuid"
                    },
                    "connection_id": {
                      "type": "string",
                      "description": "The ERP connection that ran.",
                      "format": "uuid"
                    },
                    "direction": {
                      "type": "string",
                      "description": "Sync direction. Every new run is `pull` — Finero no longer writes invoices back to an ERP. `push` remains in the enum because historical runs carry it.",
                      "enum": [
                        "pull",
                        "push"
                      ]
                    },
                    "trigger": {
                      "type": "string",
                      "description": "How the run started: `manual` (a person, in the Finero app), `scheduled` (the connection's own cadence), or `api` (POST /v1/erp-connections/{connectionId}/sync). Until 2026-08-11 API-started runs were recorded as `manual`; they are `api` now, because reporting a machine-started run as a human one misattributes it.",
                      "enum": [
                        "manual",
                        "scheduled",
                        "api"
                      ]
                    },
                    "status": {
                      "type": "string",
                      "description": "running is non-terminal; success/partial/failed are terminal. `partial` means the run ended with some invoices processed and some not: what WAS processed is applied and trustworthy, and `error_summary` says why the rest was not — a failure on some items, or a pause at a per-run limit that the next sync resumes from.",
                      "enum": [
                        "running",
                        "success",
                        "partial",
                        "failed"
                      ]
                    },
                    "started_at": {
                      "type": "string",
                      "description": "Run start time (ISO 8601 UTC).",
                      "format": "date-time"
                    },
                    "finished_at": {
                      "type": "string",
                      "description": "Run finish time (null while running) (ISO 8601 UTC).",
                      "nullable": true,
                      "format": "date-time"
                    },
                    "items_processed": {
                      "type": "integer",
                      "description": "Invoices this run processed. A pull is INCREMENTAL: it reads only invoices your ERP reports changed since the last successful run, so a small number - even 1 - on a large workspace is the normal, healthy result of a quiet hour, NOT a partial read. A run that processes the whole book is the one that had no saved position to resume from: the first pull, or recovery after certain failures. Judge a run by `status`; judge freshness by the connection's `last_successful_sync_at` - never by whether this number looks big enough."
                    },
                    "items_failed": {
                      "type": "integer",
                      "description": "Invoices this run read but could not process."
                    },
                    "error_summary": {
                      "type": "string",
                      "description": "Categorical failure summary (never raw provider payloads).",
                      "nullable": true
                    }
                  },
                  "required": [
                    "id",
                    "connection_id",
                    "direction",
                    "trigger",
                    "status",
                    "started_at",
                    "finished_at",
                    "items_processed",
                    "items_failed",
                    "error_summary"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "id": "3f2e1d0c-9b8a-4756-8493-21f0e9d8c7b6",
                  "connection_id": "1a2b3c4d-5e6f-4a80-91b2-c3d4e5f60718",
                  "direction": "pull",
                  "trigger": "manual",
                  "status": "success",
                  "started_at": "2026-07-14T05:00:04+00:00",
                  "finished_at": "2026-07-14T05:00:41+00:00",
                  "items_processed": 36,
                  "items_failed": 0,
                  "error_summary": null
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/workflow-executions": {
      "get": {
        "operationId": "listWorkflowExecutions",
        "summary": "List workflow executions",
        "description": "Audit trail of the tenant's Payment Link Automation runs: what ran, what was skipped, and why — with stable machine-readable reason codes. Call `GET /v1/workflows` for what each reason code means. NOTE: only Payment Link Automation records executions here. Email workflows do not, so an absent execution never means an email was not sent. Cursor-paginated: pass ?limit= (1–100) and follow pagination.next_cursor until has_more is false. To learn HOW MANY rows match without walking every page, pass ?include_total=true once and read pagination.total_count.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Workflows"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size, 1–100. Default 25.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque pagination cursor from a previous response's pagination.next_cursor.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 400
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "Sort direction. Ordering is ALWAYS by when the record was CREATED in Finero, never by when it changed — `updated_since` narrows the set but does not reorder it, so the most recently updated row is not necessarily first. Creation time is immutable, which is what lets the cursor stay stable: ordering by a value that changes would move a row mid-walk and make a page skip or repeat it. Default desc (newest first by creation time).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "include_total",
            "in": "query",
            "required": false,
            "description": "Return pagination.total_count — the number of rows matching the filters, ignoring paging. It is the SAME number on every page, including alongside a cursor. Off by default: an exact count scans the whole filtered set, so asking on each page pays repeatedly for an answer that does not change — ask on the first request and keep it.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "workflow_type",
            "in": "query",
            "required": false,
            "description": "Filter by workflow type.",
            "schema": {
              "type": "string",
              "enum": [
                "payment_link_automation",
                "email_notification",
                "internal_notification"
              ]
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Filter by outcome.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "processing",
                "succeeded",
                "skipped",
                "failed"
              ]
            }
          },
          {
            "name": "invoice_id",
            "in": "query",
            "required": false,
            "description": "Filter by invoice.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "description": "Workflow executions, newest first by creation time.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Workflow execution id.",
                            "format": "uuid"
                          },
                          "workflow_type": {
                            "type": "string",
                            "description": "Which automation ran.",
                            "enum": [
                              "payment_link_automation",
                              "email_notification",
                              "internal_notification"
                            ]
                          },
                          "invoice_id": {
                            "type": "string",
                            "description": "Related invoice.",
                            "nullable": true,
                            "format": "uuid"
                          },
                          "installment_id": {
                            "type": "string",
                            "description": "Related installment.",
                            "nullable": true,
                            "format": "uuid"
                          },
                          "payment_link_id": {
                            "type": "string",
                            "description": "Related payment link.",
                            "nullable": true,
                            "format": "uuid"
                          },
                          "trigger_reason": {
                            "type": "string",
                            "description": "What triggered the run."
                          },
                          "status": {
                            "type": "string",
                            "description": "Outcome of this execution.",
                            "enum": [
                              "pending",
                              "processing",
                              "succeeded",
                              "skipped",
                              "failed"
                            ]
                          },
                          "reason_code": {
                            "type": "string",
                            "description": "Categorical reason for a skip/failure (stable machine-readable code). the workflows catalog returns what every code means, where the problem is, and what to do about it — never guess from the name.",
                            "nullable": true
                          },
                          "reason_message": {
                            "type": "string",
                            "description": "Human-readable reason.",
                            "nullable": true
                          },
                          "started_at": {
                            "type": "string",
                            "description": "Execution start (ISO 8601 UTC).",
                            "format": "date-time"
                          },
                          "finished_at": {
                            "type": "string",
                            "description": "Execution finish (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          }
                        },
                        "required": [
                          "id",
                          "workflow_type",
                          "invoice_id",
                          "installment_id",
                          "payment_link_id",
                          "trigger_reason",
                          "status",
                          "reason_code",
                          "reason_message",
                          "started_at",
                          "finished_at"
                        ],
                        "additionalProperties": false
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "next_cursor": {
                          "type": "string",
                          "description": "Opaque cursor for the next page — pass as ?cursor=. Null when there are no further results.",
                          "nullable": true
                        },
                        "has_more": {
                          "type": "boolean",
                          "description": "Whether another page exists."
                        },
                        "limit": {
                          "type": "integer",
                          "description": "The page size that was applied."
                        },
                        "total_count": {
                          "type": "integer",
                          "description": "Total rows matching the filters, ignoring paging. Present ONLY when the request passed ?include_total=true."
                        }
                      },
                      "required": [
                        "next_cursor",
                        "has_more",
                        "limit"
                      ],
                      "additionalProperties": false
                    }
                  },
                  "required": [
                    "data",
                    "pagination"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "data": [
                    {
                      "id": "6c5d4e3f-2a1b-4c9d-8e7f-0a1b2c3d4e5f",
                      "workflow_type": "payment_link_automation",
                      "invoice_id": "8f14e45f-ceea-4a5b-9d2c-167ce7de1a10",
                      "installment_id": "a3c9d2e1-55b4-4c8e-9f01-2b3c4d5e6f70",
                      "payment_link_id": "0d9c8b7a-6e5f-4d3c-b2a1-908f7e6d5c4b",
                      "trigger_reason": "invoice_synced",
                      "status": "succeeded",
                      "reason_code": null,
                      "reason_message": null,
                      "started_at": "2026-07-10T06:31:00+00:00",
                      "finished_at": "2026-07-10T06:31:01+00:00"
                    }
                  ],
                  "pagination": {
                    "next_cursor": null,
                    "has_more": false,
                    "limit": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/workflows": {
      "get": {
        "operationId": "listWorkflows",
        "summary": "List automation workflows",
        "description": "Every workflow Finero has, what each one does, what causes it to run, and which are switched on for this workspace — plus what every execution reason code means. Read this to understand how the automations relate to each other: creating a payment link is what sends the customer the payment email, so the same email goes out whether the link was created by the automation, by hand in Finero, or through this API. The set is closed and small, so this operation is not paginated.\n\nAvailable to **admin and readonly** API keys (no side effects).",
        "tags": [
          "Workflows"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "x-permission": "readonly",
        "x-side-effects": false,
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "workflows": {
                      "type": "array",
                      "description": "Every workflow Finero has, with this workspace's switches merged in.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "workflow_type": {
                            "type": "string",
                            "description": "Stable identifier.",
                            "enum": [
                              "payment_link_automation",
                              "email_notification",
                              "internal_notification"
                            ]
                          },
                          "name": {
                            "type": "string",
                            "description": "Display name in Finero."
                          },
                          "audience": {
                            "type": "string",
                            "description": "Who this workflow contacts directly. `none` means it contacts nobody — its effects reach people only through the workflows it fires.",
                            "enum": [
                              "customer",
                              "internal",
                              "none"
                            ]
                          },
                          "enabled": {
                            "type": "boolean",
                            "description": "Whether the workflow is on for this workspace."
                          },
                          "configured": {
                            "type": "boolean",
                            "description": "Whether anyone has ever set this workflow up. False with `enabled` false means never set up, which is a different answer from deliberately switched off."
                          },
                          "does": {
                            "type": "string",
                            "description": "What the workflow does."
                          },
                          "never_does": {
                            "type": "array",
                            "description": "Limits worth stating, because they are what people assume wrongly.",
                            "items": {
                              "type": "string"
                            }
                          },
                          "triggers": {
                            "type": "array",
                            "description": "Its triggers, and whether each is on here.",
                            "items": {
                              "type": "object",
                              "properties": {
                                "key": {
                                  "type": "string",
                                  "description": "The switch an administrator toggles, or null when the workflow has no per-trigger switch and its own on/off is the switch.",
                                  "nullable": true
                                },
                                "title": {
                                  "type": "string",
                                  "description": "How the switch reads in Finero."
                                },
                                "enabled": {
                                  "type": "boolean",
                                  "description": "Whether this trigger is on for this workspace."
                                },
                                "fired_by": {
                                  "type": "array",
                                  "description": "What causes this trigger to fire — including things a person does, not only automated ones.",
                                  "items": {
                                    "type": "string"
                                  }
                                },
                                "effect": {
                                  "type": "string",
                                  "description": "What actually happens when it fires."
                                }
                              },
                              "required": [
                                "key",
                                "title",
                                "enabled",
                                "fired_by",
                                "effect"
                              ],
                              "additionalProperties": false
                            }
                          },
                          "causes": {
                            "type": "array",
                            "description": "Workflows this one fires. This is the relationship between automations: creating a payment link is what sends the customer the email.",
                            "items": {
                              "type": "object",
                              "properties": {
                                "workflow_type": {
                                  "type": "string",
                                  "description": "The workflow this one fires.",
                                  "enum": [
                                    "payment_link_automation",
                                    "email_notification",
                                    "internal_notification"
                                  ]
                                },
                                "trigger": {
                                  "type": "string",
                                  "description": "Which of its triggers."
                                },
                                "when": {
                                  "type": "string",
                                  "description": "Under what conditions, and why."
                                }
                              },
                              "required": [
                                "workflow_type",
                                "trigger",
                                "when"
                              ],
                              "additionalProperties": false
                            }
                          },
                          "operator_controls": {
                            "type": "array",
                            "description": "What a workspace administrator decides.",
                            "items": {
                              "type": "string"
                            }
                          },
                          "automatic": {
                            "type": "array",
                            "description": "What Finero does on its own, without being asked.",
                            "items": {
                              "type": "string"
                            }
                          },
                          "timing": {
                            "type": "string",
                            "description": "When it runs, and how late a missed one can be."
                          },
                          "history": {
                            "type": "string",
                            "description": "Where the record of what it did lives, and whether this API exposes it. Read this before concluding from an empty execution list that nothing happened."
                          },
                          "updated_at": {
                            "type": "string",
                            "description": "When the configuration was last changed (ISO 8601 UTC).",
                            "nullable": true,
                            "format": "date-time"
                          }
                        },
                        "required": [
                          "workflow_type",
                          "name",
                          "audience",
                          "enabled",
                          "configured",
                          "does",
                          "never_does",
                          "triggers",
                          "causes",
                          "operator_controls",
                          "automatic",
                          "timing",
                          "history",
                          "updated_at"
                        ],
                        "additionalProperties": false
                      }
                    },
                    "block_reasons": {
                      "type": "array",
                      "description": "Every code an installment's `block_reasons` can carry, and what each means. A DIFFERENT vocabulary from `reason_codes` below: these say why one installment cannot be collected, those say why an automation run did not do something.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string",
                            "description": "The value that appears in an installment's `block_reasons`."
                          },
                          "label": {
                            "type": "string",
                            "description": "How it reads on screen in Finero."
                          },
                          "meaning": {
                            "type": "string",
                            "description": "What it means, and the next move."
                          }
                        },
                        "required": [
                          "code",
                          "label",
                          "meaning"
                        ],
                        "additionalProperties": false
                      }
                    },
                    "reason_codes": {
                      "type": "array",
                      "description": "Every reason code an execution can carry, and what each means. Flat, because a code is not owned by one workflow.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string",
                            "description": "The value that appears as an execution's `reason_code`."
                          },
                          "meaning": {
                            "type": "string",
                            "description": "What it means, in plain words."
                          },
                          "fault": {
                            "type": "string",
                            "description": "Where the problem is: `configuration` a Finero setting, `data` the ERP data, `provider` the payment or email provider, `finero` a Finero fault, `none` not a fault at all.",
                            "enum": [
                              "none",
                              "configuration",
                              "data",
                              "provider",
                              "finero"
                            ]
                          },
                          "resolution": {
                            "type": "string",
                            "description": "What to do about it, and by whom."
                          }
                        },
                        "required": [
                          "code",
                          "meaning",
                          "fault",
                          "resolution"
                        ],
                        "additionalProperties": false
                      }
                    }
                  },
                  "required": [
                    "workflows",
                    "block_reasons",
                    "reason_codes"
                  ],
                  "additionalProperties": false
                },
                "example": {
                  "workflows": [
                    {
                      "workflow_type": "payment_link_automation",
                      "name": "Payment Link Automation",
                      "audience": "none",
                      "enabled": true,
                      "configured": true,
                      "does": "Creates a hosted payment link for every installment Finero can currently collect on, through the workspace's connected payment processor. It contacts nobody itself — the customer learns about the link only if the Email Notification workflow is switched on.",
                      "never_does": [
                        "Send anything to a customer. Creating the link and emailing it are two workflows.",
                        "Charge a card or move money. The customer pays through the link when they choose to.",
                        "Write anything back to your ERP.",
                        "Create a second link for an installment that already has a live one.",
                        "Decide which invoices are collectible — that follows from your ERP data."
                      ],
                      "triggers": [
                        {
                          "key": null,
                          "title": "When an invoice is ready to collect",
                          "enabled": true,
                          "fired_by": [
                            "a scheduled sweep, every 15 minutes, over the invoices that are ready",
                            "an ERP sync finishing — including one someone starts by hand in Finero",
                            "an invoice being created in Finero"
                          ],
                          "effect": "One payment link per collectible installment, created through the connected processor."
                        }
                      ],
                      "causes": [
                        {
                          "workflow_type": "email_notification",
                          "trigger": "payment_link_created",
                          "when": "Always, when that trigger is on. Creating the link is what sends the email — so the SAME email goes out for a link created by hand in Finero or through the public API. It is a consequence of the link existing, not a step this automation performs."
                        }
                      ],
                      "operator_controls": [
                        "Switch the whole workflow on or off.",
                        "Which payment processor it uses — whichever one is connected."
                      ],
                      "automatic": [
                        "One live payment link per installment, enforced by the database, so a duplicate cannot be created even if two runs overlap.",
                        "An invoice skipped for a missing processor or an unsupported currency is backed off for 24 hours rather than retried on every sweep.",
                        "One invoice failing never stops the rest of the batch."
                      ],
                      "timing": "Immediately during a sync, and otherwise on a sweep every 15 minutes. Nothing waits longer than that.",
                      "history": "Every run is recorded — succeeded, skipped and failed, each with a reason code — and is readable through the workflow-executions endpoint.",
                      "updated_at": "2026-07-05T17:56:44.261124+00:00"
                    },
                    {
                      "workflow_type": "email_notification",
                      "name": "Email Notification",
                      "audience": "customer",
                      "enabled": true,
                      "configured": true,
                      "does": "Emails the customer at the bill-to address on the invoice. Two triggers, switched on and off independently.",
                      "never_does": [
                        "Email anyone other than the address on the invoice. The recipient is never chosen by a caller.",
                        "Send the same notification twice for one payment link — the database refuses a second one.",
                        "Run on its own. It only ever reacts to something else happening."
                      ],
                      "triggers": [
                        {
                          "key": "payment_link_created",
                          "title": "After a payment link is created",
                          "enabled": true,
                          "fired_by": [
                            "Payment Link Automation creating a link",
                            "someone creating a payment link by hand in Finero",
                            "a payment link created through the public API"
                          ],
                          "effect": "The customer is emailed the link so they can pay online."
                        },
                        {
                          "key": "invoice_paid",
                          "title": "After an invoice is paid",
                          "enabled": false,
                          "fired_by": [
                            "a payment being confirmed by the payment processor — every path that can confirm one fires this, so the email does not depend on any single one of them working"
                          ],
                          "effect": "The customer is emailed confirmation that their payment was received."
                        }
                      ],
                      "causes": [],
                      "operator_controls": [
                        "Switch each trigger on or off independently.",
                        "Set the reply-to address.",
                        "Choose the sending mailbox by connecting a Google mailbox; with none connected, Finero sends."
                      ],
                      "automatic": [
                        "At most one email per trigger, payment link and recipient — enforced by the database, which is what makes it safe for several settlement paths to overlap as safety nets.",
                        "A notification that was never attempted — because a read failed, say — is picked up by a recovery sweep within about 15 minutes.",
                        "The amount in the email is the amount that link collects, never the invoice total."
                      ],
                      "timing": "Sent as soon as the thing that fires it happens. A missed one is recovered within about 15 minutes.",
                      "history": "Email attempts are recorded in Finero's notification history, which this API does not expose. NO workflow-execution row is written for an email — so an absent execution never means an email was not sent.",
                      "updated_at": "2026-08-12T16:36:09.323261+00:00"
                    },
                    {
                      "workflow_type": "internal_notification",
                      "name": "Internal Notification",
                      "audience": "internal",
                      "enabled": false,
                      "configured": false,
                      "does": "Emails the workspace's own team at a configured address. Never reaches a customer.",
                      "never_does": [
                        "Email a customer. The recipient is a setting, not the invoice's address.",
                        "Stop working when a connected Google mailbox does. It is always sent from Finero, deliberately, so alerts still arrive on the day that mailbox is disconnected or rate limited."
                      ],
                      "triggers": [
                        {
                          "key": "invoice_paid",
                          "title": "After an invoice is paid",
                          "enabled": false,
                          "fired_by": [
                            "a payment being confirmed by the payment processor — the same moment that fires the customer-facing confirmation, and independent of it"
                          ],
                          "effect": "The team is emailed that a payment was confirmed. Sent whether or not the payment has reached the ERP yet."
                        }
                      ],
                      "causes": [],
                      "operator_controls": [
                        "Switch it on or off.",
                        "Set the address your team is notified at. The address itself is not readable through this API."
                      ],
                      "automatic": [
                        "Sent from Finero always, even with a Google mailbox connected.",
                        "Recovered by its own sweep within about 15 minutes if it was never attempted."
                      ],
                      "timing": "Sent when a payment is confirmed. A missed one is recovered within about 15 minutes.",
                      "history": "Recorded in Finero's notification history, which this API does not expose. No workflow-execution row is written.",
                      "updated_at": null
                    }
                  ],
                  "block_reasons": [
                    {
                      "code": "closed",
                      "label": "Closed",
                      "meaning": "Your ERP reports this installment as closed."
                    },
                    {
                      "code": "disputed",
                      "label": "Disputed",
                      "meaning": "Part or all of the amount is under dispute in the ERP."
                    },
                    {
                      "code": "excluded",
                      "label": "Excluded from collections",
                      "meaning": "Someone marked this installment as not to be chased."
                    },
                    {
                      "code": "excluded_from_collections",
                      "label": "Excluded from collections",
                      "meaning": "Someone marked this installment as not to be chased."
                    },
                    {
                      "code": "has_dispute",
                      "label": "Disputed",
                      "meaning": "Part or all of the amount is under dispute in the ERP."
                    },
                    {
                      "code": "installment_disputed",
                      "label": "Disputed",
                      "meaning": "Part or all of the amount is under dispute in the ERP."
                    },
                    {
                      "code": "installment_status_not_open",
                      "label": "Closed",
                      "meaning": "Your ERP reports this installment as closed."
                    },
                    {
                      "code": "installment_zero_or_negative_balance",
                      "label": "Zero outstanding",
                      "meaning": "Nothing is left to collect on it."
                    },
                    {
                      "code": "installments_missing",
                      "label": "No payment schedule in your ERP",
                      "meaning": "Your ERP reports the invoice as complete but sent no installments for it."
                    },
                    {
                      "code": "installments_original_mismatch",
                      "label": "Installment totals do not match invoice",
                      "meaning": "The installments do not add up to the invoice total, so the amounts cannot be trusted."
                    },
                    {
                      "code": "invalid_amount",
                      "label": "Invalid amount",
                      "meaning": "The amount could not be read from the ERP."
                    },
                    {
                      "code": "invalid_installment",
                      "label": "Invalid installment data",
                      "meaning": "The installment is missing information Finero needs."
                    },
                    {
                      "code": "invoice_cancelled",
                      "label": "Invoice cancelled in the ERP",
                      "meaning": "Your ERP cancelled this invoice, so Finero stopped chasing it. Reinstate it there if that was wrong."
                    },
                    {
                      "code": "invoice_frozen",
                      "label": "Invoice frozen in the ERP",
                      "meaning": "The invoice is locked in the ERP. The money is still owed — unfreeze it there to collect."
                    },
                    {
                      "code": "invoice_not_approved",
                      "label": "Invoice not finalized in the ERP",
                      "meaning": "The invoice is still a draft."
                    },
                    {
                      "code": "invoice_not_collectible",
                      "label": "Invoice not collectible in the ERP",
                      "meaning": "The connector says this invoice cannot be collected, without a more specific reason."
                    },
                    {
                      "code": "invoice_not_ready",
                      "label": "Invoice not collection-ready",
                      "meaning": "Something on the invoice blocks every installment on it."
                    },
                    {
                      "code": "invoice_settled",
                      "label": "Paid in full",
                      "meaning": "It was collected in Finero and nothing is left."
                    },
                    {
                      "code": "invoice_unreadable_balance",
                      "label": "Invoice balance could not be read",
                      "meaning": "The ERP sent a balance Finero could not parse, so it will not act on it."
                    },
                    {
                      "code": "invoice_zero_balance",
                      "label": "Nothing outstanding on the invoice",
                      "meaning": "The invoice balance is already zero."
                    },
                    {
                      "code": "missing_currency",
                      "label": "Missing currency",
                      "meaning": "Without a currency Finero cannot ask for an amount."
                    },
                    {
                      "code": "missing_due_date",
                      "label": "Missing due date",
                      "meaning": "Without a due date Finero cannot schedule collection."
                    },
                    {
                      "code": "missing_recipient_email",
                      "label": "No billing email",
                      "meaning": "The invoice has no billing address in your ERP, so there is nobody to send a payment link to. Add one and it will be picked up on the next sync."
                    },
                    {
                      "code": "no_collectible_installment",
                      "label": "No collectible installment",
                      "meaning": "The invoice itself is fine, but not one of its installments can be charged right now. Each installment shows its own reason."
                    },
                    {
                      "code": "not_collectible",
                      "label": "Not collectible",
                      "meaning": "The ERP flagged this installment as not collectible."
                    },
                    {
                      "code": "paid",
                      "label": "Paid",
                      "meaning": "It is already paid."
                    },
                    {
                      "code": "settled_in_finero",
                      "label": "Paid in full",
                      "meaning": "It was collected in Finero and nothing is left."
                    },
                    {
                      "code": "validation_issues",
                      "label": "Invoice data could not be validated",
                      "meaning": "Something on the invoice did not survive Finero's checks — most often an amount or a currency it could not read. The invoice detail names the field."
                    },
                    {
                      "code": "zero_outstanding",
                      "label": "Zero outstanding",
                      "meaning": "Nothing is left to collect on it."
                    }
                  ],
                  "reason_codes": [
                    {
                      "code": "workflow_disabled",
                      "meaning": "The workflow is switched off for this workspace, so nothing was attempted.",
                      "fault": "configuration",
                      "resolution": "A workspace administrator switches it on in Finero, under Workflows."
                    },
                    {
                      "code": "invoice_not_ready",
                      "meaning": "The invoice is not marked ready to collect. Readiness comes from your ERP data, not from a Finero setting.",
                      "fault": "data",
                      "resolution": "Nothing to change in Finero. The invoice becomes eligible once the ERP shows a collectible balance, and the next sync picks it up."
                    },
                    {
                      "code": "no_collectible_installment",
                      "meaning": "The invoice is ready, but not one installment on it can be charged — each is already paid, disputed, excluded from collections, or has nothing outstanding.",
                      "fault": "data",
                      "resolution": "Read the installment's own block reason on the invoice. Resolving a dispute or correcting the ERP clears it."
                    },
                    {
                      "code": "missing_payment_integration",
                      "meaning": "No payment processor is connected, so there is nothing to create a link with.",
                      "fault": "configuration",
                      "resolution": "An administrator connects a payment processor in Finero. Until then the invoice is backed off for 24 hours rather than retried on every sweep."
                    },
                    {
                      "code": "integration_unavailable",
                      "meaning": "A payment processor is connected but Finero could not use it — the credential was rejected, or the processor could not be reached.",
                      "fault": "provider",
                      "resolution": "If it was briefly unreachable, the next sweep succeeds and nothing needs doing. If the credential was rejected, an administrator reconnects the processor in Finero."
                    },
                    {
                      "code": "invalid_invoice_state",
                      "meaning": "The installment carries an amount Finero cannot read as money.",
                      "fault": "data",
                      "resolution": "Correct the amount in the ERP; retrying cannot fix it."
                    },
                    {
                      "code": "payment_link_already_exists",
                      "meaning": "A live payment link already exists for that installment, so a second was not created.",
                      "fault": "none",
                      "resolution": "Nothing is wrong — this is the duplicate protection working. The existing link is the one to use."
                    },
                    {
                      "code": "amount_changed",
                      "meaning": "The amount owed changed after the link was created, so the old link no longer matches the invoice.",
                      "fault": "data",
                      "resolution": "Nothing to do. Finero revalidates before a customer pays, so a stale amount is never charged."
                    },
                    {
                      "code": "currency_changed",
                      "meaning": "The invoice is now billed in a different currency than the one the payment link was created in, so that link no longer matches the invoice.",
                      "fault": "data",
                      "resolution": "Nothing to do. Finero revalidates before a customer pays, so the wrong currency is never charged."
                    },
                    {
                      "code": "currency_not_supported",
                      "meaning": "The connected payment processor cannot charge the invoice's currency.",
                      "fault": "configuration",
                      "resolution": "Connect a processor that supports that currency, or collect those invoices outside Finero. The invoice is backed off for 24 hours rather than retried on every sweep."
                    },
                    {
                      "code": "feature_unavailable",
                      "meaning": "The workspace's plan does not include payment links.",
                      "fault": "configuration",
                      "resolution": "Change the plan. This is not a fault — retrying will keep producing the same answer."
                    },
                    {
                      "code": "ineligible",
                      "meaning": "The installment cannot be collected on: nothing is outstanding, it is disputed or excluded, or a payment landed while the link was being created.",
                      "fault": "data",
                      "resolution": "Usually nothing — an already-collected installment is the ordinary case. Otherwise the block reason on the installment says which."
                    },
                    {
                      "code": "temporary_provider_failure",
                      "meaning": "The payment processor failed in a way that is worth retrying.",
                      "fault": "provider",
                      "resolution": "Nothing to do. The next sweep tries again."
                    },
                    {
                      "code": "unknown_provider_result",
                      "meaning": "Finero contacted the email provider and could not learn whether the message went out. Belongs to the email path, not to automation runs.",
                      "fault": "provider",
                      "resolution": "It is deliberately not retried automatically where a retry could email someone twice, and the delivery keeps its place so nothing else sends in its stead. If the customer confirms it never arrived, send it again by hand from the invoice."
                    },
                    {
                      "code": "internal_error",
                      "meaning": "Finero failed, and the cause is not something in your workspace.",
                      "fault": "finero",
                      "resolution": "Report it to Finero support with the execution's timestamp."
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`validation_failed` — The request failed schema validation. See error.details for field messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_credentials` — The credential is malformed or does not match an active key. The response never reveals whether a key identifier exists. `missing_credentials` — No Authorization: Bearer header was sent. `revoked_credentials` — The presented key was revoked. Create a new key in Settings → API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`feature_unavailable` — The tenant's tier does not currently include API access. Feature rollout flags cannot grant this entitlement. `permission_denied` — The key's permission does not allow this operation (readonly keys cannot call admin-only operations).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` — No such resource in YOUR tenant. Ids belonging to another tenant are indistinguishable from missing ones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limit_exceeded` — Too many requests. Honor the Retry-After header (seconds) before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error` — Unexpected server error. Safe to retry with the same Idempotency-Key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "A Finero API key (`fnr_…`), created by a tenant admin in Settings → API. Sent as `Authorization: Bearer fnr_…`. Never place keys in URLs.",
        "x-permissions": {
          "supported": [
            "admin",
            "readonly"
          ],
          "operationField": "x-permission",
          "description": "Each operation declares the LEAST key permission that may call it in `x-permission`. An `admin` key satisfies every operation; a `readonly` key satisfies only operations whose `x-permission` is `readonly`. The router enforces this from the same classification this document is generated from — the check is never inferred from the HTTP method. These are key PERMISSIONS, not OAuth scopes: this scheme is HTTP bearer, and OpenAPI declares `scopes` only on `oauth2`/`openIdConnect` schemes. Finero's OAuth surface is the MCP server, which publishes RFC 9728 protected-resource metadata at `https://api.getfinero.com/functions/v1/mcp/.well-known/oauth-protected-resource`. Access there is granted per capability on a consent screen and enforced per tool, not by OAuth scope values — the authorization server accepts only `openid`."
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "additionalProperties": false,
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message",
              "request_id"
            ],
            "additionalProperties": false,
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable error code.",
                "enum": [
                  "missing_credentials",
                  "invalid_credentials",
                  "revoked_credentials",
                  "feature_unavailable",
                  "permission_denied",
                  "not_found",
                  "validation_failed",
                  "conflict",
                  "idempotency_key_required",
                  "idempotency_conflict",
                  "rate_limit_exceeded",
                  "payload_too_large",
                  "method_not_allowed",
                  "installment_not_collectible",
                  "sync_disabled",
                  "integration_unavailable",
                  "currency_not_supported",
                  "internal_error"
                ]
              },
              "message": {
                "type": "string",
                "description": "Human-readable explanation."
              },
              "request_id": {
                "type": "string",
                "description": "Echo of X-Request-Id — include it when reporting problems."
              },
              "details": {
                "type": "array",
                "description": "Field-level validation messages (validation_failed only).",
                "items": {
                  "type": "object",
                  "required": [
                    "field",
                    "message"
                  ],
                  "properties": {
                    "field": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}