# Wisnots Netzanmeldung API, full text The overview and every operation of https://www.wisnots.com/netz-api/openapi.json, in one file. The index is https://www.wisnots.com/netz-api/llms.txt. ## Overview ## What this is Registering an installation with the responsible German grid operator: photovoltaics, a heat pump, a wallbox, and with them the § 14a EnWG registration as a steuerbare Verbrauchseinrichtung wherever it applies. ## One route per Anlagentyp, one case model behind them `POST /v1/anmeldungen/{typ}` names what you are registering: `pv`, `speicher`, `waermepumpe`, `ladeeinrichtung`, `klimaanlage`. The path follows the national data set's own axis, the Anlagentypen, rather than the legal regime. § 14a reaches four of those types and not PV, and its threshold is a number in a law that has already been rewritten once, so routing by type keeps your URL stable across a change to the regime. It also means the path pins `device.category`: you do not send it and cannot get it wrong, and `GET /v1/anmeldungen/{typ}/fields` beside it comes back already scoped to that type. `POST /v1/anmeldungen/14a` remains, for a case opened before anyone has decided which device it is about. It is the one create path that does not pin a category, so there you declare it yourself. What any of them creates is an ordinary Anmeldung. Everything afterwards, reading, correcting, adding material, filing, the event log, happens on `/v1/anmeldungen/{id}` and is identical for every case, whatever created it. Adding an Anlagentyp later is therefore one new create call and nothing else on this page. Case ids always carry a prefix, `anm_` live and `anm_test_` in the sandbox. That is what keeps a static type segment unambiguous against `{id}`, and what makes a key pointed at the wrong plane fail on the id rather than three screens deep. ## Two ways to give us the case, and they compose **Drop everything.** Send `material`: pasted text, customer mail, PDFs, photos. We read it and return the fields we found, each with the quote it came from. **Declare everything.** Send `declared`: a structured body with as much or as little as you hold. No extraction runs for anything you stated. **Or both**, which is the ordinary case. A declared value is authoritative and is never overwritten by extraction. Where extraction disagrees with something you declared, or two documents disagree with each other, that becomes an entry in `conflicts` rather than a silent choice, because a misdeclaration to a grid operator is worse than a question. ## Sending a document Files go to `POST /v1/anmeldungen/{typ}/upload`, the create route's twin, as `multipart/form-data`: one `file` part per document, `text` for a pasted mail, and the JSON body's own fields (`external_ref`, `intent`, `operator`, and `declared` as JSON text) as further parts. Two files and the customer's mail, one call, and the answer is the new case with all three under `material[]`, already being read: ``` curl https://netz-api.wisnots.com/v1/anmeldungen/pv/upload \ -H "Authorization: Bearer $NETZ_API_KEY" \ -F file=@rechnung.pdf -F file=@typenschild.jpg \ -F text=" -H "Content-Type: application/pdf" --data-binary @rechnung.pdf # {"storageId":"kg2..."} curl -X POST https://netz-api.wisnots.com/v1/anmeldungen/{id}/material \ -H "Authorization: Bearer $NETZ_API_KEY" -H "Content-Type: application/json" \ -d '{"material":[{"kind":"file","storage_id":"kg2...","filename":"rechnung.pdf","content_type":"application/pdf"}]}' ``` The POST to the target carries the bytes as the body, not a form, with no bearer: the target is the credential, single-use, and expires within the hour. The SDK's `cases.uploadByTarget` makes the three calls for you. **Text.** A pasted mail or a note goes as the `text` part of a form, or as `{"kind":"text","label":"Kundenmail","content":"..."}` in JSON, no encoding. **Then wait on the case, not on a file.** Material walks `pending`, `reading`, then `read` or `unreadable`, and the case's `status` is `extracting` for as long as anything is in the first two. Act on `case.extraction_completed` from a webhook, or poll `GET /v1/anmeldungen/{id}` until `status` leaves `extracting`; the SDK's `cases.waitForExtraction` does that. Count on about a minute for a one-page PDF, more for a scan or a long one. **More can be sent until filing,** whatever the status, `ready` included: it is read the same way and the case is resolved again. After filing, `/upload` and `/material` answer 409. A real document is one model call, which is why the sandbox reads 25 a day per account; fixtures are unlimited, live has no cap. ## What comes back - `fields`, everything resolved, each with `source` and, when extracted, the `evidence` quote behind it. - `open`, what is still needed, in the data set's own wording, the words the operators use, each marked with who is expected to know it and whether it blocks filing. - `conflicts`, where sources disagree. - `notices`, things worth knowing that block nothing. Partial intake is accepted by design. A case with non-blocking gaps still files; we ask for the rest afterwards. ## Field naming Fields have readable paths such as `site.street`, and every one carries its number in the national grid connection data set (version 3.0), the field set every German operator's portal is built on. Anything the named schema does not cover yet is reachable through `declared.nr` by number, so no field of the standard is ever out of reach. ## The sandbox A `wnz_test_` key instead of a `wnz_live_` one, and that is the whole switch. **The sandbox differs from live in exactly one place: the irreversible outward act.** Extraction is real, resolution is real, the operator's requirements are real, and the PDF is the real template, so what you build against the sandbox is what happens live. What differs: - **Nothing is sent to a grid operator, ever.** You choose the outcome instead, with `sandbox_outcome` on `POST /{id}/file`, and how fast it arrives with `sandbox_pace`: `instant` inside the call, or `staged` to watch `submitted`, `waiting_operator` and the answer arrive a few seconds apart, each a webhook. - **You are the customer.** A sandbox case carries `signature.link`, the link the signature email would have sent; open it, see the watermarked form your customer would see, and sign it to walk the rest of the sequence. - **No credits.** A sandbox case consumes nothing, and is not checked against your balance either. - **Every PDF is watermarked** on every page. A sandbox form is otherwise byte-for-byte plausible, and somebody will eventually print one. - **Fixtures.** `{"fixture": "wallbox-invoice-de"}` as material returns a known reading with no model call, identically every time, so a screen under construction is free to rebuild. There is one fixture per Anlagentyp: `wallbox-invoice-de`, `heat-pump-invoice-de`, `pv-invoice-de`, `battery-invoice-de`, `air-conditioning-invoice-de`. Real uploads still run real extraction, capped at 25 documents per account per day; over that is a 429 naming the limit, never a silent stub. - **Thirty days.** Sandbox cases and everything under them are deleted after 30 days. Integrators paste real customer PDFs into sandboxes; this is why yours will not still be there. - **A sandbox operator.** `"operator": "sandbox"` is fictional and its form never changes, so your tests do not break on the day a real operator reissues theirs. The two planes never mix. A key of one mode cannot read, patch, file or discard a case of the other, and one page of `GET /v1/anmeldungen` holds one mode. There is no promotion: a test case never becomes a live one. ## Quickstart 1. Sign in at https://www.wisnots.com/netzanmeldung/portal, switch to Sandbox, and create a key under Entwickler. It starts with `wnz_test_`. 2. `POST /v1/anmeldungen/ladeeinrichtung` with `{"intent":"draft","material":[{"fixture":"wallbox-invoice-de"}]}`. The answer is a case with `status: "needs_input"` and `open[]` naming what is still missing, each entry with who is expected to know it. 3. `PATCH /v1/anmeldungen/{id}` with `{"declared": {...}}` for the blocking entries of `open[]`. The case comes back `ready`. 4. `POST /v1/anmeldungen/{id}/file` with `{"sandbox_outcome":"accepted","sandbox_pace":"staged"}`. 5. Register a webhook with `POST /v1/webhooks`, or poll `GET /v1/anmeldungen/{id}/events`, and watch `case.status_changed` arrive three times: `submitted`, `waiting_operator`, then the answer. 6. Switch to Live, create a `wnz_live_` key, and send the same calls without the sandbox fields. Nothing else changes: not the base URL, not the ids' shape, not the responses. ## For your agent Copy the prompt behind *Set up your agent*, at the top of this page and in the console under Entwickler, into any coding agent. It points the agent at https://www.wisnots.com/netz-api/llms.txt, the skill at https://www.wisnots.com/netz-api/skills/wisnots-netz/SKILL.md and this OpenAPI document, and tells it to build on the sandbox first and to ask you for a key rather than invent one. ## One case, one Anlagentyp A case registers one Anlagentyp of the national data set. A roof with PV and a battery is two cases, `pv` and `speicher`, which is how the standard and the operators count them too. Give both the same `external_ref` and they stay together in your system and in the console. Several units of one type on one connection are one case with `device.count`, or `inverters[]` where the standard asks per unit. ## Filing for many installers A platform that files for many installer firms under one key names the firm on every case with `installer_ref`, its own stable id for that firm. Each installer has its own login at each grid operator, and a case is filed with its installer's login there and nobody else's. Once a case names its installer, `operator_login` carries the installer's link: a page where that installer hands in grid operator, login and password, encrypted, as many as it has. It is the same link for every case of that installer. Send it when `operator_login.status` is `missing` or `unusable`, which is also when `case.operator_login_missing` fires; the console shows it on the case too. A login handed in never replaces an earlier one, and we can revoke a link without losing what came through it. The installer needs no account and gives no consent: filing the case is the consent. ## Limits - Material: at most 30 pieces of material per call and 30 files per case. A multipart `file` up to 25 MB, inline `data` up to 8 MB of base64 per file, text up to 100,000 characters. - Reading: a photograph over the reader's limit is shrunk; a PDF over it is read a few pages at a time, up to 60 pages. A single page that alone exceeds the limit is the one thing that stays unreadable, and the material says so. - Declared: 256 KB per request, 12 levels deep. - Rate: 60 requests in a burst per key, refilled at one a second. A refusal is a 429 with `Retry-After`. - Sandbox: 25 real documents read per account per day, fixtures unlimited; every sandbox case and everything under it deleted after 30 days. - The request log you see in the console keeps 30 days on the sandbox and 90 days live, and never a live case's values. ## Versioning The path carries `/v1`. Within v1 we add fields, event types and enum values without notice and never rename or remove one; a rename or a removal is a `/v2`, and v1 keeps working. Unknown request fields are refused, so a client meets a new field only when it asks for it, and a typo never passes silently. Every change is listed below. ## Changes - 2026-10-12: `installer_ref` on create and `PATCH`, and `operator_login` on the case: a platform filing for many installers names each case's installer firm, and each installer hands in its own grid operator logins through its link. `case.operator_login_missing` says when one is needed. - 2026-10-12: `section_14a.consent` is optional. Filing the case is the consent, and `channel` takes your own words. - 2026-10-03: the default of a left-out `connection.planned_commissioning_at` never lies in the past. If the day 14 days after the case was filed with us has passed when we prepare the filing, the case is filed with the operator with the day 14 days after that preparation. A date you declare is still filed as given. - 2026-10-02: `connection.planned_commissioning_at` is optional with a stated default. Left out, the case is filed with the operator with the day 14 days after it was filed with us, on Berlin's calendar; filing a case without it agrees to that. A date you declare is filed as given. - 2026-09-29: `declarations` on `POST /{id}/file`. Under the edition of 28 September 2026 of the terms, a hand-over releases the grid operator's standard declarations for its case; `"withhold"` keeps them open for one case. - 2026-09-08: material summaries are what the schema always described: `kind` is `file`, `text` or `fixture` (a file used to come back as `document`), a file carries `filename`, `content_type` and `bytes` (it used to carry only `label`), a fixture carries `fixture`. `reading` is a documented status, and the case is `extracting` for as long as any material is `pending` or `reading`; before this a material being read no longer counted, so a poll on `status` could return one step early. - 2026-09-07: `POST /v1/anmeldungen/{typ}/upload` beside every create route, and `POST /{id}/upload` on the case, take files as `multipart/form-data`. `curl -F file=@rechnung.pdf` opens a case with the file on it; what a file is comes from its bytes, not from the part's media type. - 2026-09-07: a field's number is `nr` everywhere it appears (`fields[].nr`, `open[].nr`, `conflicts[].nr`, the `/fields` rows), the escape hatch is `declared.nr`, an unnamed field is `nr.NNNN`, and the dictionary version is `3.0`. Before this the key had a different name; no integration had shipped against it. - 2026-09-07: `sandbox_pace`; `signature.link` on sandbox cases; a filed case's `status` follows the operator's answer (`confirmed`, `rejected`); `GET /v1/netzbetreiber`; the named `billing`, `builder` and `feed_in` blocks and the rest of `end_customer` and `connection`; a Hilfetext on every field; PDFs over the reader's limit read page by page; a fixture for every Anlagentyp. - 2026-09-07: webhooks, and an event log that carries the extraction lifecycle. - 2026-09-06: one route per Anlagentyp, all five of the standard's types. ## Webhooks Register an endpoint with `POST /v1/webhooks` and we deliver every event on your plane to it, signed. The secret is returned once, when you create the endpoint. **The one to act on is `case.extraction_completed`.** Reading a document is the slow step, and it is the step after which the case is worth re-reading. A delivery says WHAT changed and carries the case id; it does not carry the case, because a receiver that rebuilt a case from a stream of events would get that wrong and we would both be debugging it. Re-read `GET /v1/anmeldungen/{id}`. **Delivery is at-least-once.** A timeout after your server committed looks exactly like one before it, so you can be handed the same event twice. Every delivery carries `Wisnots-Event-Id`; key on it. Verify the signature before you trust a delivery: ``` Wisnots-Signature: t=1788770000,v1= ``` The MAC is over `${t}.${raw body}` with your endpoint secret, and the raw bytes matter: parse the body after verifying, never before. Reject a delivery whose `t` is more than five minutes from now, or a captured one stays valid forever. Non-2xx is a failure and is retried five times over about ten minutes. Twenty consecutive events that exhaust their retries disable the endpoint, which then shows `active: false` and a `last_error` on `GET /v1/webhooks`, so a silent endpoint can be told from an idle one. Endpoints belong to one mode, like keys. A sandbox endpoint never receives a live case's events, so a test loop cannot fire your production fulfilment. That is all five of the Anlagentypen the national data set defines. There is no sixth to add without going beyond the standard. ## Operations ### GET /v1/netzbetreiber Which grid operators, and which one here Every operator we carry and what each can take, and with `plz` which of them serve a postcode. `operator` on create stays explicit: pass the key this returns. An operator guessed wrong changes which questions a case has to answer, so the API never binds one from an address on its own. A postcode area crosses network boundaries often enough that the answer can be two operators; then `resolved` is false and the choice is your user's, with both names to choose from. The same list on both planes, and the same list for every key: which operators exist is neither a secret nor a mode. ### POST /v1/webhooks Register an endpoint Delivers every event on this key's plane to your URL, signed. **The secret comes back once, here.** We sign with it and you verify with it; nothing needs it readable a second time, and a secret an API re-shows is one that leaks through every screenshot of that call. The URL must be https and must not be a loopback or private address: this endpoint makes our server fetch what you give it. ### GET /v1/webhooks Your endpoints Every endpoint on this key's plane, with enough delivery state to tell a silent endpoint from an idle one. Never the secret. ### DELETE /v1/webhooks/{webhookId} Remove an endpoint Deliveries stop immediately. An id belonging to the other mode is a 404, not a 403: whether it exists is not this key's business either. ### POST /v1/anmeldungen/pv Register a PV array **Three bodies to try.** Under *Request Example* on the right, the selector beside *Test Request* switches between a **fixture** the sandbox reads for free, the **raw** shape (the customer's mail and two files, nothing structured) and **declared** fields mapped from your CRM. Whichever is selected there is what *Test Request* opens with; inside the client the same bodies sit in its sidebar, under this request. Files of your own go to *Upload files*, the operation right after this one, which takes them as a form with a file picker. **The path pins `device.category` to `pv`.** Leave the field out. The generation counterpart of `POST /v1/anmeldungen/14a`, and a separate path because the questions differ: PV wants an inverter block and module capacity where § 14a wants a module choice and a record of consent. `device.category` is not asked. Choosing this path has already answered it, and the case reports it as `derived`. A `declared.device.category` that is not `pv` is a 422 naming both halves, rather than a case judged by one family's questions and another's answers. Everything after creation is identical to any other case: `GET`, `PATCH`, `material`, `file` and `events` all live on `/v1/anmeldungen/{id}` and know nothing about families. ### Inverters Five fields are asked ONCE PER INVERTER; the standard marks them `wiederholt`. Send them as an array: ```json { "declared": { "device": { "inverter_count": 2, "apparent_power_kva": 20 }, "inverters": [ { "manufacturer": "Fronius", "apparent_power_kva": 10 }, { "manufacturer": "SMA", "apparent_power_kva": 10 } ] } } ``` They come back in `open` and `fields` one per instance, `inverters.1.apparent_power_kva`, so a gap names WHICH inverter is missing what. The dictionary publishes them as templates with `[]` where the index goes; that form is a name, not somewhere to write. What blocks a PV filing is the rated power, which is `Antragphase 1` in the standard: what an operator needs to answer a Netzanschlussbegehren at all. Manufacturer, type and ZEREZ-ID are phase 2: real requirements, later ones. The payout fields (MaStR, tax, bank) are later still and never block: the data set scopes itself to the Netzanschlussprozess, and getting paid is a different process. ### POST /v1/anmeldungen/pv/upload Upload files for a PV array Opens the case from files, the way a back office has them: the documents, the customer's mail, and whatever fields you already hold. `curl -F file=@rechnung.pdf -F file=@typenschild.jpg -F text=