Getting Started

Run the mako NB straight-through-processing demo in about five minutes: submit a UTILMD 55001 Anmeldung and receive the 55002 confirmation back.

On this page 14 sections

This guide runs the full NB STP demo stack locally and walks through the complete end-to-end flow: UTILMD 55001 → automatic NB decision → UTILMD 55002.

What you're running

ServicePortRole
postgres5432PostgreSQL — one database per service
webhook8000Demo ERP event receiver (Python, in-memory)
marktd8180Market Data Hub — MaLo/MeLo/NeLo/TR, VersorgungsStatus, durable fan-out, event_log replay
processd8580NB STP auto-responder — mako-pruefung (E_0622/G_0011), LF answers 55007/55010 inside their per-PID Frist
makod8080EDIFACT process engine — GPKE/WiM/GeLi Gas, in-memory
sequenceDiagram
    participant LF as LF 4012345000023
    participant makod as makod :8080
    participant marktd as marktd :8180
    participant processd as processd :8580
    participant webhook as webhook :8000

    LF->>makod: POST /edifact<br/>UTILMD 55001
    makod-->>webhook: APERAK BGM+312<br/>(45-min deadline, auto)
    makod->>marktd: de.mako.process.initiated<br/>HMAC POST /api/v1/events (demo override)
    marktd->>processd: de.mako.process.initiated<br/>HMAC POST /webhook
    marktd-->>webhook: de.mako.process.initiated<br/>(ERP subscription)
    processd->>marktd: GET /api/v1/versorgung/{malo_id}
    processd->>marktd: GET /api/v1/malos/{malo_id}/grid
    processd->>marktd: GET /api/v1/partners/{lf_mp_id}
    Note over processd: `mako-pruefung`: E_0622 → Accept (A51)
    processd->>makod: gpke.lieferbeginn.bestaetigen
    makod-->>webhook: UTILMD 55002 Bestätigung

Total time once the images are built: ~5 minutes. The first build takes longer — see Step 1, and rather longer again for the second demo, whose two services come out of the full builder stage.


Prerequisites

ToolVersionInstall
Docker24+ with Compose v2https://docs.docker.com/get-docker/
curlanyOS package manager
jqanyOS package manager

Step 1 — Clone and build

git clone https://github.com/hupe1980/mako.git
cd mako

just build-demo

just build-demo is the three builds the compose file needs, tagged the way it expects them:

docker build --target runtime          -t makod:dev     .
docker build --target marktd-runtime   -t marktd:dev    .
docker build --target processd-runtime -t processd:dev  .

Not docker buildx bakedocker-bake.hcl is the CI push path (push-by-digest), so it fails on the default docker driver and tags ghcr.io/hupe1980/mako-* rather than the :dev images the compose file starts.

The processd-runtime stage builds with --features integrated (includes both the NB mako-pruefung and the LF answer modules).

These three come from a builder stage carrying only their dependencies — no Iceberg/DataFusion, no LanceDB — so a cold build is a few minutes and a warm one under a minute.


Step 2 — Start the demo stack

cd demos/nb-stp
docker compose up -d
docker compose ps   # wait until all containers are running

Expected:

NAME                 IMAGE              STATUS         PORTS
nb-stp-postgres-1    postgres:17-alpine Up (healthy)   5432/tcp
nb-stp-webhook-1     python:3.12-alpine Up             0.0.0.0:8000->8000/tcp
nb-stp-marktd-1      marktd:dev         Up             0.0.0.0:8180->8180/tcp
nb-stp-processd-1    processd:dev       Up             0.0.0.0:8580->8580/tcp
nb-stp-makod-1       makod:dev          Up             0.0.0.0:8080->8080/tcp

What happens at startup:
processd self-registers its fan-out subscription with marktd on startup — no manual subscription curl required. Both marktd and processd run SQLx migrations automatically on first boot (databases are created by init-db.sh).


Step 3 — Verify health

curl -s http://localhost:8080/health | jq .
# → {"status":"ok","instance_id":"..."}

curl -s http://localhost:8180/health | jq .
# → {"status":"ok"}

curl -s http://localhost:8580/health/ready
# → 200 OK

Step 4 — Seed master data

processd's mako-pruefung needs three items in marktd to reach an Accept decision. Every path below is relative to demos/nb-stp, the directory Step 2 changed into.

4a — Price sheet

curl -s -X PUT http://localhost:8180/api/v1/preisblaetter/9900357000004 \
  -H "Content-Type: application/json" \
  --data-binary @fixtures/preisblatt-nb.json \
  -w "\nHTTP %{http_code}\n"
# → HTTP 204

4b — MaLo + MaLo grid record

MALO_ID=51238696012

