# Implementing a Luminetric integration This file is for an AI coding assistant, or a developer, writing integration code against the Luminetric Bill Data API, contract version 1.0.0. It begins with the rules integration code most often gets wrong, then gives the complete documentation and the JSON Schemas for every webhook body. Where this checklist and the documentation below seem to differ, the documentation is authoritative. ## The shape of an integration - Base URL: `https://api.luminetric.com`. All bodies are JSON. - **Two separate bearer tokens.** The upload token authorizes `POST /v1/statements`. The connection API token authorizes every `/v1/providers`, `/v1/connections`, `/v1/accounts` and `/v1/expected-accounts` call. They are not interchangeable; keep them in separate configuration values. - **Every result arrives by webhook**, at one HTTPS URL the client registers with us. No API call returns a statement. An upload answers `202 Accepted` and nothing more. - The integration therefore has two parts: an outbound client (upload, and optionally connections) and an inbound webhook receiver. Build the receiver first; nothing is usable without it. ## Sending a bill - `request_id` is chosen by the client: 1 to 128 characters of letters, digits, `.`, `_`, `:` or `-`, starting with a letter or digit. Generate it once per bill and persist it before sending, so a retry reuses it. Retrying with the same `request_id` is safe. - `document.sha256` is required: the lowercase hex SHA-256 of the PDF's raw bytes (not of the base64). - Send exactly one of `document.url` (https, publicly fetchable, at most 40 MB) or `document.content_base64`. The request body is capped at 32 MB, so files above about 24 MB must go by URL. - Retry `500` and `502` with backoff. Do not retry `400` or `401`; the error message says what to fix. - Sending a file already sent produces a `request.duplicate` event, not a new extraction, unless `options.force_reprocess` is true. ## Receiving webhooks 1. **Verify the signature against the raw body bytes, before parsing JSON.** Most frameworks parse the body by default; configure the route to expose the raw bytes. Re-serialized JSON will not verify. The `Luminetric-Signature` header is `t=,v1=`, and `v1 = hex(HMAC_SHA256(secret, t + "." + raw_body))`. Compare in constant time and reject a `t` more than 300 seconds from the current time. The HMAC key is the secret string exactly as issued, as UTF-8 bytes (do not hex- or base64-decode it). The header carries one `v1`. Treat a missing or malformed header as a failed check (`401`), not as a server error. 2. **Store, then acknowledge, then process.** Return any `2xx` as soon as the event is durably stored, and process it afterward (a queue or background job). We time out after 30 seconds and retry on a timeout or `5xx`. 3. **Return 2xx for duplicates and for event types you do not recognize.** A `4xx` tells us the request itself is wrong and is not retried, so never answer a repeat delivery or an unknown event with `4xx`. Use `401` only for a failed signature check. 4. **Delivery is at least once.** De-duplicate on the `Luminetric-Delivery-Id` header: store it with a unique constraint and skip the event if it is already present. 5. **Arrival order is not guaranteed.** Key statements on `statement_id`. When two versions of one statement arrive, keep the one with the later `extracted_at`, whichever arrived last. A `statement.withdrawn` means stop using that `statement_id`; it is never sent again. 6. Dispatch on the `Luminetric-Event` header. Every body except `statement.available` also carries an `event` field, but a statement body does not, so the header is the one to use: `statement.available`, `statement.withdrawn`, `request.failed`, `request.duplicate`, `retrieval.completed`, `retrieval.failed`. ## Reading payloads - **Values are objects, not scalars.** Money is `{"amount", "as_printed", "source"}`; quantities and dates are `{"value", ...}`; identifiers add `normalized`. Read `statement.totals.amount_due.amount`, never `statement.totals.amount_due` as a number. Amounts are numbers (credits negative), dates are `YYYY-MM-DD`. - **Match accounts, locations and meters on `normalized`**, never on `value` or `as_printed`. For a retrieved bill, tie the statement to the account by `account_id`, not by the account number: the bill may print a different form of the number than the portal listed. - **Treat an absent field and `null` alike.** Webhook bodies generally send `null` for an empty value, though a field that does not apply (such as `account_id` on an uploaded bill) is absent. The connection API's responses omit empty fields altogether. Code must handle both shapes. - **Ignore unknown fields** at every level, and **do not use closed enums**. An unrecognized `service` or `kind` is `other`; an unrecognized `confidence.tier` is `review`; an unrecognized `reason.code` is judged by its `customer_action_required` flag. Generated types must not reject unknown enum values (the schemas list them under `x-known-values`, not `enum`, for this reason). - Do not depend on the order of `charges`, `sections`, `meters` or `children.delivered`. - `provider` is not yet sent on statements. Do not require it. - `confidence.tier` is `deliver` or `review`. A sensible default is to load `deliver` automatically and route `review` to a person. ## Summary bills - A summary bill arrives as a parent (`statement.is_summary: true`) followed by children, each with `parent_statement_id`. The parent's top-level `children` block holds `expected_count` and `delivered[]`, one entry per child. - The parent can carry charges of its own. Its `current_charges` is its own charges plus the children's; summing only the children comes up short. - Several children can share an account number and address. Tell them apart by `meter_numbers`. - If `children.expected_count` exceeds the children received, more are still owed; do not treat the bill as complete. ## Failures - `request.failed` and `retrieval.failed` carry `reason.code`, `reason.message` and `reason.customer_action_required`. When that flag is false the problem is ours, and statements may still arrive later under the same `request_id`. Surface `reason.message` to a person only when the flag is true. ## Portal connections (only if retrieving bills) - `POST /v1/connections` returns `201` for a new connection and `200` for a login already registered. Both are success. - The first run of a connection is a discovery run: it lists accounts (`retrieval.completed` with `"dry_run": true` and `new_accounts`) and downloads nothing. Bills flow only for accounts that are activated (`POST /v1/accounts/{account_id}/activate`) or pre-registered with `PUT /v1/expected-accounts`. - A connection whose `customer_action_required` is true is not run until credentials are sent again with `PUT /v1/connections/{connection_id}/credentials`. - `POST /v1/connections/{connection_id}/runs` returns `202` (queued), `200` (a run was already pending; not an error) or `429` (triggered under 60 minutes ago; wait, do not retry in a loop). - A run may send more than one `retrieval.*` event under one `request_id`. The latest is the outcome. - Retrieved statements carry their own `request_id` (one per bill, not the run's); link them to the connection by `connection_id` and to the account by `account_id`. Never require either field: uploaded bills have neither, and a retrieved bill that matched no account has no `account_id`. ## Testing Every sample payload and schema is linked below and published at `https://luminetric.com/docs/samples/` and `https://luminetric.com/docs/schemas/`. A good test suite replays each `statement.available` sample through the receiver, validates it against `statement.available.schema.json`, and signs it with a test secret to exercise verification. Fields in the samples whose names begin with `_` are explanatory notes and never appear in real payloads. ## Overview You send us a utility bill as a PDF, or give us a login to the utility's portal and we retrieve the bills ourselves. Either way, we send back one structured statement per bill, as signed JSON webhooks to an HTTPS endpoint you register. Every figure we send carries the text it was read from and says how it was obtained, so you can check any value against the bill. This documentation describes **contract version 1.0.0**, frozen on October 14, 2026. Every endpoint and field it describes is live and is what the service produces today; the retrieval schedule starts when your account goes live. From 1.0.0 on, the rules in [Compatibility and versioning](https://luminetric.com/docs/versioning.md) apply. ### How an integration works An integration has two halves. 1. **You send a bill.** One HTTPS request per PDF, authenticated with your API token. You choose the `request_id`, and you can attach your own `client_reference`, which we echo on everything we send back about that request. 2. **We answer every request** at the webhook URL you registered. Most requests produce one statement. A summary bill produces a parent statement plus one child per metered service point, and a PDF holding several unrelated bills produces one statement for each. A request we cannot complete produces a `request.failed` event instead, and a file you already sent produces `request.duplicate`. **Or we retrieve the bills.** Register a login to a utility portal once, choose which of its accounts you want, and we sign in on a schedule or whenever you ask. Each new bill is delivered exactly as an uploaded one would be, and every run ends in a `retrieval.completed` or `retrieval.failed` event. See [Connecting a portal](https://luminetric.com/docs/connecting-portals.md). Processing is asynchronous. The upload returns as soon as the bill is queued. Results arrive by webhook. Turnaround is set in your service agreement, and a large summary bill takes longer than a single bill. ``` Your system --POST /v1/statements--> Luminetric (answers 202 Accepted at once) Luminetric queues, extracts and checks the bill Luminetric --statement.available, or request.failed, or request.duplicate--> your webhook later, if re-extracted: a new version (statement.available), or statement.withdrawn ``` A request ends in exactly one of three answers: statements, a failure or a duplicate notice. A later re-extraction can add a version or withdraw a statement. ### Terms | Term | Meaning | | --- | --- | | Request | One PDF you sent, identified by your `request_id` | | Document | That file. Its `document_id` is the same however many statements it yields | | Statement | One bill for one account and period (or one service point on a summary bill), identified by `statement_id` | | Version | One extraction of a statement, identified by `version_id`. A re-extraction delivers a new version under the same `statement_id` | | Connection | One login to one utility portal, identified by `connection_id` | | Account | One account a connection's login can see, identified by `account_id`. You choose which accounts we retrieve | | Run | One retrieval for a connection, scheduled or triggered by you, and retried under the same id when the portal could not be read. Identified by its own `request_id` | | Event | One webhook delivery, named in the `Luminetric-Event` header | ## Sending a bill Send requests to `https://api.luminetric.com`. Your API token is issued when your account is set up. ``` POST /v1/statements Authorization: Bearer Content-Type: application/json ``` ```json { "request_id": "req_0a4d6e82-9c37-4b15-a8f2-3e7b1d5c9042", "client_reference": "invoice-433193", "provider": "Example Water District", "document": { "url": "https://files.example.com/bills/7299639426-2026-08-18.pdf", "content_type": "application/pdf", "sha256": "329ec7b43ecb6bad0f2bbc25c888987a5ce677ade468ac31a1f0f2e0875a1b4c" }, "options": { "force_reprocess": false } } ``` | Field | Required | Notes | | --- | --- | --- | | `request_id` | yes | Your unique id for this request: 1 to 128 letters, digits, '.', '\_', ':' or '-', starting with a letter or digit. Every event about it carries it. Reusing an id another account holds is refused | | `client_reference` | no | Your own label, echoed on every statement and event for this request. A string of at most 255 characters; anything else is ignored rather than failing the request | | `provider` | no | The utility you believe issued the bill. We read the issuer from the bill itself and use your value as a check, not an instruction | | `document.url` | one of url or content\_base64 | Where we fetch the PDF, if you are not sending it inline: an https URL on the standard port, publicly reachable for long enough for us to fetch it (a pre-signed storage URL works well). Up to 3 redirects are followed, to https only. At most 40 MB | | `document.content_base64` | one of url or content\_base64 | The PDF itself, base64-encoded (standard alphabet, padding allowed), instead of a URL. Send this or document.url, not both. The request body is capped at 32 MB and base64 adds a third, so files above about 24 MB must go by document.url | | `document.sha256` | yes | SHA-256 of the file's bytes, so we can confirm the file we received is the one you sent | | `options.force_reprocess` | no | Extract again even if you have sent this file before. Default `false` | Only PDFs are accepted. A scanned bill is fine. Send one request per file: a PDF that holds several bills is split for you. **Responses.** `202 Accepted` means the bill is queued, and everything after that arrives by webhook. `401` means the token is missing or invalid, and `400` means the request cannot succeed as sent: the body is not valid JSON, a field is missing or malformed, both or neither of url and content\_base64 are sent, the URL is refused, the base64 does not decode, or the file received is not a PDF matching document.sha256. The error message says which, and lists every problem at once. 502 means we could not fetch the document (it was unreachable or answered with an error), and 500 means we could not queue it; retry both. 404 and 405 mean a wrong path or method. Retrying a request with the same `request_id` is safe. **Sending the same file twice.** We do not extract a file you have already sent. The new request is answered at once with a `request.duplicate` event naming the earlier request, and the statements are the earlier request's. To extract it again, set `force_reprocess`. ## Connecting a portal A connection is one login to one utility portal. We store the login encrypted, sign in on a schedule or when you ask, list every account the login can see, and retrieve bills only for the accounts you activate. Accounts are covered on their own page: [Accounts](https://luminetric.com/docs/accounts.md). 1. **Register the connection** with `POST /v1/connections`. It starts as `pending_verification`. 2. **The first run verifies the login and lists its accounts.** It downloads nothing: its `retrieval.completed` has `"dry_run": true` and every account under `new_accounts`. It follows the schedule, or you can start it at once with `POST /v1/connections/{connection_id}/runs`. 3. **Activate the accounts you want**, or send their numbers beforehand with `PUT /v1/expected-accounts` and they activate themselves when found. Accounts you do not activate stay `never_collected`, and their bills are never delivered. 4. **We retrieve each active account around its billing cycle.** Every run ends in a `retrieval.completed` or `retrieval.failed` event, and each new bill arrives as a `statement.available`. ### Endpoints The connection API shares the base URL `https://api.luminetric.com`. Every call carries `Authorization: Bearer `, a token issued with your account separately from the upload token. Bodies are JSON, at most 64 KB. | Method and path | What it does | | --- | --- | | `GET /v1/providers` | The portals you can register, each with its `credential_fields` (`username` and `password` today) | | `POST /v1/connections` | Registers a login: `201` for a new connection, `200` with the existing one if you registered this login before | | `GET /v1/connections` | Every connection on your account, with its status, as a connections array. Each connection's accounts come from its /accounts endpoint | | `GET /v1/connections/{connection_id}` | The connection and its status | | `PUT /v1/connections/{connection_id}/credentials` | Replaces the password; the next run re-verifies the login | | `POST /v1/connections/{connection_id}/runs` | Runs the connection now (see [Triggering a run](#triggering-a-run)) | | `DELETE /v1/connections/{connection_id}` | Deletes the connection and destroys its stored login: `204` | | `GET /v1/connections/{connection_id}/accounts` | The connection and every account behind it | | `POST /v1/accounts/{account_id}/activate` | Starts retrieving an account | | `POST /v1/accounts/{account_id}/deactivate` | Stops retrieving it. Statements already sent are unaffected | | `PUT /v1/expected-accounts` | Account numbers, for one provider, to activate automatically when a run finds them | ### Registering a connection `provider` is a name from `GET /v1/providers`. `client_reference` is your own label, up to 255 characters, returned on the connection. ```json { "provider": "Example Electric Cooperative", "credentials": { "username": "billing@yourco.example", "password": "..." }, "client_reference": "site-114" } ``` The answer is the connection. API responses leave out any field that has no value yet, such as `last_success_at` before the first run. ```json { "connection_id": "con_8d62c46f-d186-427c-9610-2711def78c57", "provider": "Example Electric Cooperative", "client_reference": "site-114", "status": "pending_verification", "customer_action_required": false, "created_at": "2026-10-13T19:54:44Z", "mfa": { "delivery": "email", "address": "mfa+example@luminetric.com" }, "created": true } ``` Registering the same login again (same portal, same username, compared ignoring case) returns the existing connection with `200` and stores the password you sent. It is a way to update a password when you do not have the `connection_id` to hand. To change the username, register a new connection: `PUT .../credentials` refuses a different username. ### Connection status `customer_action_required` on the connection is true exactly when its `status` waits on you. `last_failure_reason` names the last failed run's reason code (see [`retrieval.failed`](https://luminetric.com/docs/events.md#retrievalfailed)). | `status` | Meaning | What to do | | --- | --- | --- | | `pending_verification` | Registered, or credentials replaced, and not yet verified by a run | Nothing; the next run verifies it | | `active` | Signed in on the last run that reached the portal | Nothing | | `credentials_failed` | The portal rejected the username or password | Send the correct password with `PUT .../credentials` | | `mfa_failed` | The portal asked for a verification code that did not reach us | Check that the code goes to `mfa.address`, then send the credentials again | | `client_assistance_required` | The portal needs the account holder to act, for example to accept new terms | Do that on the portal, then send the credentials again | | `disabled` | We have stopped running this connection | Contact us | While a connection waits on you, we do not run it. Sending the credentials again with PUT .../credentials, even unchanged, returns it to `pending_verification` and the next run verifies it. ### Triggering a run `POST /v1/connections/{connection_id}/runs` runs a connection now, outside the schedule. The body is optional. Send nothing, or `{"mode": "collect"}`, for a regular run. Send `{"mode": "discover"}` for a **discovery run**, which lists every account behind the login and downloads nothing; use it to find accounts added since the connection's first run. ```json { "request_id": "run_6e6b4590-8c6a-4b3e-bbaf-7b05ab2c588f", "connection_id": "con_8d62c46f-d186-427c-9610-2711def78c57", "mode": "collect", "status": "queued", "created": true } ``` - `202` with `"created": true`: a run was queued. Its `request_id` is `run_` plus a UUID for a regular run and `dsc_` plus a UUID for a discovery run you asked for, and the run's `retrieval.completed` or `retrieval.failed` carries it. It starts as soon as capacity allows. - `200` with `"created": false`: a run is already pending, and this is that run, whatever mode you asked for. `mode` says which it is, and `status` is `queued`, or `in_progress` once it has started. - `400`: the connection waits on you (see [Connection status](#connection-status)) or is disabled, or `mode` is not `collect` or `discover`. The error says what to do. - `429`: you triggered a run of this connection, of either mode, less than 60 minutes ago. The error says how many minutes to wait. Scheduled runs do not count toward this. A regular run is the same as a scheduled one: it looks for bills from the last 62 days, and a bill already delivered is not delivered again. On a connection that has never run, any run is the discovery run of step 2: `mode` says `discover`, and the `request_id` keeps the prefix of the mode you asked for. ### The schedule Once your account is live, we run each connection without being asked: - **at once** after it is registered, after its credentials are replaced, and after you activate an account (within half an hour); - **daily** from 3 days before an active account's next expected bill until that bill arrives or is 20 days late; - **weekly** otherwise, to find accounts that appeared or disappeared. Apart from those first cases, a scheduled run happens at most once a day. No run starts alongside another run of the same connection, and never while the connection waits on you. ### Verification codes Where a portal sends one-time codes by email, have them reach the address in the connection's `mfa.address`, either by setting the login's email to it or with a forwarding rule. The address is yours alone. Codes by text message or authenticator app are not currently supported, but can be based on demand. ### Security - Credentials are encrypted per connection with a key held in a key-management service, never stored beside the data. They are decrypted only at the moment we sign in, and never written to a log or returned by the API. - Deleting a connection destroys its stored credentials. - You confirm, in your agreement with us, that you hold each account holder's consent to store their login and retrieve their bills. ### Responses and errors Errors have the upload endpoint's shape: `{"status": "error", "error": "..."}`. | Status | Meaning | | --- | --- | | `200` / `201` / `202` | Done. `201`: a connection was created. `202`: a run was queued | | `204` | Deleted | | `400` | Refused as sent. `error` says why: a malformed body, a portal not enabled for you, a different username on a credentials update, a connection waiting on you, an unknown run mode | | `401` | No valid bearer token | | `404` | No such connection, account or endpoint. Another client's ids answer the same way | | `405` | That method is not allowed on that path | | `413` | The body is over 64 KB | | `429` | A run was triggered too recently | | `500` / `503` | Our fault or temporarily unavailable; retrying is safe | ## Accounts Every account carries two separate fields. `collection` is **yours**: whether we retrieve it. `status` and `status_detail` are **ours**: what the portal showed on the last completed run. "You turned this off" and "this broke" are never the same field. | `collection` | Meaning | Bills delivered | | --- | --- | --- | | `never_collected` | Seen behind the login, never activated | No | | `active` | You activated it, it matched an expected account number, or your account is set to activate new accounts automatically | Yes | | `paused` | You deactivated it | No | | `status` / `status_detail` | Meaning | | --- | --- | | `connection_success` / `data_accessible` | Listed by the portal on the last completed run | | `connection_error` / `not_listed_by_portal` | Listed before, absent on the last completed run | `GET /v1/connections/{connection_id}/accounts` returns the connection with an `accounts` array. Each account has `account_id`, `connection_id`, `account_number`, `account_number_as_printed`, `service_address`, `collection`, `status`, `status_detail`, `customer_action_required` (false today: an account never waits on you by itself), `first_seen` and `last_statement_date`, and an active account adds `next_expected_statement_date`. ```json { "account_id": "acc_bf1318e1-8ea7-48ac-9828-647371c76c79", "connection_id": "con_8d62c46f-d186-427c-9610-2711def78c57", "account_number": "1000000101", "account_number_as_printed": "1000-0001-01", "service_address": "100 Example Street, Springfield", "collection": "active", "status": "connection_success", "status_detail": "data_accessible", "customer_action_required": false, "first_seen": "2026-10-13", "last_statement_date": "2026-09-28", "next_expected_statement_date": "2026-10-28" } ``` **Account numbers.** `account_number` is the matching form: letters and digits only, upper case, the same as a statement's `normalized`, so `1000-0001-01` and `1000000101` are one account. `account_number_as_printed` is the portal's own text. Some portals list an account under a different form than its bills print (a location code, a composite, an extra prefix). The statement then carries the bill's own number, and `account_id` links the two. **Use `account_id`, not the number, to tie a statement to the account you activated.** The child accounts of a summary bill follow their summary: activating the summary delivers its children's statements too. **Expected accounts.** `PUT /v1/expected-accounts` takes `{"provider": "...", "account_numbers": ["..."]}` and answers with `provider` and `expected_count`, the number of numbers newly added. It replaces the numbers not yet found for that provider, across all your connections to it; a number already matched to an account stays matched and is not counted again. A number that is already a known `never_collected` account activates at once, and any other activates when a run finds it. A `paused` account is never reactivated by this list. **Missing accounts** are the report most worth acting on. An account the portal stops listing usually means it was closed, moved to another login, or the portal changed. It turns `connection_error` / `not_listed_by_portal` on the run that first notices, appears in that event's `missing_accounts`, and returns to `connection_success` by itself if it reappears. Its `collection` never changes on its own. **Portals read selectively.** On some portals a regular run reads only the accounts you collect, which keeps large logins fast. There, an account you do not collect is neither reported missing nor discovered by a regular run. A discovery run lists them all: the first run of every connection is one, and you can ask for another at any time with `{"mode": "discover"}`. **Shortfalls.** Portals sometimes list only part of a login's accounts. A run that sees fewer than 80% of the accounts listed before, once a login has five or more, is treated as a partial run: nothing is marked missing, the event's `enumeration` block shows the counts, and the run is retried. ## Receiving results Everything we send goes to one HTTPS URL you register, as a `POST` with a JSON body, signed with a secret issued to you alone. We deliver only to `https://` URLs. | Header | Value | | --- | --- | | `Luminetric-Event` | What this is: `statement.available`, `statement.withdrawn`, `request.failed`, `request.duplicate`, `retrieval.completed` or `retrieval.failed` | | `Luminetric-Signature` | `t=,v1=`, described below | | `Luminetric-Delivery-Id` | A key unique to this event: the statement's `version_id`, or the event's `failure_id`, `withdrawal_id`, `duplicate_id` or `event_id` | | `Luminetric-Statement-Id` | The `statement_id`, on statement and withdrawal events | | `Luminetric-Schema-Version` | The contract version of the body, for example `1.0.0` | ### Verifying the signature `v1` is the hex HMAC-SHA256, keyed with your secret, of the timestamp, a period and the raw request body, exactly as received: ``` signed_payload = t + "." + raw_body expected = hex(HMAC_SHA256(secret, signed_payload)) ``` The key is your secret exactly as issued, as UTF-8 bytes. Compare `expected` with `v1` in constant time, and reject a request whose `t` is more than five minutes from your clock. The timestamp is inside the signed string, so an old signature cannot be replayed under a new time. Verify against the bytes you received, before parsing: re-serializing the JSON changes them. ```python import hmac, hashlib, time def verify(secret: bytes, header: str, raw_body: bytes, tolerance=300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t, sig = parts["t"], parts["v1"] if abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig) ``` ### Responding and retries Return any `2xx` once you have stored the event. Do the processing afterward. - A connection failure, a timeout (30 seconds) or a `5xx` is retried, up to five attempts in all, with the wait doubling from 2 seconds. - A `4xx` is not retried: it tells us the request itself is wrong, and sending the same bytes again cannot change that. We investigate and re-send. - An event that is still undelivered is kept and re-sent once the problem is resolved, under its original key. Nothing is lost by an outage on your side. For a retrieval run, only its latest event is re-sent, since that is the run's outcome. Delivery is **at least once**, so you may receive an event more than once. De-duplicate on `Luminetric-Delivery-Id`. Return `2xx` for an event type you do not recognize, and ignore it. A summary bill is delivered parent first, then its children. If a delivery fails partway, you hold the parent and some children, and the parent's `children.expected_count` shows that more are owed. ## Events Every request you send is answered by at least one of the first four events, and every retrieval run by one of the last two. All of them carry `schema_version` and `request_id`. The request events also carry `client_reference` when you sent one; the retrieval events carry `connection_id`. | Event | When | Key it by | What to do | | --- | --- | --- | --- | | `statement.available` | A statement was extracted, or a new version of one | `version_id` | Store it under `statement_id`, replacing any earlier version | | `statement.withdrawn` | A re-extraction no longer contains a statement you were sent | `withdrawal_id` | Stop using that `statement_id`. It is never sent again | | `request.failed` | The request produced no statements | `failure_id` | Act on `reason.customer_action_required` | | `request.duplicate` | The file was already sent under another request | `duplicate_id` | Follow the earlier request named in `duplicate_of` | | `retrieval.completed` | A run reached the portal and listed its accounts | `event_id` | Update your accounts; act on `new_accounts` and `missing_accounts` | | `retrieval.failed` | A run did not complete | `event_id` | Act on `reason.customer_action_required` | ### request.failed ```json { "schema_version": "1.0.0", "event": "request.failed", "failure_id": "flr_6c2e9a17-4b83-4f05-9d61-2a7e0c5b8f39", "request_id": "req_0a4d6e82-9c37-4b15-a8f2-3e7b1d5c9042", "client_reference": "invoice-433193", "failed_at": "2026-09-24T03:41:12Z", "reason": { "code": "not_a_pdf", "message": "The document received is not a PDF. Resubmit the bill as a PDF.", "customer_action_required": true } } ``` | `reason.code` | `customer_action_required` | Meaning | | --- | --- | --- | | `not_a_pdf` | true | The file is not a PDF. Resubmit the bill as a PDF | | `unreadable_pdf` | true | The PDF is damaged or cannot be opened. Resubmit a readable copy | | `extraction_failed` | false | We could not extract the bill completely. It is held for review, not sent incomplete | | `validation_failed` | false | The extraction failed our checks and was not sent | | `internal_error` | false | A fault on our side | A failure is **not final**. When `customer_action_required` is false the problem is ours: we fix it and re-run the request, and its statements then arrive under the same `request_id`, after the failure. Codes may be added, so treat an unknown code by its `customer_action_required` flag. ### statement.withdrawn Statement ids follow a bill's content across re-extractions. If a re-extraction no longer contains a statement, for example because a summary bill was previously split one child too many, the statement is withdrawn. You are told only about statements you were actually sent, once each, after the request's current statements. ```json { "schema_version": "1.0.0", "event": "statement.withdrawn", "withdrawal_id": "wdr_3a7e1c95-2d48-4f06-b913-8c5e0a7d4f21", "statement_id": "stt_b5e29c74-1f83-4a06-8d51-7e0c3b9d4a26", "last_version_id": "ver_7f03d8a1-6b29-4c54-9e18-0d5a2f7c3b96", "request_id": "req_4c8e2a17-6b93-4d05-8f21-7e0a3c9d5b68", "client_reference": "invoice-604882", "withdrawn_at": "2026-09-29T21:14:05Z", "reason": { "code": "not_in_reextraction", "message": "A re-extraction of this request no longer contains this statement. Stop using it; the request's current statements have been sent." } } ``` `last_version_id` is the last version you received. If the same bill reappears later, it arrives under a new `statement_id`. ### request.duplicate ```json { "schema_version": "1.0.0", "event": "request.duplicate", "duplicate_id": "dup_6e2b9f14-8c37-4a50-b1d2-5f09c3e7a846", "request_id": "req_9d15a3e8-4f27-4b61-8c09-2e7d5b1f6a43", "client_reference": "invoice-433193-resend", "duplicate_of": { "request_id": "req_0a4d6e82-9c37-4b15-a8f2-3e7b1d5c9042", "client_reference": "invoice-433193" }, "document_id": "doc_b72e4f18-6a93-4d51-8027-3e9c1b5d8a64", "detected_at": "2026-09-30T18:02:44Z", "message": "This request carries a file you already sent. It was not extracted again: its statements are delivered under the earlier request named in duplicate_of." } ``` It is sent whether the earlier request has finished, is still running or failed. Follow that request's own events. ### retrieval.completed The run reached the portal and listed the login's accounts. A run that finds no new bill still completes; that is the normal case on most days of a month. ```json { "schema_version": "1.0.0", "event": "retrieval.completed", "event_id": "rtv_3f1c7a52-8d4e-4b9a-a6f0-2c9e81d4b7aa", "request_id": "run_437971a5-fd17-41d6-b2ba-bd06485099aa", "provider": "Example Electric Cooperative", "completed_at": "2026-10-09T04:12:33Z", "dry_run": false, "invoices_found": 2, "invoices_downloaded": 1, "accounts": [ { "account_number": "1000000101", "account_number_as_printed": "1000-0001-01", "service_address": "100 Example Street, Springfield", "most_recent_statement_date": "2026-09-28", "most_recent_total_due": "$142.17", "account_id": "acc_1a2b3c4d-0000-4000-8000-000000000001", "collection": "active", "status": "connection_success", "status_detail": "data_accessible" }, { "account_number": "1000000201", "account_number_as_printed": "1000-0002-01", "service_address": "200 Example Avenue, Springfield", "most_recent_statement_date": "2026-09-30", "most_recent_total_due": "$87.40", "account_id": "acc_1a2b3c4d-0000-4000-8000-000000000002", "collection": "never_collected", "status": "connection_success", "status_detail": "data_accessible" } ], "connection_id": "con_8e4b1d2a-6f73-4c09-b5e1-9d0a7c3f2e64", "new_accounts": [ { "account_id": "acc_1a2b3c4d-0000-4000-8000-000000000002", "account_number": "1000000201", "account_number_as_printed": "1000-0002-01", "collection": "never_collected", "status": "connection_success", "status_detail": "data_accessible" } ], "missing_accounts": [ { "account_id": "acc_1a2b3c4d-0000-4000-8000-000000000003", "account_number": "1000000301", "account_number_as_printed": "1000-0003-01", "collection": "active", "status": "connection_error", "status_detail": "not_listed_by_portal" } ], "enumeration": { "expected_account_count": 3, "enumerated_account_count": 2, "shortfall": false } } ``` - `request_id` is the run's: `run_` plus a UUID for a regular run you triggered, `dsc_` plus a UUID for a discovery run you asked for (a connection's first run is a discovery run whatever you asked for, and keeps the `run_` prefix if you asked for a regular one), `sch_` plus a UUID for a scheduled one. - One run can send more than one event. A run that could not read the portal (`portal_unavailable`, `internal_error`) or saw too few accounts is retried under the same `request_id`, and each attempt sends its own event with a new `event_id`. The latest is the run's outcome. - `dry_run` is true for the discovery run, which lists accounts and downloads nothing. - `accounts` is every account this run listed. `new_accounts` are those seen for the first time; `missing_accounts` are those listed before and absent now. `enumeration.shortfall` true means the run saw too few accounts to trust, and nothing was marked missing. `enumeration` is absent when the run listed no accounts at all. - `invoices_found` counts what the portal listed in the window, and `invoices_downloaded` what we downloaded. Bills for accounts you do not collect are held back before delivery. - `most_recent_total_due` is the portal's own text, not a parsed amount; the statement carries the amount as a value. ### retrieval.failed The run did not complete. `reason` has the same shape as on `request.failed`. ```json { "schema_version": "1.0.0", "event": "retrieval.failed", "event_id": "rtv_9c4e2b71-3a5d-4f86-8e10-b7d2c6a1f053", "request_id": "sch_5c0e2d71-9b84-4a36-8f15-d27e6a0c3b91", "provider": "Example Electric Cooperative", "failed_at": "2026-10-09T04:12:33Z", "reason": { "code": "credentials_failed", "message": "The portal rejected the username or password.", "customer_action_required": true }, "dry_run": false, "invoices_found": 0, "invoices_downloaded": 0, "accounts": [], "connection_id": "con_8e4b1d2a-6f73-4c09-b5e1-9d0a7c3f2e64", "new_accounts": [], "missing_accounts": [] } ``` | `reason.code` | `customer_action_required` | What to do | | --- | --- | --- | | `credentials_failed` | true | Send the correct password (`PUT .../credentials`). We do not run the connection until you do | | `mfa_failed` | true | Make sure the portal's codes reach the connection's `mfa.address`, then send the credentials again | | `client_assistance_required` | true | The portal wants the account holder to act (accept terms, confirm details). Do that on the portal, then send the credentials again | | `provider_not_enabled` | true | This portal is not enabled for your account, so the run was not attempted. Contact us | | `portal_unavailable` | false | The portal was down or showed no billing data. Nothing to do; we retry | | `internal_error` | false | Ours. Nothing to do; we retry | As with `request.failed`, codes may be added. Treat an unknown code by its `customer_action_required` flag. ### Statements from a connection A retrieved bill arrives as an ordinary `statement.available`, before or after its run's event, under a `request_id` of its own (one per bill, not the run's). It also carries `connection_id`, and `account_id` for the account we retrieved it for, even where the bill prints its account number differently from the portal. A bill we cannot match to one of the connection's accounts is delivered with `connection_id` and no `account_id`. A bill for an account you have not activated, or have deactivated, is not delivered. ## The statement payload A `statement.available` body is one statement: an envelope of identifiers, the statement's own fields, and its meters, sections and charges. Charges hang off sections, and a section or charge may point at a meter. ### Envelope | Field | Type | Notes | | --- | --- | --- | | `schema_version` | string | Contract version, for example `1.0.0` | | `statement_id` | string | `stt_…`. Stable across re-extractions of the same bill | | `version_id` | string | `ver_…`. One extraction of this statement | | `is_latest` | boolean | False once a newer version exists | | `superseded_by` | string or null | The `version_id` that replaced this one | | `parent_statement_id` | string | On a child of a summary bill only: the parent's `statement_id` | | `request_id` | string | The request that delivered this version | | `client_reference` | string | Echoed from your request, when you sent one | | `document_id` | string | `doc_…`. The same for every statement from one file, and stable across re-extractions | | `extracted_at` | string | UTC timestamp, ISO 8601 | | `statement` | object | The statement's own fields, below | | `meters`, `sections`, `charges` | arrays | Below | | `children` | object | On a summary parent only. See [Summary bills](https://luminetric.com/docs/summary-bills.md) | | `reconciliation` | object | The arithmetic, checked. See below | | `confidence` | object | Checkable signals and a tier. See below | | `issuer` | null | Reserved; currently always null | `connection_id` and `account_id` are sent on statements retrieved through a connection (see [Connecting a portal](https://luminetric.com/docs/connecting-portals.md)), and are absent on uploaded bills. `provider` appears in some samples but is **not yet sent**; it will be added in a minor version, so do not require it. ### Values carry their printed form and their source Money, quantities, dates and identifiers are objects, not bare values: ```json "amount_due": { "amount": 55.7, "as_printed": "55.70", "source": "extracted" } "usage": { "value": 3, "unit": "thousand gallons", "as_printed": "3", "source": "extracted" } "due_date": { "value": "2026-09-09", "as_printed": "9/9/2026", "source": "extracted" } ``` Amounts are numbers in the bill's `currency`, credits negative. Dates are `YYYY-MM-DD`. `as_printed` is the text on the bill, so you can always check a value against the document. | `source` | Meaning | | --- | --- | | `extracted` | Read directly from the bill | | `derived` | Calculated from other printed values, for example a period start from the end date and the day count | | `inferred` | Not printed; concluded from context | | `defaulted` | Not on the bill at all; a stated default, such as a total of 0.00 for a bill that prints no late fee | ### statement `statement_date`, `due_date`, `period_start`, `period_end` and `account_number` are value objects. `totals` holds `amount_due`, `current_charges`, `previous_balance`, `balance_forward`, `late_fee`, `payments`, `credits` and `adjustments` as money objects, each present when the bill prints or implies it. Also `currency`, `service_address`, `is_summary`, and `location_id` when the bill prints an identifier for the service location separate from the account number. `statement_date` and `totals` are always present. Identifiers carry one more field. `account_number`, `location_id` and each `meters[].number` add `normalized`: the value's letters and digits only, in upper case. Luminetric computes it from `value`; the model never writes it. The same printed number always gives the same `normalized`, however the bill punctuates it, so `10-00001-00001` and `10 00001 00001` both give `100000100001`. Leading zeros are kept. ```json "account_number": { "value": "10-00001-00001", "as_printed": "10-00001-00001", "source": "extracted", "normalized": "100000100001" } ``` Key accounts, locations and meters on `normalized`, and show `value` or `as_printed` to people. Where a provider issues one account for several locations, the account and location together identify a place: key on both `normalized` values. `normalized` is absent only when the identifier itself is. ### meters, sections and charges - **meters\[\]** — `meter_id` (local to this statement), `number` (the meter number as printed), `service`, `service_address`, `previous_read` and `current_read`, `multiplier`, `read_start` and `read_end` dates, and `usage` as a quantity object with its unit. - **sections\[\]** — each block of charges as the bill prints it: `section_id`, `heading_as_printed`, `service`, `service_source` (how the service was decided), `ordinal`, optional `meter_id`, and `printed_total` when the bill prints a subtotal. - **charges\[\]** — each line: `name_as_printed`, `section_id`, `service`, `kind`, `amount`, `contributes_to_total` and `service_source`, plus `quantity`, `rate`, `meter_id`, `period_start` and `period_end` when the bill gives them. `contributes_to_total` is true for charges that make up `current_charges`. Payments, prior balances and printed subtotals are false, so summing the true rows reproduces the bill's current charges. | Enum | Values | | --- | --- | | `service` | `electric`, `gas`, `water`, `sewer`, `irrigation`, `stormwater`, `drainage`, `trash`, `fire`, `steam`, `chilled_water`, `internet`, `phone`, `tv`, `none`, `other`, `unknown` | | `kind` | `supply`, `delivery`, `tax`, `fee`, `credit`, `adjustment`, `subtotal`, `other`, `unknown` | | `charges[].service_source` | `section`, `item_text`, `meter`, `summary_row`, `single_service_bill`, `convention`, `inferred`, `none` | | `sections[].service_source` | `heading`, `summary_row`, `item_text`, `meter`, `single_service_bill`, `inferred`, `none` | `other` means classified but not in the list. `unknown` means the bill gave nothing to classify it from, and is never a guess. `none` on `service` marks a line that belongs to no service, such as a payment or a bill-level late fee. ### reconciliation We check the arithmetic and report it. We never adjust a value to make it balance. ```json "reconciliation": { "identity": "sum(charges where contributes_to_total) == statement.totals.current_charges", "current_charges": 85.7, "sum_of_charges": 85.7, "delta": 0.0, "balanced": true, "total_due": 55.7, "total_due_identity": "previous_balance + payments + adjustments + current_charges", "total_due_delta": 30.0, "total_due_balanced": false } ``` `balanced` concerns the current charges and is the one to rely on. `total_due_balanced` is often false on a correct bill, because many bills print the balance forward differently, as in this example where a $30.00 credit is carried outside the printed identity. ### confidence ```json "confidence": { "tier": "deliver", "reconciles": true, "key_totals_extracted": true, "unknown_service_charge_count": 0, "signals_failed": [] } ``` Three signals, each one you can recompute from the payload: the charges reconcile, `amount_due` and `current_charges` were both read from the bill rather than derived, and no charge has an `unknown` service. `tier` is `deliver` when all three hold, and `review` when any fails, with the failures named in `signals_failed`. `review` does not mean wrong. It means one of the checks could not confirm the statement, and it tells you which. A sensible policy is to load `deliver` statements automatically and route `review` statements to a person, or to automatic use only where the failed signal does not matter to you. Treat an unrecognized tier as `review`. ## Statement identity and versions Store statements under `statement_id` and keep the latest `version_id`. Those two ids are what let you take corrections without creating duplicates. - **A re-extraction keeps the ids.** If we extract a bill again, for example after improving how a provider's layout is read, each statement keeps its `statement_id` and arrives as a new version. Statements are matched on their content (account, address, period, meters, services and total), not on their position in the bill. The new version has `is_latest: true`, and an older one you hold is superseded. - **The same bill in a later request keeps the ids too.** If you send a bill again in a new request, and it is not byte-for-byte the file you sent before, we recognize it by account number, statement date and amount due. Its statements keep their ids and arrive as new versions under the new `request_id`. This never matches across clients. - **A statement that can no longer be matched gets a new id**, never a neighbor's. If a re-extraction drops a statement you were sent, you receive `statement.withdrawn` for it. - **One request can produce several statements**, and one statement can arrive under several requests. `request_id` tells you which request delivered a version; it is not the statement's identity. A minimal consumer: ``` on statement.available: if version_id already stored: return 2xx # a repeat delivery upsert statements[statement_id] = payload # unless the stored one has a later extracted_at on statement.withdrawn: mark statements[statement_id] withdrawn ``` Do not rely on arrival order: a delivery retried after an outage on your side can arrive after a newer one. When two versions of one statement disagree about which is newer, keep the one with the later `extracted_at`. ## Summary bills and multi-bill files ### Summary bills A summary bill covers many service points under one invoice. It is delivered as one **parent** statement followed by one **child** statement per metered service point. Each child carries `parent_statement_id`. The parent has `statement.is_summary: true` and a `children` block listing what it expects: ```json "children": { "expected_count": 2, "source": "derived", "delivered": [ { "statement_id": "stt_b5e29c74-…", "account_number": "10-00001-00001", "service_address": "100 Example Ave, Springfield ST 00000", "meter_numbers": [], "current_charges": 2144.24 }, { "statement_id": "stt_3f8c0a15-…", "account_number": "10-00002-00001", "service_address": "100 Example Ave, Springfield ST 00000", "meter_numbers": [], "current_charges": 576.68 } ] } ``` Three rules matter when you consume one. 1. **The parent can carry charges of its own.** Invoice-level charges, such as an account fee, appear only on the parent. The parent's current charges are its own charges plus its children's: in the sample above, $8.50 + $2,144.24 + $576.68 = $2,729.42. Summing only the children comes up short by the parent's own charges. The parent's `reconciliation.identity` states the formula it was checked against. 2. **Match children to your records by meter.** Several children can share an account number and an address. `meter_numbers` is what tells them apart; it is empty for an unmetered child, such as a waste service. 3. **A parent may balance only with its adjustments.** Some bills print adjustments apart from the new charges and itemize them on one child. Then `reconciliation.children_include_adjustments` names the amount, and `balanced` is still true. The key appears only when it was needed. We deliver a summary bill only when it is complete: every child the bill prints is present and the arithmetic holds. An incomplete extraction is held for review and answered with `request.failed` (`extraction_failed`), never sent in part. If a delivery fails partway through, compare `expected_count` with the children you hold to see what is still owed. ### Several bills in one file If a PDF holds several unrelated bills, each is delivered as its own statement: several `statement.available` events sharing one `request_id`, `client_reference` and `document_id`, none with a parent. The request is delivered only if every bill in it balances; otherwise none of it is sent and the request fails. If you can send one bill per file, do. ## Compatibility and versioning Every payload carries `schema_version`, a semantic version. Within a major version, we only ever add. | Change | Version | Examples | | --- | --- | --- | | Breaking | major | A field removed or renamed, a type changed, a definition changed | | Additive | minor | A new field, a new enum value, a new event type, a new failure code | | Neither | patch | Clearer documentation; better extraction that keeps every field's meaning | **Within a major version we promise:** 1. No field is removed or renamed. A field we plan to retire is announced as deprecated and keeps being sent until the next major version. 2. No field changes its JSON type. 3. No field changes its meaning. Definitions are part of the contract: what `current_charges` includes, which charges have `contributes_to_total`, and that a child statement is one metered service point. 4. No enum value is removed or repurposed. 5. A statement keeps its `statement_id` when its bill is re-extracted or sent again. 6. A field that is always present stays always present. A value that was wrong becoming right is not a change of meaning. It arrives as a new version of the statement. When an improvement moves values systematically, for example correcting a unit for one utility, the changelog says so. **A new major version runs beside the old one.** You move when you choose, and the old version keeps being produced for at least six months after the new one is available to you. We retire an old version only after at least 90 days' written notice, and we never produce more than two major versions at once. Your agreement may give you a longer overlap, but never a shorter one. **What you need to do** so that every minor version is safe to receive without code changes: - Ignore fields you do not recognize, at every level, including inside arrays. - Treat an unrecognized enum value as its catch-all: `service` and `kind` as `other`, `confidence.tier` as `review`, an unknown entry in `signals_failed` as a failed signal, and an unknown `reason.code` by its `customer_action_required` flag. - Return `2xx` for an event type you do not recognize, and ignore it. - Treat an absent field and `null` alike. - Do not depend on key order, or on the order of `charges`, `sections`, `meters` or `children.delivered`. - De-duplicate on the delivery id, and key what you store on `statement_id`. Every change is listed, newest first, in the [changelog](https://luminetric.com/docs/changelog.md). ## Sample payloads Download the sample payloads below, or [all of them as a zip](https://luminetric.com/docs/samples/luminetric-samples-1.0.0.zip). Each statement sample is real output from a real bill, with account numbers, names and addresses replaced by placeholders. Start with the first. | Sample | Event or call | What it shows | | --- | --- | --- | | [`01-input-connection-create.json`](https://luminetric.com/docs/samples/01-input-connection-create.json) | `POST /v1/connections` | Registering a login | | [`01b-output-connection.json`](https://luminetric.com/docs/samples/01b-output-connection.json) | `POST /v1/connections` answer | A new connection, before its first run | | [`02-output-run-requested.json`](https://luminetric.com/docs/samples/02-output-run-requested.json) | `POST /v1/connections/{connection_id}/runs` answer | A run you triggered, queued | | [`03-input-upload-request.json`](https://luminetric.com/docs/samples/03-input-upload-request.json) | `POST /v1/statements` | Uploading a bill by reference | | [`04-output-accounts-discovered.json`](https://luminetric.com/docs/samples/04-output-accounts-discovered.json) | `GET /v1/connections/{connection_id}/accounts` | Three accounts after two runs: one active, one never collected, one active and no longer listed | | [`05-output-statement.json`](https://luminetric.com/docs/samples/05-output-statement.json) | `statement.available` | A multi-service municipal bill with two separate water blocks, and charges attributed from their own printed words | | [`06-output-statement-flagged-for-review.json`](https://luminetric.com/docs/samples/06-output-statement-flagged-for-review.json) | `statement.available` | A statement that balances and is still `review`, because `current_charges` was derived rather than read | | [`07-output-statement-summary-parent.json`](https://luminetric.com/docs/samples/07-output-statement-summary-parent.json) | `statement.available` | A summary parent with charges of its own, and its `children` block | | [`07b-output-statement-summary-child.json`](https://luminetric.com/docs/samples/07b-output-statement-summary-child.json) | `statement.available` | One child of that parent | | [`09-output-statement-bundle-member.json`](https://luminetric.com/docs/samples/09-output-statement-bundle-member.json) | `statement.available` | One of seven unrelated bills uploaded in a single scanned PDF | | [`10-output-statement-summary-parent-adjusted.json`](https://luminetric.com/docs/samples/10-output-statement-summary-parent-adjusted.json) | `statement.available` | A 49-child parent that balances with its adjustments | | [`11-output-statement-summary-parent-per-meter.json`](https://luminetric.com/docs/samples/11-output-statement-summary-parent-per-meter.json) | `statement.available` | Twelve children, seven sharing one account and address, told apart by `meter_numbers` | | [`08-output-request-failed.json`](https://luminetric.com/docs/samples/08-output-request-failed.json) | `request.failed` | A request that produced no statements | | [`12-output-statement-withdrawn.json`](https://luminetric.com/docs/samples/12-output-statement-withdrawn.json) | `statement.withdrawn` | A statement a re-extraction no longer contains | | [`13-output-request-duplicate.json`](https://luminetric.com/docs/samples/13-output-request-duplicate.json) | `request.duplicate` | A file sent a second time | | [`14-output-retrieval-completed.json`](https://luminetric.com/docs/samples/14-output-retrieval-completed.json) | `retrieval.completed` | A run that listed two accounts, one of them new, and noticed one missing | | [`15-output-retrieval-failed.json`](https://luminetric.com/docs/samples/15-output-retrieval-failed.json) | `retrieval.failed` | A scheduled run the portal refused the password for | Samples `01` to `04` are the connection API's request and response bodies, not webhook payloads. On statements, `connection_id` and `account_id` appear only for retrieved bills, and `provider` is not yet sent (see [The statement payload](https://luminetric.com/docs/statement-payload.md#envelope)). Fields whose names begin with `_` are explanatory notes for readers and never appear in real payloads. ## JSON Schemas We publish a JSON Schema (draft 2020-12) for each body we send, named after its `Luminetric-Event`: [`statement.available.schema.json`](https://luminetric.com/docs/schemas/statement.available.schema.json), [`request.failed.schema.json`](https://luminetric.com/docs/schemas/request.failed.schema.json), [`statement.withdrawn.schema.json`](https://luminetric.com/docs/schemas/statement.withdrawn.schema.json), [`request.duplicate.schema.json`](https://luminetric.com/docs/schemas/request.duplicate.schema.json), [`retrieval.completed.schema.json`](https://luminetric.com/docs/schemas/retrieval.completed.schema.json) and [`retrieval.failed.schema.json`](https://luminetric.com/docs/schemas/retrieval.failed.schema.json). Use them to generate types or to validate what reaches your webhook. They are open by design, so that validating against them never breaks when we add to a major version: - Fields the schema does not name are allowed. Ignore the ones you do not use. - Enumerated fields list the values we send today under `x-known-values`, not as a closed `enum`. A value not in that list is valid; handle it as [Compatibility and versioning](https://luminetric.com/docs/versioning.md) describes. - `schema_version` must match the schema's major version. A body from another major version fails validation, which is the signal to move to that version's schemas. Each delivery is checked against these schemas before we send it. ## Changelog Every change to the contract, newest first. Additions arrive as minor versions and never require a change on your side; see [Compatibility and versioning](https://luminetric.com/docs/versioning.md). | Version | Date | Change | | --- | --- | --- | | 1.0.0 | October 14, 2026 | First published version. Bill upload (`POST /v1/statements`), portal connections and retrieval runs, the events `statement.available`, `statement.withdrawn`, `request.failed`, `request.duplicate`, `retrieval.completed` and `retrieval.failed`, and a JSON Schema for each | ## Schema files The schema for each webhook body, named after its `Luminetric-Event` header. ### request.duplicate.schema.json ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:luminetric:canonical:1:request.duplicate", "$comment": "Generated by build_schemas.py. Open by design: accept fields and enum values you do not recognise (COMPATIBILITY.md).", "title": "request.duplicate", "description": "A request carrying a file you already sent. Follow the earlier request's events.", "type": "object", "properties": { "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$", "description": "Semantic version of this contract. This schema covers major version 1." }, "event": { "type": "string", "const": "request.duplicate" }, "duplicate_id": { "type": "string", "pattern": "^dup_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The delivery id." }, "request_id": { "type": "string", "minLength": 1, "description": "This (duplicate) request." }, "client_reference": { "type": "string", "minLength": 1, "maxLength": 255 }, "duplicate_of": { "type": "object", "properties": { "request_id": { "type": "string", "minLength": 1 }, "client_reference": { "type": "string", "minLength": 1, "maxLength": 255 } }, "required": [ "request_id" ] }, "document_id": { "type": "string", "pattern": "^doc_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The file, the same id as on the earlier request." }, "detected_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$", "description": "When the duplicate was detected. ISO 8601, UTC." }, "message": { "type": "string" } }, "required": [ "schema_version", "event", "duplicate_id", "request_id", "duplicate_of", "detected_at", "message" ] } ``` ### request.failed.schema.json ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:luminetric:canonical:1:request.failed", "$comment": "Generated by build_schemas.py. Open by design: accept fields and enum values you do not recognise (COMPATIBILITY.md).", "title": "request.failed", "description": "A request that produced no statement. Not terminal: a later statement with the same request_id supersedes it.", "type": "object", "properties": { "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$", "description": "Semantic version of this contract. This schema covers major version 1." }, "event": { "type": "string", "const": "request.failed" }, "failure_id": { "type": "string", "pattern": "^flr_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The delivery id." }, "request_id": { "type": "string", "minLength": 1 }, "client_reference": { "type": "string", "minLength": 1, "maxLength": 255 }, "failed_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$", "description": "When the request failed. ISO 8601, UTC." }, "reason": { "type": "object", "properties": { "code": { "type": "string", "description": "Why. Known values: not_a_pdf, unreadable_pdf, extraction_failed, validation_failed, internal_error. Values may be added in a minor version. Act on an unknown code by customer_action_required.", "x-known-values": [ "not_a_pdf", "unreadable_pdf", "extraction_failed", "validation_failed", "internal_error" ] }, "message": { "type": "string" }, "customer_action_required": { "type": "boolean" } }, "required": [ "code", "message", "customer_action_required" ] } }, "required": [ "schema_version", "event", "failure_id", "request_id", "failed_at", "reason" ] } ``` ### retrieval.completed.schema.json ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:luminetric:canonical:1:retrieval.completed", "$comment": "Generated by build_schemas.py. Open by design: accept fields and enum values you do not recognise (COMPATIBILITY.md).", "title": "retrieval.completed", "description": "A retrieval that reached the portal and listed its accounts. Statements follow as statement.available.", "type": "object", "properties": { "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$", "description": "Semantic version of this contract. This schema covers major version 1." }, "event": { "type": "string", "const": "retrieval.completed" }, "event_id": { "type": "string", "pattern": "^rtv_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The delivery id." }, "request_id": { "type": "string", "minLength": 1, "description": "The run: run_ plus a UUID for a run you triggered (POST /v1/connections/{connection_id}/runs), dsc_ plus a UUID for a discovery run you triggered, sch_ plus a UUID for a scheduled one. Statements the run downloads arrive under request ids of their own." }, "connection_id": { "type": "string", "pattern": "^con_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The connection it ran for, when it ran for one." }, "provider": { "type": [ "string", "null" ] }, "dry_run": { "type": "boolean", "description": "True for a discovery run: accounts listed, nothing downloaded." }, "invoices_found": { "type": "integer", "minimum": 0 }, "invoices_downloaded": { "type": "integer", "minimum": 0 }, "accounts": { "type": "array", "items": { "type": "object", "properties": { "account_number": { "type": [ "string", "null" ], "description": "Letters and digits only, upper case: the form to match on, as in the accounts API (sample 04) and statements' normalized." }, "account_number_as_printed": { "type": [ "string", "null" ], "description": "As the portal showed it." }, "account_id": { "type": "string", "pattern": "^acc_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Our id for the account. Present when the request ran for a connection." }, "collection": { "type": "string", "description": "Yours: whether we retrieve it. Never the same field as status. Known values: never_collected, active, paused. Values may be added in a minor version.", "x-known-values": [ "never_collected", "active", "paused" ] }, "status": { "type": "string", "description": "Ours: what the portal did. Known values: connection_success, connection_error. Values may be added in a minor version.", "x-known-values": [ "connection_success", "connection_error" ] }, "status_detail": { "type": "string", "description": "Ours: why. Known values: data_accessible, not_listed_by_portal. Values may be added in a minor version.", "x-known-values": [ "data_accessible", "not_listed_by_portal" ] }, "service_address": { "type": [ "string", "null" ] }, "most_recent_statement_date": { "type": [ "string", "null" ], "description": "As the portal showed it, ISO YYYY-MM-DD when the session could read it." }, "most_recent_total_due": { "type": [ "string", "null" ], "description": "The portal's own text; not parsed." } }, "required": [ "account_number" ] } }, "new_accounts": { "type": "array", "items": { "type": "object", "properties": { "account_number": { "type": [ "string", "null" ], "description": "Letters and digits only, upper case: the form to match on, as in the accounts API (sample 04) and statements' normalized." }, "account_number_as_printed": { "type": [ "string", "null" ], "description": "As the portal showed it." }, "account_id": { "type": "string", "pattern": "^acc_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Our id for the account. Present when the request ran for a connection." }, "collection": { "type": "string", "description": "Yours: whether we retrieve it. Never the same field as status. Known values: never_collected, active, paused. Values may be added in a minor version.", "x-known-values": [ "never_collected", "active", "paused" ] }, "status": { "type": "string", "description": "Ours: what the portal did. Known values: connection_success, connection_error. Values may be added in a minor version.", "x-known-values": [ "connection_success", "connection_error" ] }, "status_detail": { "type": "string", "description": "Ours: why. Known values: data_accessible, not_listed_by_portal. Values may be added in a minor version.", "x-known-values": [ "data_accessible", "not_listed_by_portal" ] } }, "required": [ "account_number" ] } }, "missing_accounts": { "type": "array", "items": { "type": "object", "properties": { "account_number": { "type": [ "string", "null" ], "description": "Letters and digits only, upper case: the form to match on, as in the accounts API (sample 04) and statements' normalized." }, "account_number_as_printed": { "type": [ "string", "null" ], "description": "As the portal showed it." }, "account_id": { "type": "string", "pattern": "^acc_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Our id for the account. Present when the request ran for a connection." }, "collection": { "type": "string", "description": "Yours: whether we retrieve it. Never the same field as status. Known values: never_collected, active, paused. Values may be added in a minor version.", "x-known-values": [ "never_collected", "active", "paused" ] }, "status": { "type": "string", "description": "Ours: what the portal did. Known values: connection_success, connection_error. Values may be added in a minor version.", "x-known-values": [ "connection_success", "connection_error" ] }, "status_detail": { "type": "string", "description": "Ours: why. Known values: data_accessible, not_listed_by_portal. Values may be added in a minor version.", "x-known-values": [ "data_accessible", "not_listed_by_portal" ] } }, "required": [ "account_number" ] } }, "enumeration": { "type": "object", "description": "How many of the accounts present before this run it listed. A shortfall marks nothing missing.", "properties": { "expected_account_count": { "type": "integer", "minimum": 0 }, "enumerated_account_count": { "type": "integer", "minimum": 0 }, "shortfall": { "type": "boolean" } }, "required": [ "expected_account_count", "enumerated_account_count", "shortfall" ] }, "completed_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$", "description": "When the retrieval completed. ISO 8601, UTC." } }, "required": [ "schema_version", "event", "event_id", "request_id", "dry_run", "invoices_found", "invoices_downloaded", "accounts", "completed_at" ] } ``` ### retrieval.failed.schema.json ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:luminetric:canonical:1:retrieval.failed", "$comment": "Generated by build_schemas.py. Open by design: accept fields and enum values you do not recognise (COMPATIBILITY.md).", "title": "retrieval.failed", "description": "A retrieval that did not complete. customer_action_required says whose move it is.", "type": "object", "properties": { "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$", "description": "Semantic version of this contract. This schema covers major version 1." }, "event": { "type": "string", "const": "retrieval.failed" }, "event_id": { "type": "string", "pattern": "^rtv_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The delivery id." }, "request_id": { "type": "string", "minLength": 1, "description": "The run: run_ plus a UUID for a run you triggered (POST /v1/connections/{connection_id}/runs), dsc_ plus a UUID for a discovery run you triggered, sch_ plus a UUID for a scheduled one. Statements the run downloads arrive under request ids of their own." }, "connection_id": { "type": "string", "pattern": "^con_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The connection it ran for, when it ran for one." }, "provider": { "type": [ "string", "null" ] }, "dry_run": { "type": "boolean", "description": "True for a discovery run: accounts listed, nothing downloaded." }, "invoices_found": { "type": "integer", "minimum": 0 }, "invoices_downloaded": { "type": "integer", "minimum": 0 }, "accounts": { "type": "array", "items": { "type": "object", "properties": { "account_number": { "type": [ "string", "null" ], "description": "Letters and digits only, upper case: the form to match on, as in the accounts API (sample 04) and statements' normalized." }, "account_number_as_printed": { "type": [ "string", "null" ], "description": "As the portal showed it." }, "account_id": { "type": "string", "pattern": "^acc_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Our id for the account. Present when the request ran for a connection." }, "collection": { "type": "string", "description": "Yours: whether we retrieve it. Never the same field as status. Known values: never_collected, active, paused. Values may be added in a minor version.", "x-known-values": [ "never_collected", "active", "paused" ] }, "status": { "type": "string", "description": "Ours: what the portal did. Known values: connection_success, connection_error. Values may be added in a minor version.", "x-known-values": [ "connection_success", "connection_error" ] }, "status_detail": { "type": "string", "description": "Ours: why. Known values: data_accessible, not_listed_by_portal. Values may be added in a minor version.", "x-known-values": [ "data_accessible", "not_listed_by_portal" ] }, "service_address": { "type": [ "string", "null" ] }, "most_recent_statement_date": { "type": [ "string", "null" ], "description": "As the portal showed it, ISO YYYY-MM-DD when the session could read it." }, "most_recent_total_due": { "type": [ "string", "null" ], "description": "The portal's own text; not parsed." } }, "required": [ "account_number" ] } }, "new_accounts": { "type": "array", "items": { "type": "object", "properties": { "account_number": { "type": [ "string", "null" ], "description": "Letters and digits only, upper case: the form to match on, as in the accounts API (sample 04) and statements' normalized." }, "account_number_as_printed": { "type": [ "string", "null" ], "description": "As the portal showed it." }, "account_id": { "type": "string", "pattern": "^acc_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Our id for the account. Present when the request ran for a connection." }, "collection": { "type": "string", "description": "Yours: whether we retrieve it. Never the same field as status. Known values: never_collected, active, paused. Values may be added in a minor version.", "x-known-values": [ "never_collected", "active", "paused" ] }, "status": { "type": "string", "description": "Ours: what the portal did. Known values: connection_success, connection_error. Values may be added in a minor version.", "x-known-values": [ "connection_success", "connection_error" ] }, "status_detail": { "type": "string", "description": "Ours: why. Known values: data_accessible, not_listed_by_portal. Values may be added in a minor version.", "x-known-values": [ "data_accessible", "not_listed_by_portal" ] } }, "required": [ "account_number" ] } }, "missing_accounts": { "type": "array", "items": { "type": "object", "properties": { "account_number": { "type": [ "string", "null" ], "description": "Letters and digits only, upper case: the form to match on, as in the accounts API (sample 04) and statements' normalized." }, "account_number_as_printed": { "type": [ "string", "null" ], "description": "As the portal showed it." }, "account_id": { "type": "string", "pattern": "^acc_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Our id for the account. Present when the request ran for a connection." }, "collection": { "type": "string", "description": "Yours: whether we retrieve it. Never the same field as status. Known values: never_collected, active, paused. Values may be added in a minor version.", "x-known-values": [ "never_collected", "active", "paused" ] }, "status": { "type": "string", "description": "Ours: what the portal did. Known values: connection_success, connection_error. Values may be added in a minor version.", "x-known-values": [ "connection_success", "connection_error" ] }, "status_detail": { "type": "string", "description": "Ours: why. Known values: data_accessible, not_listed_by_portal. Values may be added in a minor version.", "x-known-values": [ "data_accessible", "not_listed_by_portal" ] } }, "required": [ "account_number" ] } }, "enumeration": { "type": "object", "description": "How many of the accounts present before this run it listed. A shortfall marks nothing missing.", "properties": { "expected_account_count": { "type": "integer", "minimum": 0 }, "enumerated_account_count": { "type": "integer", "minimum": 0 }, "shortfall": { "type": "boolean" } }, "required": [ "expected_account_count", "enumerated_account_count", "shortfall" ] }, "failed_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$", "description": "When the retrieval failed. ISO 8601, UTC." }, "reason": { "type": "object", "properties": { "code": { "type": "string", "description": "Why. Known values: credentials_failed, mfa_failed, client_assistance_required, provider_not_enabled, portal_unavailable, internal_error. Values may be added in a minor version. Act on an unknown code by customer_action_required.", "x-known-values": [ "credentials_failed", "mfa_failed", "client_assistance_required", "provider_not_enabled", "portal_unavailable", "internal_error" ] }, "message": { "type": "string" }, "customer_action_required": { "type": "boolean" } }, "required": [ "code", "message", "customer_action_required" ] } }, "required": [ "schema_version", "event", "event_id", "request_id", "dry_run", "invoices_found", "invoices_downloaded", "accounts", "failed_at", "reason" ] } ``` ### statement.available.schema.json ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:luminetric:canonical:1:statement.available", "$comment": "Generated by build_schemas.py. Open by design: accept fields and enum values you do not recognise (COMPATIBILITY.md).", "title": "statement.available", "description": "One statement. Sent with Luminetric-Event: statement.available.", "type": "object", "properties": { "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$", "description": "Semantic version of this contract. This schema covers major version 1." }, "statement_id": { "type": "string", "pattern": "^stt_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Durable across re-extractions. Key what you store on it." }, "version_id": { "type": "string", "pattern": "^ver_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "This extraction of the statement. The delivery id." }, "is_latest": { "type": "boolean" }, "superseded_by": { "type": [ "string", "null" ], "pattern": "^ver_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The version that replaced this one." }, "extracted_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$", "description": "When this version was extracted. ISO 8601, UTC." }, "request_id": { "type": "string", "minLength": 1, "description": "The request the bill arrived in." }, "client_reference": { "type": "string", "minLength": 1, "maxLength": 255, "description": "Echoed from the request when it carried one." }, "connection_id": { "type": "string", "pattern": "^con_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The connection the bill was retrieved through. Present only on a retrieved bill; absent on an uploaded one. Do not require it." }, "account_id": { "type": "string", "pattern": "^acc_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The account the bill was retrieved for: match on this, not on the account number, which the bill may print in another form. Present when a retrieved bill matched one of its connection's accounts; absent otherwise and on uploaded bills. Do not require it." }, "document_id": { "type": "string", "pattern": "^doc_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The received file; shared by every statement in it." }, "parent_statement_id": { "type": "string", "pattern": "^stt_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Present only on a child of a summary bill." }, "provider": { "$ref": "#/$defs/provider" }, "issuer": { "type": [ "object", "null" ], "description": "Reserved. Always null today." }, "statement": { "$ref": "#/$defs/statement_body" }, "children": { "$ref": "#/$defs/children" }, "reconciliation": { "$ref": "#/$defs/reconciliation" }, "confidence": { "$ref": "#/$defs/confidence" }, "meters": { "type": "array", "items": { "$ref": "#/$defs/meter" } }, "sections": { "type": "array", "items": { "$ref": "#/$defs/section" } }, "charges": { "type": "array", "items": { "$ref": "#/$defs/charge" } } }, "required": [ "schema_version", "statement_id", "version_id", "is_latest", "superseded_by", "extracted_at", "request_id", "issuer", "statement", "reconciliation", "confidence", "meters", "sections", "charges" ], "$defs": { "service": { "type": "string", "description": "The utility service. 'unknown' means the charge belongs to one of the bill's services but the bill does not say which; 'none' means it belongs to no service at all (a bill-wide tax, a payment, a late fee). A tax or fee you cannot attribute is 'none', never 'unknown'. Attribute a tax or fee to its service whenever the bill allows - a sales tax inside an electric section, or on an electric-only bill, is 'electric'. Never guess, and never fall back to the bill's dominant service. Known values: electric, gas, water, sewer, irrigation, stormwater, drainage, trash, fire, steam, chilled_water, internet, phone, tv, none, other, unknown. Values may be added in a minor version. Treat an unknown value as 'other'.", "x-known-values": [ "electric", "gas", "water", "sewer", "irrigation", "stormwater", "drainage", "trash", "fire", "steam", "chilled_water", "internet", "phone", "tv", "none", "other", "unknown" ] }, "money": { "type": "object", "description": "A currency figure. 'amount' is signed: credits and payments are negative.", "properties": { "amount": { "type": "number" }, "as_printed": { "type": "string", "description": "Exactly as it appears on the bill, including $, commas, CR or parentheses." }, "source": { "$ref": "#/$defs/source" } }, "required": [ "amount", "as_printed", "source" ] }, "quantity": { "type": "object", "description": "A measured quantity with its unit kept separate from its value.", "properties": { "value": { "type": "number" }, "unit": { "type": "string", "description": "As printed, e.g. 'kWh', 'KGAL', 'therms', 'gallons'." }, "as_printed": { "type": "string", "description": "Exactly as it appears on the bill, e.g. '139 KGAL'." }, "source": { "$ref": "#/$defs/source" } }, "required": [ "value", "unit", "as_printed", "source" ] }, "source": { "type": "string", "description": "Where the value came from. 'extracted' = read off the bill. 'derived' = computed from other values on the bill. 'defaulted' = supplied because the bill omitted it. 'inferred' = judged from context. Never mark a computed value 'extracted'. Known values: extracted, derived, defaulted, inferred. Values may be added in a minor version.", "x-known-values": [ "extracted", "derived", "defaulted", "inferred" ] }, "dated": { "type": "object", "description": "A date. 'value' is ISO 8601 (YYYY-MM-DD). 'as_printed' is the date exactly as the bill shows it. 'source' says whether a labelled field supplied it - a date taken from an unlabelled position is NOT 'extracted'.", "properties": { "value": { "type": "string", "description": "YYYY-MM-DD." }, "as_printed": { "type": "string", "description": "Exactly as printed: '08/07/26', 'Aug 7, 2026'." }, "source": { "$ref": "#/$defs/source" } }, "required": [ "value", "as_printed", "source" ] }, "identifier": { "type": "object", "description": "An account, meter or premise identifier. 'value' is the identifier alone, with no label, read type or composite suffix. 'source' says whether a labelled field supplied it.", "properties": { "value": { "type": "string", "description": "The identifier alone. Never a word such as ACTUAL or a column heading." }, "as_printed": { "type": "string", "description": "The identifier as printed, including any composite form." }, "source": { "$ref": "#/$defs/source" }, "normalized": { "type": "string", "description": "The identifier's letters and digits only, upper case, derived by Luminetric from 'value'. The same printed number always gives the same key however it was punctuated, so key accounts, locations and meters on this rather than on 'value' or 'as_printed'." } }, "required": [ "value", "as_printed", "source" ] }, "statement_body": { "type": "object", "properties": { "service_address": { "type": "string" }, "statement_date": { "$ref": "#/$defs/dated" }, "due_date": { "$ref": "#/$defs/dated" }, "period_start": { "$ref": "#/$defs/dated" }, "period_end": { "$ref": "#/$defs/dated" }, "currency": { "type": "string", "description": "ISO 4217, normally USD" }, "is_summary": { "type": "boolean", "description": "true when the bill aggregates several accounts or premises" }, "totals": { "type": "object", "properties": { "amount_due": { "$ref": "#/$defs/money" }, "current_charges": { "$ref": "#/$defs/money", "description": "Charges for this period alone, excluding any prior balance. Where the bill prints no separate figure, compute it and mark source 'derived' - never omit it and never pass a computed figure as 'extracted'." }, "previous_balance": { "$ref": "#/$defs/money" }, "balance_forward": { "$ref": "#/$defs/money" }, "late_fee": { "$ref": "#/$defs/money" }, "payments": { "$ref": "#/$defs/money" }, "credits": { "$ref": "#/$defs/money" }, "adjustments": { "$ref": "#/$defs/money" } }, "required": [ "amount_due", "current_charges" ] }, "account_number": { "$ref": "#/$defs/identifier" }, "location_id": { "$ref": "#/$defs/identifier" }, "children": { "type": "object", "description": "Present ONLY on a parent. How many child statements this bill says it has, and where that count came from. A consumer checks the delivered children against it, so a truncated extraction is detectable instead of silent.", "properties": { "expected_count": { "type": "integer", "description": "The number of children the bill itself states or enumerates." }, "source": { "$ref": "#/$defs/source" } }, "required": [ "expected_count", "source" ] } }, "required": [ "statement_date", "totals" ], "description": "The statement itself, as extracted. The ids that place it - statement_id, parent_statement_id - are on the envelope, not here." }, "meter": { "type": "object", "properties": { "meter_id": { "type": "string", "description": "Local id used to reference this meter from sections and charges." }, "service": { "$ref": "#/$defs/service" }, "service_address": { "type": "string" }, "previous_read": { "type": "string" }, "current_read": { "type": "string" }, "multiplier": { "type": "string" }, "read_start": { "$ref": "#/$defs/dated" }, "read_end": { "$ref": "#/$defs/dated" }, "usage": { "$ref": "#/$defs/quantity" }, "number": { "$ref": "#/$defs/identifier" } }, "required": [ "meter_id", "service" ], "description": "One entry per metered point of service. Empty when the bill shows no meter." }, "section": { "type": "object", "properties": { "section_id": { "type": "string" }, "heading_as_printed": { "type": "string", "description": "The heading text exactly as printed, e.g. 'CURRENT CHARGES - ELECTRIC'. Empty string when the grouping had no printed heading." }, "service": { "$ref": "#/$defs/service" }, "service_source": { "type": "string", "description": "How the section's service was determined. Known values: heading, summary_row, item_text, meter, single_service_bill, inferred, none. Values may be added in a minor version.", "x-known-values": [ "heading", "summary_row", "item_text", "meter", "single_service_bill", "inferred", "none" ] }, "meter_id": { "type": "string", "description": "Meter this section belongs to, when the bill ties it to one." }, "ordinal": { "type": "integer" }, "printed_total": { "$ref": "#/$defs/money" } }, "required": [ "section_id", "heading_as_printed", "service", "service_source", "ordinal" ], "description": "The printed groupings of charges on the bill, in page order. THIS IS THE FIELD THAT CARRIES SERVICE ATTRIBUTION. Create one section per printed grouping even when the grouping is only a labelled row in a summary table." }, "charge": { "type": "object", "properties": { "name_as_printed": { "type": "string", "description": "The charge description exactly as printed. Never normalised." }, "section_id": { "type": "string", "description": "The section this row sat under. Required; use the catch-all section when the bill has no grouping." }, "meter_id": { "type": "string", "description": "Omit when the charge is account-level and cannot be attributed to a meter." }, "service": { "$ref": "#/$defs/service" }, "kind": { "type": "string", "x-known-values": [ "supply", "delivery", "tax", "fee", "credit", "adjustment", "subtotal", "other", "unknown" ], "description": "Known values: supply, delivery, tax, fee, credit, adjustment, subtotal, other, unknown. Values may be added in a minor version. Treat an unknown value as 'other'." }, "contributes_to_total": { "type": "boolean", "description": "false for printed subtotals and for any row already counted inside another row, so a consumer can sum without double-counting." }, "amount": { "$ref": "#/$defs/money" }, "quantity": { "$ref": "#/$defs/quantity" }, "rate": { "type": "string", "description": "Unit rate exactly as printed." }, "period_start": { "$ref": "#/$defs/dated" }, "period_end": { "$ref": "#/$defs/dated" }, "service_source": { "type": "string", "description": "How this charge's service was decided. 'section' = inherited from its section, the normal case. 'item_text' = the charge's own printed words name the service. 'single_service_bill' = the bill carries exactly one service and the charge took it. 'convention' = a house rule decided it, e.g. a line naming two services takes the first. 'none' = the service is 'none' or 'unknown', so nothing decided it. Known values: section, item_text, meter, summary_row, single_service_bill, convention, inferred, none. Values may be added in a minor version.", "x-known-values": [ "section", "item_text", "meter", "summary_row", "single_service_bill", "convention", "inferred", "none" ] } }, "required": [ "name_as_printed", "section_id", "service", "kind", "contributes_to_total", "amount", "service_source" ], "description": "Every charge row on the bill, including printed subtotals (marked kind=subtotal)." }, "provider": { "type": "object", "description": "NOT YET SENT. Reserved for the provider resolved from the bill; it will arrive in a minor version. Do not require it.", "properties": { "provider_id": { "type": "string", "pattern": "^prv_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "Luminetric provider id." }, "slug": { "type": "string" }, "display_name": { "type": "string" }, "resolved_from": { "type": "string" }, "asserted_by_client": { "type": [ "string", "null" ] }, "assertion_agrees": { "type": [ "boolean", "null" ] } } }, "children": { "type": "object", "description": "Present only on a parent. The children this bill has, declared as data.", "properties": { "expected_count": { "type": [ "integer", "null" ], "minimum": 0, "description": "How many children the bill states. Compare with the number delivered." }, "source": { "type": [ "string", "null" ], "description": "Where expected_count came from. Known values: extracted, derived, defaulted, inferred. Values may be added in a minor version.", "x-known-values": [ "extracted", "derived", "defaulted", "inferred" ] }, "delivered": { "type": "array", "items": { "type": "object", "properties": { "statement_id": { "type": "string", "pattern": "^stt_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The child statement." }, "account_number": { "type": [ "string", "null" ] }, "service_address": { "type": [ "string", "null" ] }, "meter_numbers": { "type": "array", "items": { "type": "string" }, "description": "Empty for an unmetered child. Match children by meter, not by account or address." }, "current_charges": { "type": [ "number", "null" ] } }, "required": [ "statement_id", "account_number", "service_address", "meter_numbers", "current_charges" ] } } }, "required": [ "expected_count", "source", "delivered" ] }, "reconciliation": { "type": "object", "description": "Recomputed from this payload. Reported, never a gate.", "properties": { "identity": { "type": "string", "description": "The formula checked." }, "current_charges": { "type": [ "number", "null" ] }, "sum_of_charges": { "type": "number" }, "sum_of_parent_charges": { "type": "number", "description": "Parents only." }, "sum_of_child_current_charges": { "type": "number", "description": "Parents only." }, "children_include_adjustments": { "type": "number", "description": "Parents only, and only when the children balance with the bill's adjustments rather than its current charges." }, "delta": { "type": [ "number", "null" ] }, "balanced": { "type": "boolean" }, "total_due": { "type": "number" }, "total_due_identity": { "type": "string" }, "total_due_delta": { "type": "number" }, "total_due_balanced": { "type": "boolean" } }, "required": [ "identity", "current_charges", "sum_of_charges", "delta", "balanced" ] }, "confidence": { "type": "object", "properties": { "tier": { "type": "string", "description": "deliver when every signal holds, review when any fails. Known values: deliver, review. Values may be added in a minor version. Treat an unknown value as 'review'.", "x-known-values": [ "deliver", "review" ] }, "reconciles": { "type": "boolean" }, "key_totals_extracted": { "type": "boolean" }, "unknown_service_charge_count": { "type": "integer", "minimum": 0 }, "signals_failed": { "type": "array", "items": { "type": "string", "description": "A signal that did not hold. Known values: reconciliation, unknown_service_charges, key_totals_not_extracted. Values may be added in a minor version. Treat an unknown entry as a failed signal.", "x-known-values": [ "reconciliation", "unknown_service_charges", "key_totals_not_extracted" ] } } }, "required": [ "tier", "reconciles", "key_totals_extracted", "unknown_service_charge_count", "signals_failed" ] } } } ``` ### statement.withdrawn.schema.json ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:luminetric:canonical:1:statement.withdrawn", "$comment": "Generated by build_schemas.py. Open by design: accept fields and enum values you do not recognise (COMPATIBILITY.md).", "title": "statement.withdrawn", "description": "A statement you were sent that a re-extraction no longer contains.", "type": "object", "properties": { "schema_version": { "type": "string", "pattern": "^1\\.[0-9]+\\.[0-9]+$", "description": "Semantic version of this contract. This schema covers major version 1." }, "event": { "type": "string", "const": "statement.withdrawn" }, "withdrawal_id": { "type": "string", "pattern": "^wdr_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The delivery id." }, "statement_id": { "type": "string", "pattern": "^stt_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The withdrawn statement." }, "last_version_id": { "type": "string", "pattern": "^ver_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", "description": "The last version of it you were sent." }, "request_id": { "type": "string", "minLength": 1 }, "client_reference": { "type": "string", "minLength": 1, "maxLength": 255 }, "withdrawn_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]+)?Z$", "description": "When it was withdrawn. ISO 8601, UTC." }, "reason": { "type": "object", "properties": { "code": { "type": "string", "description": "Why. Known values: not_in_reextraction. Values may be added in a minor version.", "x-known-values": [ "not_in_reextraction" ] }, "message": { "type": "string" } }, "required": [ "code", "message" ] } }, "required": [ "schema_version", "event", "withdrawal_id", "statement_id", "last_version_id", "request_id", "withdrawn_at", "reason" ] } ``` ## Example statements Two complete `statement.available` bodies, from real bills with identifying details replaced. Fields beginning with `_` are notes for readers and never appear in real payloads. The other samples are linked from the Sample payloads section. ### 05-output-statement.json ```json { "schema_version": "1.0.0", "_comment": "OUTPUT - a canonical statement. The statement/meters/sections/charges bodies below are the REAL output of the shadow canonical extractor on a real City of North Las Vegas bill, re-extracted 2026-09-15 against the v2 schema, unedited except that account numbers, meter numbers and addresses are placeholders. The envelope around them - ids, provider, reconciliation, confidence - is the plan's design applied to that real body, and its derived blocks are computed from the body by regen.py rather than typed. This bill is the multi-service case: it carries TWO separate water blocks, city water $21.08 and Southern Nevada Water Authority $19.28, which together are the $40.36 of water that the legacy output cannot express and which made legacy understate water on this provider. Note charges[].service_source: 13 of these 19 charges are attributed from their OWN printed text rather than from a section heading, which the pre-v2 schema had no way to record.", "statement_id": "stt_4e91c7a2-3b58-4d06-9f41-8c2e5a7b0d39", "version_id": "ver_1a6f83d5-9e27-4c10-b843-7f0d2c9e6b15", "is_latest": true, "superseded_by": null, "extracted_at": "2026-08-19T04:07:31Z", "request_id": "req_0a4d6e82-9c37-4b15-a8f2-3e7b1d5c9042", "client_reference": "invoice-433193", "connection_id": "con_5d2e8f31-6b94-4a07-8c13-2f6e9b4d7a01", "account_id": "acc_2b81f47c-6d09-4a53-8e1b-9f2c7d3a05e6", "document_id": "doc_b72e4f18-6a93-4d51-8027-3e9c1b5d8a64", "provider": { "provider_id": "prv_9f3c1a84-2e77-4c1b-9d0e-6b5a0f2c7d13", "slug": "city-of-north-las-vegas", "display_name": "City of North Las Vegas", "resolved_from": "issuer", "asserted_by_client": "prv_9f3c1a84-2e77-4c1b-9d0e-6b5a0f2c7d13", "assertion_agrees": true }, "issuer": null, "statement": { "statement_date": { "value": "2026-08-18", "as_printed": "8/18/2026", "source": "extracted" }, "totals": { "amount_due": { "amount": 55.7, "as_printed": "55.70", "source": "extracted" }, "current_charges": { "amount": 85.7, "as_printed": "85.70", "source": "extracted" }, "balance_forward": { "amount": -30, "as_printed": "(30.00)", "source": "extracted" }, "credits": { "amount": -30, "as_printed": "(30.00)", "source": "extracted" }, "payments": { "amount": -81.4, "as_printed": "(81.40)", "source": "extracted" }, "previous_balance": { "amount": 81.4, "as_printed": "81.40", "source": "extracted" } }, "account_number": { "value": "357426", "as_printed": "357426", "source": "extracted" }, "currency": "USD", "due_date": { "value": "2026-09-09", "as_printed": "9/9/2026", "source": "extracted" }, "is_summary": false, "period_end": { "value": "2026-08-07", "as_printed": "8/7/2026", "source": "inferred" }, "period_start": { "value": "2026-07-08", "as_printed": "7/8/2026", "source": "derived" }, "service_address": "6406 OSPREY HAWTHORN CT" }, "reconciliation": { "identity": "sum(charges where contributes_to_total) == statement.totals.current_charges", "current_charges": 85.7, "sum_of_charges": 85.7, "delta": 0.0, "balanced": true, "total_due": 55.7, "total_due_identity": "previous_balance + payments + adjustments + current_charges", "total_due_delta": 30.0, "total_due_balanced": false }, "confidence": { "tier": "deliver", "reconciles": true, "key_totals_extracted": true, "unknown_service_charge_count": 0, "signals_failed": [] }, "meters": [ { "meter_id": "m1", "service": "water", "current_read": "299", "read_end": { "value": "2026-08-07", "as_printed": "8/7/2026", "source": "extracted" }, "usage": { "value": 3, "unit": "thousand gallons", "as_printed": "3", "source": "extracted" } } ], "sections": [ { "section_id": "sec1", "heading_as_printed": "Billing Summary", "service": "none", "service_source": "none", "ordinal": 1 }, { "section_id": "sec2", "heading_as_printed": "", "service": "unknown", "service_source": "none", "ordinal": 2, "printed_total": { "amount": 85.7, "as_printed": "85.70", "source": "extracted" } } ], "charges": [ { "name_as_printed": "Misc", "section_id": "sec1", "service": "none", "kind": "credit", "contributes_to_total": false, "amount": { "amount": -30, "as_printed": "(30.00)", "source": "extracted" }, "service_source": "section" }, { "name_as_printed": "Payment - Thank You", "section_id": "sec1", "service": "none", "kind": "credit", "contributes_to_total": false, "amount": { "amount": -81.4, "as_printed": "(81.40)", "source": "extracted" }, "service_source": "section" }, { "name_as_printed": "Previous Bill", "section_id": "sec1", "service": "none", "kind": "other", "contributes_to_total": false, "amount": { "amount": 81.4, "as_printed": "81.40", "source": "extracted" }, "service_source": "section" }, { "name_as_printed": "Balance Forward", "section_id": "sec1", "service": "none", "kind": "subtotal", "contributes_to_total": false, "amount": { "amount": -30, "as_printed": "(30.00)", "source": "extracted" }, "service_source": "section" }, { "name_as_printed": "Water - Daily Water Service", "section_id": "sec2", "service": "water", "kind": "supply", "contributes_to_total": true, "amount": { "amount": 13.64, "as_printed": "13.64", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "Water Usage - Tier 1 (Days -31)", "section_id": "sec2", "service": "water", "kind": "supply", "contributes_to_total": true, "amount": { "amount": 7.44, "as_printed": "7.44", "source": "extracted" }, "service_source": "item_text", "meter_id": "m1", "quantity": { "value": 3, "unit": "thousand gallons", "as_printed": "3.0", "source": "extracted" }, "rate": "2.48" }, { "name_as_printed": "Water Subtotal", "section_id": "sec2", "service": "water", "kind": "subtotal", "contributes_to_total": false, "amount": { "amount": 21.08, "as_printed": "21.08", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "Sewer (Days -31)", "section_id": "sec2", "service": "sewer", "kind": "delivery", "contributes_to_total": true, "amount": { "amount": 24.2, "as_printed": "24.20", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "Wastewater Subtotal", "section_id": "sec2", "service": "sewer", "kind": "subtotal", "contributes_to_total": false, "amount": { "amount": 24.2, "as_printed": "24.20", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "Graffiti - Community Improvement", "section_id": "sec2", "service": "none", "kind": "fee", "contributes_to_total": true, "amount": { "amount": 0.75, "as_printed": "0.75", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "City of North Las Vegas Utility Subtotal", "section_id": "sec2", "service": "unknown", "kind": "subtotal", "contributes_to_total": false, "amount": { "amount": 46.03, "as_printed": "46.03", "source": "extracted" }, "service_source": "none" }, { "name_as_printed": "SNWA - Reliability Surcharge", "section_id": "sec2", "service": "water", "kind": "fee", "contributes_to_total": true, "amount": { "amount": 0.06, "as_printed": "0.06", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "SNWA - Infrastructure Charge", "section_id": "sec2", "service": "water", "kind": "fee", "contributes_to_total": true, "amount": { "amount": 17.21, "as_printed": "17.21", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "SNWA - Commodity Charge", "section_id": "sec2", "service": "water", "kind": "supply", "contributes_to_total": true, "amount": { "amount": 2.01, "as_printed": "2.01", "source": "extracted" }, "service_source": "item_text", "meter_id": "m1", "quantity": { "value": 3, "unit": "thousand gallons", "as_printed": "3.0", "source": "extracted" }, "rate": "0.67" }, { "name_as_printed": "Southern Nevada Water Authority Subtotal", "section_id": "sec2", "service": "water", "kind": "subtotal", "contributes_to_total": false, "amount": { "amount": 19.28, "as_printed": "19.28", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "Garbage - Single Family Residential", "section_id": "sec2", "service": "trash", "kind": "delivery", "contributes_to_total": true, "amount": { "amount": 20.28, "as_printed": "20.28", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "Garbage - State Taxes", "section_id": "sec2", "service": "trash", "kind": "tax", "contributes_to_total": true, "amount": { "amount": 0.11, "as_printed": "0.11", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "Republic Services Garbage, Com & Mod Tax", "section_id": "sec2", "service": "trash", "kind": "subtotal", "contributes_to_total": false, "amount": { "amount": 20.39, "as_printed": "20.39", "source": "extracted" }, "service_source": "item_text" }, { "name_as_printed": "Current Charges", "section_id": "sec2", "service": "none", "kind": "subtotal", "contributes_to_total": false, "amount": { "amount": 85.7, "as_printed": "85.70", "source": "extracted" }, "service_source": "none" } ] } ``` ### 07-output-statement-summary-parent.json ```json { "schema_version": "1.0.0", "_comment": "OUTPUT - the PARENT of a summary bill. Body is real output on a real Waste Management bill (account numbers and addresses are placeholders), which bills two service locations on one invoice. The parent carries the invoice-level charges and declares children.expected_count: 2, taken from the bill itself. A consumer checks delivered children against that count; it is what makes a truncated extraction detectable instead of silent. NOTE: extraction emits ONE document containing all three statements; delivery splits it into three payloads. See regen.py for the mapping.", "statement_id": "stt_9d41e8b6-2c07-4f53-a819-6b3f0d5c7e24", "version_id": "ver_5a07c3f9-8e16-4b42-90d7-2c8e1f6a3b05", "is_latest": true, "superseded_by": null, "extracted_at": "2026-09-03T05:14:08Z", "request_id": "req_4c8e2a17-6b93-4d05-8f21-7e0a3c9d5b68", "client_reference": "invoice-604882", "connection_id": "con_1f6b3e09-8d47-4a25-b0c3-5e9a2d7f4c81", "account_id": "acc_8e3c0b57-1d94-4f26-a70b-3c5d9e1a6f42", "document_id": "doc_5b1a7d34-9e62-4c08-8153-0f7e2a6c9d45", "provider": { "provider_id": "prv_6a1e9c30-4b78-4d25-8f13-0c7b2e5a9d64", "slug": "waste-management", "display_name": "Waste Management", "resolved_from": "issuer", "asserted_by_client": "prv_6a1e9c30-4b78-4d25-8f13-0c7b2e5a9d64", "assertion_agrees": true }, "issuer": null, "statement": { "statement_date": { "value": "2026-09-02", "as_printed": "09/02/2026", "source": "extracted" }, "totals": { "amount_due": { "amount": 2729.42, "as_printed": "$2,729.42", "source": "extracted" }, "current_charges": { "amount": 2729.42, "as_printed": "2,729.42", "source": "extracted" }, "adjustments": { "amount": 0.0, "as_printed": "0.00", "source": "extracted" }, "payments": { "amount": -2754.42, "as_printed": "(2,754.42)", "source": "extracted" }, "previous_balance": { "amount": 2754.42, "as_printed": "2,754.42", "source": "extracted" } }, "account_number": { "value": "48-87659-29097", "as_printed": "48-87659-29097", "source": "extracted" }, "children": { "expected_count": 2, "source": "derived" }, "currency": "USD", "due_date": { "value": "2026-10-01", "as_printed": "Oct 01, 2026", "source": "extracted" }, "is_summary": true, "period_end": { "value": "2026-09-30", "as_printed": "09/30/26", "source": "inferred" }, "period_start": { "value": "2026-09-01", "as_printed": "09/01/26", "source": "inferred" } }, "children": { "_comment": "The expected child set, declared as DATA. expected_count comes from the BILL, never from what was extracted, so a short delivery is detectable: compare it against len(delivered).", "expected_count": 2, "source": "derived", "delivered": [ { "statement_id": "stt_b5e29c74-1f83-4a06-8d51-7e0c3b9d4a26", "account_number": "90-99289-31114", "service_address": "The Harbor On 3rd, 2530 3rd Ave S, Orchard AL 99306-6193", "meter_numbers": [], "current_charges": 2144.24 }, { "statement_id": "stt_3f8c0a15-7e42-4b96-8d03-5a1e9c6b2f74", "account_number": "53-29320-00894", "service_address": "The Harbor On 3rd, 2530 3rd Ave S, Orchard AL 99306-6193", "meter_numbers": [], "current_charges": 576.68 } ] }, "reconciliation": { "identity": "sum(parent charges where contributes_to_total) + sum(children[].current_charges) == statement.totals.current_charges", "current_charges": 2729.42, "sum_of_parent_charges": 8.5, "sum_of_child_current_charges": 2720.92, "sum_of_charges": 2729.42, "delta": 0.0, "balanced": true, "_comment": "A parent's own charges are NOT redundant with its children. Here 8.50 of invoice-level charges exist only on the parent; a consumer that sums only the children is short by exactly that.", "total_due": 2729.42, "total_due_identity": "previous_balance + payments + adjustments + current_charges", "total_due_delta": 0.0, "total_due_balanced": true }, "confidence": { "tier": "deliver", "reconciles": true, "key_totals_extracted": true, "unknown_service_charge_count": 0, "signals_failed": [] }, "meters": [], "sections": [ { "section_id": "sec1", "heading_as_printed": "Invoice Charges - Master Account", "service": "trash", "service_source": "single_service_bill", "ordinal": 1, "printed_total": { "amount": 8.5, "as_printed": "8.50", "source": "extracted" } } ], "charges": [ { "name_as_printed": "MASTER ACCOUNT FEL", "section_id": "sec1", "service": "trash", "kind": "other", "contributes_to_total": true, "amount": { "amount": 0.0, "as_printed": "0.00", "source": "extracted" }, "service_source": "section", "period_end": { "value": "2026-09-30", "as_printed": "09/30/26", "source": "inferred" }, "period_start": { "value": "2026-09-01", "as_printed": "09/01/26", "source": "inferred" } }, { "name_as_printed": "ADMINISTRATIVE CHARGE", "section_id": "sec1", "service": "trash", "kind": "fee", "contributes_to_total": true, "amount": { "amount": 8.5, "as_printed": "8.50", "source": "extracted" }, "service_source": "section", "period_end": { "value": "2026-09-30", "as_printed": "09/30/26", "source": "inferred" }, "period_start": { "value": "2026-09-01", "as_printed": "09/01/26", "source": "inferred" } }, { "name_as_printed": "Total Invoice Charges", "section_id": "sec1", "service": "trash", "kind": "subtotal", "contributes_to_total": false, "amount": { "amount": 8.5, "as_printed": "8.50", "source": "extracted" }, "service_source": "section" } ] } ```