# MaLo (NB=9900357000004, no active LF)
curl -s -X PUT "http://localhost:8180/api/v1/malos/$MALO_ID" \
  -H "Content-Type: application/json" \
  --data-binary "$(jq --arg m "$MALO_ID" '.data.marktlokationsId=$m' fixtures/malo-nb.json)" \
  -w "\nHTTP %{http_code}\n"
# → HTTP 201

# MaLo grid record (`mako-pruefung` check 1)
curl -s -X PUT "http://localhost:8180/api/v1/malos/$MALO_ID/grid" \
  -H "Content-Type: application/json" \
  -d '{"nb_mp_id":"9900357000004","bilanzierungsgebiet":"11YN0------0STXG","netzgebiet":"DEMO-NZ-001","sparte":"STROM","source":"manual"}' \
  -w "\nHTTP %{http_code}\n"
# → HTTP 204

4c — LF trading partner (mako-pruefung check 5)

# Register in marktd partner directory
curl -s -X PUT http://localhost:8180/api/v1/partners/4012345000023 \
  -H "Content-Type: application/json" \
  -d '{"display_name":"Demo LF","marktrolle":"LF","sparte":"STROM","makoadresse":["https://as4.example.com/as4/receive"]}' \
  -w "\nHTTP %{http_code}\n"
# → HTTP 200

# Register in makod for EDIFACT routing
curl -s -X PUT http://localhost:8080/admin/partners/4012345000023 \
  -H "Authorization: Bearer demo-secret-change-me" \
  -H "Content-Type: application/json" \
  --data-binary @fixtures/partner-lf.json | jq '.'

Two registries, two shapes. marktd holds the partner's BO4E Geschaeftspartner and its AS4 endpoints (makoadresse); makod holds the PARTIN record with its communication channels, roles and contacts. The bodies are not interchangeable, and both refuse a field they do not have — so a body sent to the wrong one is a 422 naming the field rather than a 200 with the values dropped.


Step 5 — Submit a UTILMD 55001

curl -s -X POST http://localhost:8080/edifact \
  -H "Authorization: Bearer demo-secret-change-me" \
  -H "Content-Type: text/plain; charset=utf-8" \
  --data-binary @fixtures/utilmd-55001.edi | jq .

Expected response:

{
  "accepted": 1,
  "rejected": 0,
  "messages": [{
    "message_type": "UTILMD",
    "pid": 55001,
    "workflow": "gpke-supplier-change",
    "status": "routed",
    "process_id": "...",
    "malo_id": "51238696012"
  }]
}

Step 6 — Automatic NB decision

Within ~200 ms, processd receives the de.mako.process.initiated event from marktd's fan-out and runs all 6 mako-pruefung validation checks synchronously.

# Check the decision log
curl -s http://localhost:8580/api/v1/decisions | jq '.[] | {
  malo_id, decision, antwortcode, decided_at
}'
# → {"malo_id":"51238696012","decision":"Accept","antwortcode":"A51","decided_at":"..."}

With NB_AUTO_ACCEPT=true (set in the demo compose file), Accept automatically dispatches gpke.lieferbeginn.bestaetigen to makod, which enqueues the outbound UTILMD 55002 (Bestätigung Anmeldung verb. MaLo):

curl -s http://localhost:8000/events | jq '[.[] |
  select(.body.makomessagetype=="UTILMD")
  | {edifact: .body.data.edifact}
]'

Step 7 — Run the automated smoke test

The demo ships smoke.sh which runs all of the above automatically and asserts every step passes, including the auto-accept timing:

bash smoke.sh

MARKTD_URL and WEBHOOK_URL default to what docker-compose.yml publishes. Set either to the empty string to exercise makod alone.

Output ends with:

✓ processd NB auto-responder dispatched bestaetigen → UTILMD 55002 already arrived
✓ POST /api/v1/commands → HTTP 409 (duplicate bestaetigen correctly rejected — AntwortGesendet guard confirmed)
✓ UTILMD 55002 was already verified in step 6c (auto-responder path)
All smoke tests passed.
  Wechselprozess auto-responder: ENABLED
  Flow: UTILMD 55001 → makod → marktd ingest → validate → bestaetigen → UTILMD 55002

The HTTP 409 confirms the workflow state guardprocessd already advanced the workflow to AntwortGesendet, so the later manual command is correctly rejected with invalid_state: expected ValidationPassed, found AntwortGesendet. This is not idempotency: it is the state machine rejecting an out-of-order command.


Step 8 — Explore the APIs

InterfaceURL
makod Swagger UIhttp://localhost:8080/api/v1/docs/
makod MCP serverhttp://localhost:8080/mcp
marktd Swagger UIhttp://localhost:8180/api/v1/docs/
marktd DLQ adminhttp://localhost:8180/admin/fanout/dlq
marktd metricshttp://localhost:8180/metrics
processd decisionshttp://localhost:8580/api/v1/decisions
processd approval queuehttp://localhost:8580/api/v1/queue
ERP webhook event loghttp://localhost:8000/events

Stop and clean up

docker compose down      # keep database volumes
docker compose down -v   # destroy all volumes (full reset)

The second demo — EEG feed-in settlement

demos/eeg-billing runs the other half of the platform: no EDIFACT at all, but the settlement path an NB owes an Anlagenbetreiber. einsd holds the plant register and computes the § 21 EEG 2023 Einspeisevergütung; edmd holds the quarter-hour Einspeisemenge it settles against.

ServicePortRole
marktd8180Market Data Hub — the MaLo the plant feeds into
edmd8380Energy Data Management — quarter-hour readings, billing periods
einsd9180EEG/KWKG settlement — plant register, monthly Vergütung
# from the repo root
docker build --target edmd-runtime  -t edmd:dev  .
docker build --target einsd-runtime -t einsd:dev .

cd demos/eeg-billing
docker compose up -d
bash smoke.sh

marktd comes from the image just build-demo already produced. edmd and einsd are built on their own, and they are the expensive half of this page: both copy from the full builder stage, which compiles every service in the workspace in one cargo build — Iceberg/DataFusion and LanceDB included. Budget 20–45 minutes cold, a few minutes on a warm BuildKit cache.

It registers the Anlagenbetreiber and a 9.8 kWp rooftop plant behind it, pushes a month of quarter-hour readings under OBIS 1-0:2.8.0, settles the month at EUR 233.57, and asserts the de.eeg.verguetung.berechnet CloudEvent the ERP receives. The amount alone is not a legal document: under the Gutschriftverfahren (§ 14 Abs. 2 Satz 2 UStG) the Netzbetreiber issues the Gutschrift, so einsd renders it as a BO4E Rechnung whose VAT follows the operator's declared ust_status — the fixture is a Kleinunternehmer (§ 19 UStG), so it carries 0 % USt.


The third demo — order-to-cash

demos/o2c runs the retail money path: what the supplier sells, who bought it, what they owe, the document that says so, and the receivable behind it. No EDIFACT at all — demos/nb-stp is where a market message goes out; this is what happens on the supplier's own books once supply is running.

ServicePortRole
productd9080Product and tariff catalogue — the only price source
vertragd9780Kunden, Verträge, Kündigungsfristen
billingd9280Multi-product billing engine (EN 16931)
outputd9880Document store and delivery
accountingd9380Massenkontokorrent — the double-entry ledger
webhook8001ERP CloudEvent receiver, so this stack runs beside demos/nb-stp

marktd, edmd and makod are deliberately absent — the smoke test names the Netzbetreiber and supplies the meter reading, so no lookup happens. Their URLs are configured to hostnames that do not resolve, so an override that is ever dropped fails loudly instead of quietly billing zero.

# from the repo root
just build-demo-o2c

cd demos/o2c
docker compose up -d
bash smoke.sh

It publishes a Tarifpreisblatt, creates a Kunde as a BO4E Geschaeftspartner and a Versorgungsvertrag on a Marktlokation, bills one month against that tariff, records the document in outputd for the § 147 AO eight years, watches the invoice land in accountingd as an Offener Posten, and closes it with a payment import.

The customer is a BO4E document rather than a flat bag of vorname / strasse / plz, and the demo asserts why: § 14 Abs. 4 Nr. 1 UStG makes the Leistungsempfänger's full name and address part of what an invoice has to state, so step 3 reads the recipient back off both the BO4E Rechnung and the EN 16931 model. A flat shape posted to an endpoint that has none of those fields is a 422 naming the field, not a 201 and a nameless customer.

A demo payload is copied into tickets as "this is what a request looks like", so both halves are held to the real types — just test-demo-payloads for every request body, just test-demo-configs for every *.toml the stacks start with. No Docker, no running stack, seconds.

The expected amount is computed by the smoke test itself — 20 ct/Tag × 31 Tage, 32 ct/kWh × 250 kWh and the § 3 StromStG Stromsteuer at 2.05 ct/kWh come to 91.325 netto and 108.675 gross at 19 % USt — so a run that priced from the wrong tariff version, or rounded the tax the other way, fails at the assertion rather than reporting a green invoice for a different amount.


Next steps

TopicGuide
EDIFACT parsing and validationParsing guide
ERP integration — CloudEvents, HMACERP integration
makod operator referencemakod guide
marktd operator referencemarktd guide
processd — NB STP + LF answer automationprocessd guide
INVOIC plausibility, § 147 AO / GoBDinvoicd guide
Energy data, imbalance, billing periodsedmd guide
Process observability, §20 parityobsd guide
Full system architectureArchitecture
Process catalogue (GPKE, WiM, …)Processes
mako-service SDKmako-service README

Edit this page ↗