marktd Operator Guide

marktd operator guide: Market Data Hub for Marktlokation, Messlokation, VersorgungsStatus (with history + point-in-time queries), NeLo (Redispatch 2.0), MaLo grid topology (NB STP), trading-partner management, price sheets, Cedar ABAC, EventBus fan-out. PostgreSQL-backed, OIDC-secured, OpenAPI 3.1, CloudEvents 1.0 outbound webhooks.

marktd Operator Guide

marktd is the Market Data Hub — the single source of truth for all market entity state in a MaKo deployment. It stores Marktlokationen (MaLo) with typed rubo4e::current::Marktlokation API responses (schema validated on PUT), Messlokationen (MeLo) with typed rubo4e::current::Messlokation responses, Zaehler + Geraete (device registry) with typed rubo4e::current::Zaehler/Geraet responses, energy contracts, trading partners, network contracts (nb_contracts) with full BO4E Vertrag payload (vertragsart, vertragsstatus as indexed columns for ERP digital LRV exchange), price sheets (PreisblattNetznutzung), VersorgungsStatus per MaLo (with full history and point-in-time queries), MaLo grid topology (malo_grid — sourced from the NB's NIS/GIS system and provisioned via PUT /api/v1/malo/{id}/grid; read by processd for Anmeldung STP decisions), and Netz-Element-Lokationen (NeLo) for Redispatch 2.0.

Beyond data storage, marktd includes:

  • EventBus fan-out — enriches inbound de.mako.* events with marktrole and fans out to all registered subscribers (ERP, processd, invoicd, obsd) via HMAC-signed webhooks.
  • VersorgungsStatus derivation — five-phase lifecycle: announce_lf_next (55001/44001 process.initiated), confirm_supply (55002/44002 process.completed — Bestätigung Anmeldung), end_supply (55005/44005 process.completed — Bestätigung Lieferende; without an announced successor it emits de.markt.versorgung.gap-detected, the §38 EnWG gap-closure trigger), begin_eog_supply (55013/44013 process.completed — Anmeldung/Zuordnung EOG: the Grundversorger becomes the supplier of record, eog_seit anchors the §38 Abs. 4 3-month maximum), clear_lf_next (55003/44003 process.completed — Ablehnung Anmeldung resets the announced future supplier). Tracks lf_mp_id_next + lf_next_lieferbeginn (pending transition), appends every change to versorgungsstatus_history, and supports ?at=YYYY-MM-DD point-in-time queries.

marktd is a pure data hub. Automated Anmeldung STP decisions are the responsibility of processd's NB module, which subscribes to marktd's EventBus and uses the pure netz-checker library for all decisions. This separation keeps marktd free of domain policy and makes processd independently scalable and testable.

graph TB
    makod["makod :8080<br/>EDIFACT ↔ BO4E"]
    marktd["marktd :8180<br/>Market Data Hub<br/>(this service)"]
    processd["processd :8580<br/>Process decisions<br/>(NB STP + LF E_0624)"]
    erp["ERP<br/>(Powercloud / SAP IS-U)"]
    invoicd["invoicd :8280<br/>Billing"]
    edmd["edmd :8380<br/>Energy Data"]
    obsd["obsd :8480<br/>Observability"]
    pg["PostgreSQL"]

    makod -->|"de.mako.process.*<br/>HMAC-signed POST"| marktd
    marktd -->|"VersorgungsStatus<br/>derivation"| pg
    marktd -->|"de.mako.* + de.markt.*<br/>HMAC webhooks"| erp
    marktd -->|"de.mako.process.initiated"| processd
    marktd -->|"de.mako.process.initiated"| invoicd
    marktd -->|"de.mako.*"| edmd
    marktd -->|"de.mako.*"| obsd
    marktd --- pg
    erp -->|"PUT /api/v1/malo<br/>PUT /api/v1/partners"| marktd
    invoicd -->|"GET /api/v1/preisblaetter<br/>GET /api/v1/nb-contracts"| marktd
    processd -->|"GET /api/v1/versorgung<br/>GET /api/v1/malo/{id}/grid<br/>GET /api/v1/partners"| marktd
    processd -->|"POST /api/v1/commands"| makod

The clean separation of concerns:

ServiceResponsibility
makodEDIFACT parsing, BDEW process rules, AS4 delivery, regulatory deadlines
marktdMarket data, VersorgungsStatus, ERP subscriptions, EventBus fan-out
processdAutomated STP decisions (NB: netz-checker; LF: E_0624 auto-response)

Port Layout

┌─────────────────────────────────────────────────────────────────┐
│  marktd  :8180                                                    │
│                                                                 │
│  Axum REST API                                                  │
│   ├─ OIDC/JWT middleware  → JwtClaims { sub, mako_tenant,      │
│   │                                     mako_roles, mako_sparte}│
│   ├─ Cedar ABAC enforcer  → permit / deny                      │
│   └─ Handlers             → PostgreSQL (SQLx)                  │
│                                                                 │
│  POST /api/v1/events  ← makod CloudEvents ingest               │
│   ├─ Verify HMAC signature                                     │
│   ├─ Deduplicate via processed_events table                    │
│   ├─ Fan-out to all EventBus subscribers                       │
│   └─ Derive VersorgungsStatus (55003/55005/55013 + Gas twins) │
│                                                                 │
│  GET  /admin/fanout/dlq                       ← DLQ inspection │
│  POST /admin/fanout/dlq/{event}/{sub}/retry   ← re-deliver     │
│  DEL  /admin/fanout/dlq/{event}/{sub}         ← discard entry  │
│  GET  /metrics                                ← Prometheus     │
│                                                                 │
│  Note: Automated STP decisions live in processd :8580          │
│  marktd is a pure data hub — no domain policy.                  │
│                                                                 │
│  GET /health        — liveness (no DB check)                   │
│  GET /health/live   — liveness (no DB check)                   │
│  GET /health/ready  — readiness (PostgreSQL ping)              │
└─────────────────────────────────────────────────────────────────┘

Quick Start

With Docker Compose (full stack)

See demos/nb-stp/docker-compose.yml for the complete 8-service stack (postgres + webhook + marktd + processd + makod + invoicd + edmd + obsd).

Minimal compose snippet for marktd alone:

services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB:       marktd
      POSTGRES_USER:     marktd
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U marktd -d marktd"]
      interval: 5s
      retries: 10

  marktd:
    image: ghcr.io/hupe1980/marktd:0.12.0
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - ./marktd.toml:/etc/marktd/marktd.toml:ro
    environment:
      DATABASE_URL:         postgres://marktd:secret@postgres/marktd
      MAKOD_API_KEY:        my-makod-api-key
      MAKOD_WEBHOOK_SECRET: my-webhook-secret
    command: ["--config=/etc/marktd/marktd.toml"]
    ports: ["8180:8180"]

Binary

marktd --config /etc/marktd/marktd.toml
# or: MARKTD_CONFIG=/etc/marktd/marktd.toml marktd

Migrations run automatically at startup via sqlx migrate run.


Configuration

marktd reads its configuration from a TOML file (default: marktd.toml), with secrets deferred to environment variables via "env:VAR_NAME" values.

Full marktd.toml reference

[http]
addr = "0.0.0.0:8180"     # default

[storage.postgres]
url = "env:DATABASE_URL"  # required; use env: for secrets

[makod]
base_url  = "http://makod:8080"   # required
api_key   = "env:MAKOD_API_KEY"   # required
tenant = "9900357000004"           # required — operator primary MP-ID

[webhook]
inbound_path   = "/api/v1/events"             # must match makod's [erp] webhook_url (code default: /api/v1/mako/events)
inbound_secret = "env:MAKOD_WEBHOOK_SECRET"   # required unless allow_insecure_no_auth

[oidc]              # required unless allow_insecure_no_auth
issuer   = "https://login.microsoftonline.com/{tenant-id}/v2.0"
audience = "api://mako-marktd"
jwks_refresh_secs = 300

# marktd is fail-closed: without [oidc] AND webhook.inbound_secret it refuses
# to start. The insecure dev posture must be requested by name:
# allow_insecure_no_auth = true

# [otel]            # omit to disable tracing
# endpoint = "http://otel-collector:4317"

CLI flags

FlagEnv varDefaultDescription
--config / -cMARKTD_CONFIGmarktd.tomlPath to marktd.toml
--log-levelRUST_LOGinfoLog level (info, debug, marktd=trace)
--checkMARKTD_CHECKfalseValidate config + DB connectivity, then exit 0. Used by Dockerfile HEALTHCHECK.

Authentication & JWT Claims

marktd validates every request using a JWT Bearer token. The JWT must contain these custom claims (in addition to standard OIDC claims):

ClaimTypeRequiredDescription
substringyesPrincipal identifier
mako_tenantstringyesGLN of the tenant this principal belongs to
mako_rolesstring[]yesRoles, e.g. ["NB"], ["LF"], ["MSB","NB"]
mako_spartestring[]noOptional commodity scope, e.g. ["Strom","Gas"]

Configure your OIDC provider (Keycloak, Zitadel, Auth0, Entra ID) to populate mako_tenant and mako_roles from your user store or service account attributes.

Supported signing algorithms: RS256, ES256, PS256. HS256/HS512 are rejected — symmetric algorithms are not acceptable for OIDC.


Authorization: Cedar ABAC

marktd uses Cedar (AWS PARC model) for fine-grained Attribute-Based Access Control. The policy file is loaded once at startup.

Default policy (policies/marktd.cedar)

// Any authenticated principal of the same tenant can read all resources.
permit(
    principal,
    action in [
        Action::"read-malo", Action::"read-melo", Action::"read-nb-contract",
        Action::"read-partner", Action::"read-preisblatt"
    ],
    resource
)
when {
    principal.tenant == resource.tenant
};

// Any principal of the same tenant can write malo/melo/nb-contracts/partners.
permit(
    principal,
    action in [
        Action::"write-malo", Action::"write-melo", Action::"write-nb-contract",
        Action::"write-partner"
    ],
    resource
)
when {
    principal.tenant == resource.tenant
};

// Only NB-role principals may write price sheets.
permit(
    principal,
    action == Action::"write-preisblatt",
    resource
)
when {
    principal.tenant == resource.tenant &&
    principal.roles.contains("NB")
};

// Only operator-admin principals may manage the fanout dead-letter queue.
// Grant this action to your on-call / operations service account.
permit(
    principal,
    action == Action::"manage-fanout",
    resource
)
when {
    principal.tenant == resource.tenant &&
    principal.roles.contains("ADMIN")
};

Context fields

FieldValue
principal.tenantmako_tenant JWT claim
principal.rolesmako_roles JWT claim
resource.tenantTenant GLN of the requested resource

Denied response

{
  "error": "Forbidden",
  "detail": "action=write-preisblatt denied for principal=svc@example.com resource_tenant=9900357000004"
}

Custom policies

Replace policies/marktd.cedar and restart marktd; policies are loaded at startup.


REST API

Interactive docs: http://localhost:8180/api/v1/docs/

OpenAPI spec: GET /api/v1/openapi.json

Endpoints

MethodPathCedar actionDescription
GET/healthHealth check (no auth)
GET/health/liveLiveness check (no DB, no auth)
GET/health/readyReadiness check (DB ping, no auth)
PUT/api/v1/malo/{malo_id}write-maloUpsert Marktlokation; validates _typ = MARKTLOKATION and strictly rejects any out-of-schema enum value anywhere in the BO (Bo4eStrict::ensure_known_enums, 422 with the offending JSON-path); pushes to makod MaLo cache
GET/api/v1/malo/{malo_id}read-maloGet Marktlokation as typed rubo4e::current::Marktlokation (canonical BO4E camelCase)
GET/api/v1/maloread-maloList Marktlokationen (schema-drift records silently filtered)
PUT/api/v1/melo/{melo_id}write-meloUpsert Messlokation; validates _typ = MESSLOKATION and strictly rejects any out-of-schema enum value anywhere in the BO (Bo4eStrict::ensure_known_enums, 422 with the offending JSON-path)
GET/api/v1/melo/{melo_id}read-meloGet Messlokation as typed rubo4e::current::Messlokation
PUT/api/v1/partners/{mp_id}write-partnerUpsert trading partner — validates payload as rubo4e::current::Geschaeftspartner (auto-injects _typ; validates marktrolle, rollencodetyp, marktteilnehmerstatus, adresse; canonicalises camelCase)
GET/api/v1/partners/{mp_id}read-partnerGet trading partner — returns a geschaeftspartner field with the typed rubo4e::current::Geschaeftspartner payload (graceful fallback for legacy records)
GET/api/v1/partnersread-partnerList partners
GET/api/v1/partners/{mp_id}/marktteilnehmerread-partnerBO4E Marktteilnehmer view of a partner (typed marktrolle/rollencodetyp, mp_id → rollencodenummer). Note: partner PUTs with the legacy literal role "LFG" are rejected 422 — model gas suppliers as LF + Rollencodetyp DVGW
GET/PUT/api/v1/mmma-preise/gas/{year}/{month}read/write-preisblattGas MMM Abrechnungspreise (Trading Hub Europe / MGV, monthly) — {mehr_ct_kwh, minder_ct_kwh}; queried by netzbilanzd for INVOIC 31007/31008 billing and invoicd check 6 validation
GET/api/v1/mmma-preise/gasread-preisblattList all Gas MMM price records (newest first; ?limit=)
GET/PUT/api/v1/mmm-preise/strom/{year}/{month}read/write-preisblattStrom MMM prices (VNB per GPKE BK6-24-174 Teil 1 Kap. 8.4) — {vnb_mp_id, mehr_ct_kwh, minder_ct_kwh}; queried by netzbilanzd for MMM INVOIC 31005/31006 and invoicd check 6
PUT/api/v1/preisblaetter/{nb_mp_id}write-preisblattUpsert price sheet + store versioned snapshot + emit de.markt.pricat.published
GET/api/v1/preisblaetter/{nb_mp_id}read-preisblattGet price sheet valid on date
GET/api/v1/pricat/{nb_mp_id}/historyread-preisblattList PRICAT version history (newest first)
GET/api/v1/pricat/{nb_mp_id}/dispatch-log/{version_id}read-preisblattPRICAT dispatch audit log for a version
POST/api/v1/pricat/{nb_mp_id}/dispatchwrite-preisblattEnqueue (re-)dispatch of latest PRICAT to all active LF partners
GET/api/v1/versorgung/{malo_id}read-versorgungsstatusCurrent VersorgungsStatus; add ?at=YYYY-MM-DD for point-in-time
GET/api/v1/versorgung/{malo_id}/historyread-versorgungsstatusFull supply-state change history (newest first, paged)
PUT/api/v1/versorgung/{malo_id}write-versorgungsstatusUpsert VersorgungsStatus (ERP-driven override)
GET/api/v1/grundversorger/{nb_mp_id}read-grundversorgerGrundversorger Feststellung (§36 Abs. 2 EnWG); ?sparte=STROM|GAS
PUT/api/v1/grundversorger/{nb_mp_id}write-grundversorgerUpsert the Feststellung (NB role) — read by the processd EoG gap closure. Optional default_bilanzkreis deposits the GPKE-Teil-4 default BK applied when an EoG completes without the E/G supplying its own (EoG ohne Antwort).
POST/api/v1/esa/einwilligungenGrant an ESA consent (§49 Abs. 2 Nr. 9 MsbG). Emits de.markt.einwilligung.erteilt. Evidence-agnostic
GET/api/v1/esa/einwilligungenList active consents (?esa_mp_id=)
GET/api/v1/esa/einwilligungen/{id}Get a consent
DELETE/api/v1/esa/einwilligungen/{id}Revoke (GDPR Art. 7(3)) — emits de.markt.einwilligung.widerrufen and fires the 17008 Abbestellung at makod
PUT/GET/api/v1/esa/framework/{msb_mp_id}/{esa_mp_id}Bilateral EDI@Energy framework agreement + AS4 cert state
GET/api/v1/esa/consent-checkGate an ESA message (?esa_mp_id=&msb_mp_id=&location_id=&perspective=) → {allowed, code, reason}. perspective=msb_inbound (default, lenient: missing record = self-assertion) or esa_outbound (strict: missing record = no lawful basis). makod calls this before running the Wertebestellung workflow
PUT/api/v1/netzzugang/antraegewrite-netzzugangUpsert a §20b EnWG Netzzugangsplattform request (makod netzzugang adapter projection). Emits de.markt.netzzugang.antrag.updated
GET/api/v1/netzzugang/antraegeread-netzzugangList §20b requests (?status=&netzanschluss_id=)
GET/api/v1/netzzugang/antraege/{id}read-netzzugangGet a §20b request
PATCH/api/v1/netzzugang/antraege/{id}/statuswrite-netzzugangAdvance lifecycle (erfasst → uebermittelt → bestaetigt/abgelehnt, optional platform_ref; optional expected_version → 412 on mismatch). Used by the makod sender and by the operator recording the platform's answer
PUT/api/v1/msb-rahmenvertraege-gaswrite-msb-rv-gasUpsert a Gas MSB-Rahmenvertrag conclusion (GeLi Gas 3.0 Tenor 13–16: KoV XV Anlage 8 from 01.10.2026; status=anpassung_erforderlich tracks the BK7-17-026 migration duty). Idempotent on the natural key (tenant, gnb_mp_id, msb_mp_id, valid_from); optimistic version (mismatch → 412); rejects valid_to < valid_from. Emits de.markt.msb-rahmenvertrag-gas.updated (incl. version, valid_from, valid_to, signed_at)
GET/api/v1/msb-rahmenvertraege-gasread-msb-rv-gasList Gas MSB framework contracts (?msb_mp_id=&status=)
GET/api/v1/msb-rahmenvertraege-gas/{id}read-msb-rv-gasGet one Gas MSB framework contract
GET/api/v1/neloread-neloList NeLos (?nb_mp_id= filters by Netzbetreiber)
GET/api/v1/nelo/{id}read-neloGet a NeLo by EIC / BDEW Codenummer
PUT/api/v1/nelo/{id}write-nelo (NB role)Insert or update a NeLo
GET/api/v1/trancheread-trancheList Tranchen (?malo_id= filters by parent MaLo)
GET/api/v1/tranche/{id}read-trancheGet a Tranche
PUT/api/v1/tranche/{id}write-tranche (NB role)Insert or update a Tranche (GPKE Teil 4 „Daten der Tranche")
GET/api/v1/malo/{malo_id}/gridread-maloMaLo grid topology (Netzgebiet, Bilanzierungsgebiet)
PUT/api/v1/malo/{malo_id}/gridwrite-malo (NB role)Upsert grid record from NIS/GIS
GET/api/v1/preisblaetter-messung/{msb_mp_id}read-preisblattPreisblattMessung valid on date (MSB metering tariffs); includes auf_abschlaege
PUT/api/v1/preisblaetter-messung/{msb_mp_id}write-preisblattUpsert MSB metering price sheet
GET/api/v1/steuerbare-ressourcen/{sr_id}read-srGet a SteuerbareRessource by SR-ID
PUT/api/v1/steuerbare-ressourcen/{sr_id}write-srUpsert a SteuerbareRessource
GET/api/v1/steuerbare-ressourcen/{sr_id}/konfigurationsprodukteread-srList Konfigurationsprodukte (§14a steuerbare Verbrauchseinrichtungen)
GET/api/v1/technische-ressourcen/{tr_id}read-deviceGet a TechnischeRessource by TrId
PUT/api/v1/technische-ressourcen/{tr_id}write-deviceUpsert a TechnischeRessource (E-mobility, generation, storage)
GET/api/v1/malos/{malo_id}/technische-ressourcenread-deviceList TechnischeRessource for a MaLo
GET/api/v1/malo/{id}/lokationenread-maloRecursive Lokationszuordnung graph from a MaLo (?at=YYYY-MM-DD)
GET/api/v1/malos/{id}/buendelread-maloFirst-class Lokationsbündel rooted at a MaLo — the bundle projected from the typed graph plus its structural-integrity status (valid + validation_error; ≥1 MeLo required)
GET/api/v1/melos/{id}/lokationenread-meloRecursive Lokationszuordnung graph from a MeLo
PUT/api/v1/lokationszuordnungenwrite-maloUpsert a directed location graph edge (lokationsbuendelcode extracted into a typed column). Note the single-write-path invariant: a MeLo PUT reconciles the melo→malo graph edge in the same transaction (previous edges closed with valid_to, never deleted), so the melo.malo_id FK and the graph cannot drift
DELETE/api/v1/lokationszuordnungen/{von_id}/{nach_id}write-maloHard-delete an edge pair (all temporal variants)
GET/api/v1/melos/{melo_id}/zaehlerread-deviceList Zaehler for a MeLo (typed Vec<ZaehlerResponse> with data: rubo4e::current::Zaehler)
GET/api/v1/melos/{melo_id}/msbread-melo-msbThe MSB responsible for the MeLo on ?at=YYYY-MM-DD (default today) — WiM Teil 2 UC 4.1.1 historical Werteanfrage routing
PUT/api/v1/melos/{melo_id}/msbwrite-melo-msbRecord a dated MSB assignment ({ msb_mp_id, valid_from }); closes the previously-open assignment atomically
GET/api/v1/melos/{melo_id}/msb/historyread-melo-msbFull dated MSB timeline for the MeLo (newest first)
PUT/api/v1/malo/{malo_id}/bilanzierungwrite-bilanzierungUpsert a BO4E Bilanzierung (BO #3) — type-validated, keyed on (malo, bilanzierungsbeginn); typed columns (Bilanzkreis/Aggregationsverantwortung/Prognosegrundlage/Fallgruppe) extracted, full BO stored as JSONB
GET/api/v1/malo/{malo_id}/bilanzierungread-bilanzierungThe Bilanzierung effective at ?at=<RFC3339|YYYY-MM-DD> (default now) — point-in-time by validity window
GET/api/v1/malo/{malo_id}/bilanzierung/historyread-bilanzierungFull Bilanzierung history for the MaLo (newest validity-start first)
GET/api/v1/melos/{melo_id}/sharing-eligibilityread-sharing-eligibility§42c EnWG metering capability — qualifies via Zählerstandsgangmessung (§2 Satz 1 Nr. 27 MsbG) or viertelstündliche RLM. Returns capability, basis, required_action, reasons, bilanzierungsgebiet, and the master-data evidence it decided from.
GET/api/v1/zaehler/{zaehler_id}/zaehlwerkeread-deviceList Zaehlwerk registers for a Zaehler (typed Vec<Zaehlwerk> from JSONB)
PUT/api/v1/zaehler/{zaehler_id}write-deviceUpsert a Zaehler; validates _typ = ZAEHLER and schema (422 on violation)
GET/api/v1/zaehler/{zaehler_id}/geraeteread-deviceList Geraete for a Zaehler (typed Vec<GeraetResponse> with data: rubo4e::current::Geraet + konfigurationen: Vec<GeraetKonfiguration>)
GET/api/v1/zaehler/{zaehler_id}/geraete/{geraet_id}read-deviceGet a single Geraet — full BO4E payload + konfigurationen; 404 when not found
GET/PUT/api/v1/zaehler/{zaehler_id}/geraete/{geraet_id}/konfigurationenread-device / write-deviceGet or atomically replace typed GeraetKonfiguration entries (MsbG §23); PUT emits de.markt.geraet.konfiguration.updated
GET/PUT/api/v1/zaehler/{zaehler_id}/registerread-device / write-deviceList/upsert iMSys TOU registers (ZaehlzeitRegister)
GET/PUT/api/v1/zaehler-register/{register_id}/saisonsread-device / write-deviceList/upsert seasonal TOU windows (ZaehlzeitSaison)
GET/api/v1/zaehler/{zaehler_id}/tariff-zoneread-deviceResolve HT/NT/EINZEL tariff zone for a given local datetime
GET/api/v1/zaehler/{zaehler_id}/zaehlzeitdefinitionenread-deviceReturn typed rubo4e::current::Zaehlzeitdefinition assembled from zaehler_register + zaehler_saisons; ?valid_only=true filters to current registers
PUT/api/v1/geraete/{geraet_id}write-deviceUpsert a Geraet; validates _typ = GERAET and schema (422 on violation)
GET/api/v1/nb-contracts/{id}read-nb-contractGet NB network contract with typed BO4E Vertrag payload
PUT/api/v1/nb-contracts/{id}write-nb-contractUpsert NB network contract; validates Vertrag _typ and enums (422 on violation); emits de.markt.nb-contract.updated
GET/api/v1/nb-contractsread-nb-contractList NB contracts (?nb_mp_id=... required)
POST/api/v1/eventsInbound CloudEvent from makod (HMAC-verified); appended to event_log before fan-out
GET/admin/fanout/dlqmanage-fanoutList unresolved DLQ entries
POST/admin/fanout/dlq/{event_id}/{subscriber_id}/retrymanage-fanoutRe-deliver a dead-lettered delivery
DELETE/admin/fanout/dlq/{event_id}/{subscriber_id}manage-fanoutDiscard a dead-lettered delivery
GET/admin/eventsmanage-fanoutCloudEvent replay log — ?from=RFC3339&to=RFC3339&type=&limit=
GET/metricsPrometheus metrics (no auth, internal only)

Price Sheets — PreisblattNetznutzung

Price sheets record the Netznutzungspreise for a Netzbetreiber. Validity is derived from the BO4E gueltigkeit.startdatum / gueltigkeit.enddatum fields inside the JSON payload.

PUT request body

{
  "data": {
    "bo_typ": "PREISBLATT_NETZNUTZUNG",
    "bezeichnung": "Netznutzungspreise 2025 — 9900357000004",
    "gueltigkeit": { "startdatum": "2025-10-01", "enddatum": "2026-09-30" },
    "marktteilnehmer": {
      "bo_typ": "MARKTTEILNEHMER",
      "marktrolle": "NB",
      "rollencodenummer": "9900357000004",
      "rollencodetyp": "BDEW"
    },
    "preispositionen": [ ... ]
  },
  "bo4e_version": "v202607.0.0"
}

GET response

{
  "data":         { "bo_typ": "PREISBLATT_NETZNUTZUNG", ... },
  "source":       "api",
  "bo4e_version": "v202607.0.0",
  "updated_at":   "2025-10-01T08:15:00Z",
  "zeitvariable_preispositionen": [
    {
      "bezeichnung": "HT-Arbeitspreis",
      "einheit": "CT_PRO_KWH",
      "bezugsgroesse": "STUNDE",
      "zeitfenster": { "startzeit": "07:00", "endzeit": "21:00" },
      "preis": "8.35"
    }
  ]
}

zeitvariable_preispositionen contains the ZeitvariablePreisposition array from the BO4E payload. If the price sheet has no ToU tariffs the field is omitted (serialized with #[serde(skip_serializing_if = "Vec::is_empty")]). This array is consumed by netzbilanzd for §14a Modul 2 ToU billing (BNetzA BK6-22-300).

Query parameter: ?date=YYYY-MM-DD (defaults to today in CET/CEST).

Source field

Every price sheet row carries a source field:

SourceSet bySemantics
apiREST PUT /api/v1/preisblaetter/{nb_mp_id}Operator-supplied via REST API or ERP export
makoPRICAT ingest path (invoicd/makod)Received as EDIFACT from the NB

Operator-override rule: an api entry always supersedes a mako entry for the same NB GLN and validity period. A price sheet uploaded via the REST API cannot be silently overwritten by an incoming EDIFACT PRICAT.

Enforced in SQL:

ON CONFLICT (nb_mp_id, valid_from)
DO UPDATE SET data = EXCLUDED.data, ...
WHERE preisblaetter.source <> 'api' OR EXCLUDED.source = 'api';

PRICAT 27003 dispatch pipeline

Every PUT /api/v1/preisblaetter/{nb_mp_id} call:

  1. Writes or updates the current price sheet in preisblaetter (existing behaviour)
  2. Inserts a versioned snapshot in pricat_versions keyed on (nb_mp_id, tenant, valid_from)
  3. Emits de.markt.pricat.published → fan-out to ERP webhook subscribers
  4. A background task dispatches PRICAT 27003 per active LF partner via MakodClient

The dispatch audit log (pricat_dispatch_log) records every outbound dispatch attempt (NB × LF pair) with outcome and makod process ID.

sequenceDiagram
    participant ERP
    participant marktd
    participant pricat_versions
    participant MakodClient
    participant AS4

    ERP->>marktd: PUT /api/v1/preisblaetter/{nb_mp_id}
    marktd->>preisblaetter: UPSERT (existing)
    marktd->>pricat_versions: UPSERT versioned snapshot
    marktd-->>ERP: 204 No Content
    marktd-)fanout: de.markt.pricat.published
    Note over marktd: background task
    marktd->>pricat_versions: mark_queued(version_id)
    loop per active LF partner
        marktd->>MakodClient: POST /api/v1/commands dispatch-pricat-27003
        MakodClient->>AS4: PRICAT 27003
        marktd->>pricat_dispatch_log: log(lf_mp_id, outcome)
    end
    marktd->>pricat_versions: mark_done(version_id)

Auto-dispatch on LF partner registration: when PUT /api/v1/partners/{mp_id} registers a partner with marktrolle = "LF", the latest PRICAT version for the operator's NB GLN is automatically re-queued for dispatch to the new partner.

Manual re-dispatch: POST /api/v1/pricat/{nb_mp_id}/dispatch resets dispatch state to queued so the background task picks it up again. Use this after AS4 outages or to force distribution to newly on-boarded partners.

Dispatch states:

StateMeaning
pendingVersion stored; no dispatch started yet
queuedDispatch task picked this version up
doneAll active LF partners successfully reached
errorLast dispatch attempt failed; will be retried on next poll

Trading Partners — Geschaeftspartner BO4E

marktd stores trading partners in the partners table, keyed by mp_id (BDEW-Codenummer or DVGW-Codenummer). Every PUT validates and normalises the partner payload as rubo4e::current::Geschaeftspartner.

Schema validation on PUT

The PUT /api/v1/partners/{mp_id} handler:

  1. Auto-injects "_typ": "GESCHAEFTSPARTNER" when absent.
  2. Rejects 422 when _typ is wrong.
  3. Validates all enum fields (marktrolle, rollencodetyp, marktteilnehmerstatus, adresse) via rubo4e::current::Geschaeftspartner.
  4. Re-serialises to canonical BO4E camelCase before storage.
# Register a trading partner (LF, validated as Geschaeftspartner)
curl -s -X PUT "http://marktd:8180/api/v1/partners/9904234560001" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "channels": {
      "marktrolle": "LF",
      "rollencodetyp": "BDEW",
      "marktteilnehmerstatus": "AKTIV",
      "adresse": {
        "strasse": "Musterstraße",
        "hausnummer": "1",
        "postleitzahl": "10115",
        "ort": "Berlin",
        "landescode": "DE"
      }
    }
  }'

Typed GET response

GET /api/v1/partners/{mp_id} returns a structured response with a geschaeftspartner field containing the typed rubo4e::current::Geschaeftspartner payload:

{
  "mp_id":   "9904234560001",
  "display_name": "Muster Energieversorgung GmbH",
  "marktrolle": "LF",
  "rollencodetyp": "BDEW",
  "makoadresse": ["https://as4.muster-ev.de/as4/in"],
  "geschaeftspartner": {
    "_typ": "GESCHAEFTSPARTNER",
    "marktrolle": "LF",
    "rollencodetyp": "BDEW",
    "marktteilnehmerstatus": "AKTIV",
    "adresse": { "strasse": "Musterstraße", "hausnummer": "1", ... }
  },
  "version": 3,
  "updated_at": "2026-07-11T09:15:00Z"
}

Legacy partner records written before schema validation was introduced are returned with the raw channels JSONB in the geschaeftspartner field (graceful fallback).


Database Schema

marktd uses a single SQL schema file (migrations/0001_initial.sql). Migrations run automatically at startup via sqlx migrate run.

Tables

TablePurpose
maloMarktlokationen — JSONB payload, bo4e_version, GIN index
rollenzuordnungenTemporal NB/LF/MSB role assignments per MaLo
melo_msb_zuordnungenPer-MeLo dated MSB timeline(tenant, melo_id, msb_mp_id, valid_from, valid_to); point-in-time MSB resolution for WiM Teil 2 UC 4.1.1 (a MaLo can bundle MeLos with divergent MSB history)
bilanzierungenBO4E Bilanzierung (BO #3) — first-class temporal balancing resource per MaLo: bilanzierungsbeginn/ende validity, typed bilanzkreis/aggregationsverantwortung/prognosegrundlage/fallgruppenzuordnung, full BO in data JSONB. Authoritative home for the Bilanzierung-model fields. Writing a currently-effective Bilanzierung derives malo.fallgruppe. Note: malo.bilanzierungsmethode/bilanzierungsgebiet are Marktlokation fields (BO #12), correctly on malo — not Bilanzierung
lokationszuordnungenLocation graph edges — (tenant, von_id, von_typ, nach_id, nach_typ, valid_from, valid_to); von_typ/nach_typ are the canonical BO4E Lokationstyp codes (MALO/MELO/NELO/SR/TR); recursive-CTE BFS traversal
meloMesslokationen — JSONB payload, bo4e_version
partnersTrading partners (GLN → channels) — JSONB
subscriptionsERP webhook registrations
process_correlationRunning/completed MaKo process tracking per MaLo
processed_eventsInbound event idempotency log
preisblaetterNB price sheets — source CHECK ('api','mako'), GIN index
preisblaetter_messungMSB metering price sheets — same source-override protection
versorgungsstatusVersorgungsStatus per MaLo — LieferStatus CHECK, optimistic concurrency version BIGINT
versorgungsstatus_historyAppend-only audit log of every supply-state transition — powers ?at= and /history
nb_contractsNB network contracts — typed SQL columns (netzebene, bilanzierungsmethode, billing_schedule) + full BO4E Vertrag JSONB (data) for ERP digital LRV exchange
pricat_versionsVersioned PRICAT snapshots — (nb_mp_id, tenant, valid_from) unique, dispatch state
pricat_dispatch_logDispatch audit log — one row per NB × LF dispatch attempt
neloNetz-Element-Lokationen (Redispatch 2.0) — EIC or BDEW Codenummer, owner NB GLN, JSONB data
trancheTranchen der Marktlokation (GPKE Teil 4 „Daten der Tranche") — keyed by (tranche_id, tenant), parent malo_id; bilanzierungsgebiet/netzebene/energierichtung typed columns + BO4E Tranche JSONB
malo_gridMaLo grid topology — Netzgebiet, Bilanzierungsgebiet, sourced from NIS/GIS
steuerbare_ressourcenWiM iMS controllable resources — keyed by SR-ID (C[A-Z0-9]{9}[0-9]), linked to MaLo; konfigurationsprodukte JSONB for contracted iMS control products
technische_ressourcenE-mobility, generation, storage resources — keyed by TrId; BO4E-aligned nutzung (TechnischeRessourceNutzung) + verbrauchsart (TechnischeRessourceVerbrauchsart) + ist_fernschaltbar typed columns; linked to MaLo/MeLo
zaehlerMeter registry — linked to MeLo; zaehler_typ (CHECK-constrained to BO4E Zaehlertyp), eichung_bis typed columns; BO4E payload with zaehlwerke array
geraeteDevice registry — linked to Zaehler, stores geraet_typ, BO4E payload, and geraet_konfigurationen JSONB (typed GeraetKonfiguration[] per MsbG §23; GIN-indexed for cert-expiry queries)
event_logDurable CloudEvent replay log — keyed by event_id (unique); indexed by ce_type + received_at

All columns listed below are part of the single migrations/0001_initial.sql file. There are no incremental migration files — the initial schema is the authoritative source.

Key typed columns on malo

ColumnTypeDescription
netzebeneTEXTNetzebene (voltage/pressure level) — drives NNE tariff selection
bilanzierungsgebietTEXTBilanzierungsgebiet code for EEG/KWK allocation
energierichtungTEXTAUSSP (consumption) / EINSP (generation)
gasqualitaetTEXTGas quality (H/L)
bilanzierungsmethodeTEXTRLM | SLP | IMS | TLP_*; drives netzbilanzd Leistungspreis routing
regelzoneTEXTRegelzone EIC code — maps to ÜNB for Redispatch 2.0 + MABIS
fallgruppeTEXTGaBi Gas RLM category (e.g. LNF, LF, TK)
fernsteuerbarBOOLEAN§14a EnWG „Status der Fernsteuerbarkeit" — true = technisch fernsteuerbar, false = nicht (UTILMD CCI+7037 Z97/Z96)

Key typed columns on melo

ColumnTypeDescription
netzebene_messungTEXTNetzebene where the meter is installed
regelzoneTEXTRegelzone EIC code — extracted from standorteigenschaften.eigenschaftenStrom[0].regelzone
standorteigenschaftenJSONBFull StandortEigenschaften object (GIN indexed) for WiM Stammdaten enrichment

NB Network Contracts — Vertrag BO4E

NB network contracts are stored in nb_contracts as both fast-query typed SQL columns (netzebene, bilanzierungsmethode, billing_schedule, valid_from, valid_to) and a full BO4E Vertrag JSON payload for ERP digital LRV exchange.

vertragsart and vertragsstatus are extracted from the payload as indexed columns, enabling SQL-level filtering. A de.markt.nb-contract.updated CloudEvent is emitted on every successful upsert so ERP subscribers can rebuild Vertrag caches without polling.

PUT /api/v1/nb-contracts/{contract_id}

{
  "malo_id":             "51238696780",
  "nb_mp_id":            "9900357000004",
  "sparte":              "STROM",
  "netzebene":           "NS",
  "bilanzierungsmethode": "SLP",
  "billing_schedule":    "MONTHLY",
  "valid_from":          "2026-10-01",
  "valid_to":            null,
  "data": {
    "_typ":            "VERTRAG",
    "vertragsart":     "NETZNUTZUNGSVERTRAG",
    "vertragsstatus":  "AKTIV",
    "sparte":          "STROM",
    "vertragsbeginn":  "2026-10-01T00:00:00+00:00"
  }
}

data is optional — if omitted, a minimal Vertrag is auto-constructed from the other fields (vertragsart = NETZNUTZUNGSVERTRAG, vertragsstatus = AKTIV).

Validation: _typ must be "VERTRAG" (422 if wrong). All enum fields (vertragsart, vertragsstatus) are validated against rubo4e::current::Vertrag.

GET /api/v1/nb-contracts/{contract_id} response

{
  "contract_id":         "nv-9900357000004-51238696780",
  "malo_id":             "51238696780",
  "nb_mp_id":            "9900357000004",
  "sparte":              "STROM",
  "netzebene":           "NS",
  "bilanzierungsmethode": "SLP",
  "billing_schedule":    "MONTHLY",
  "valid_from":          "2026-10-01",
  "valid_to":            null,
  "data": {
    "_typ":           "VERTRAG",
    "vertragsart":    "NETZNUTZUNGSVERTRAG",
    "vertragsstatus": "AKTIV",
    "sparte":         "STROM",
    "vertragsbeginn": "2026-10-01T00:00:00+00:00"
  },
  "vertragsart":    "NETZNUTZUNGSVERTRAG",
  "vertragsstatus": "AKTIV",
  "version":        1,
  "tenant":         "9900357000004"
}

netzebene accepts all Strom voltage levels (NS/MS/MSP/HSP/HS/HöS/HöS/HS) and Gas pressure levels (GND/GMT/GHD). bilanzierungsmethode accepts RLM, SLP, IMS, and TLP variants.


Inbound Events from makod

marktd receives process lifecycle events from makod via POST /api/v1/events.

Enable push in makod config

# makod.toml
[erp]
webhook_url    = "http://marktd:8180/api/v1/events"
webhook_secret = "shared-hmac-secret"

Inbound delivery is idempotent — duplicates are detected by event_id and silently acknowledged without re-processing.

Automatic malo.bilanzierungsmethode + malo.fallgruppe update

When marktd receives de.mako.process.initiated for PIDs 55001 (GPKE) or 44001 (GeLi Gas), it calls MaloRepository::patch_typenmerkmal() to update the malo table:

Payload fieldColumnSource
bilanzierungsmethodemalo.bilanzierungsmethodeUTILMD TM+EM segment (Z01→SLP, Z02→RLM, Z04→IMS) extracted by makod adapter
fallgruppemalo.fallgruppeUTILMD TM+Z10 segment (Gas GaBi RLM category) extracted by makod adapter

This keeps the MaLo's billing mode and GaBi Gas Fallgruppe in sync with the UTILMD without requiring a separate ERP PUT /api/v1/malo call. The update is best-effort: if the MaLo row does not yet exist, the patch silently no-ops (the values will be set on the first PUT /api/v1/malo).

# After a 55001 Anmeldung, verify the update:
curl -s "http://marktd:8180/api/v1/malo/10001234567" \
  -H "Authorization: Bearer <token>" | jq '.bilanzierungsmethode, .fallgruppe'
# → "SLP", null     (for a Strom SLP point)
# → "RLM", "Z01"   (for a Gas RLM point with GaBi category Z01)

PUT /api/v1/malo — MaLo Typed Columns & Schema Validation

Every PUT /api/v1/malo/{malo_id} call:

  1. Validates the incoming data payload as rubo4e::current::Marktlokation:
    • Auto-injects _typ: "MARKTLOKATION" if absent
    • Returns 422 if _typ is present but not MARKTLOKATION
    • Returns 422 if any typed field contains an unknown enum value (e.g. "bilanzierungsmethode": "UNKNOWN")
  2. Normalises to canonical camelCase BO4E form before storage (non-standard keys like fallgruppenzuordnung are preserved via the _additional extension map)
  3. Extracts typed columns for efficient SQL queries:
ColumnSource fieldPurpose
netzebenedata.netzebeneVoltage/pressure level for NNE billing tier
bilanzierungsgebietdata.bilanzierungsgebietEIC code; drives processd NB check 4
gasqualitaetdata.gasqualitaetHGas | LGas; Gas tariff routing
energierichtungdata.energierichtungAussp = generation, Einsp = consumption
bilanzierungsmethodedata.bilanzierungsmethodeRLM | SLP | IMS | TLP_*; drives netzbilanzd Leistungspreis routing — RLM requires spitzenleistung_kw
regelzonedata.regelzoneRegelzone EIC code → maps MaLo to ÜNB for MABIS IFTSTA 21000 routing and Redispatch 2.0 Stammdaten forwarding

All columns are NULL when the BO4E payload does not carry the field.

The call also automatically pushes the NB and MSB GLNs to makod's MaLo cache via PUT /admin/malo/{malo_id} — fire-and-forget; makod failure does not fail the API call.

Fields forwarded to makod:

FieldSource
nb_mp_idrollenzuordnung[] entry with zuordnungstyp == "NB" or "GNB"
msb_mp_idrollenzuordnung[] entry with zuordnungstyp == "MSB" or "GMSB"
bilanzierungsgebietdata.bilanzierungsgebiet
netzgebietdata.netzgebietsnummer or data.netzgebiet
spartesparte field

MaloResponse (GET) exposes all typed columns as top-level fields alongside the raw data JSONB for backward compatibility:

{
  "malo_id": "10001234567",
  "sparte": "STROM",
  "version": 3,
  "netzebene": "NS",
  "bilanzierungsgebiet": "11YDE-RWE-NETZ-Y",
  "gasqualitaet": null,
  "energierichtung": "Einsp",
  "bilanzierungsmethode": "SLP",
  "regelzone": "10YDE-EON------1",
  "rollenzuordnung": [...],
  "data": { "_typ": "MARKTLOKATION", ... }
}

ERP Subscriptions & Fan-Out

marktd delivers CloudEvents 1.0 to every matching ERP subscriber when master data changes or when makod lifecycle events arrive. The fan-out worker runs in a dedicated Tokio task and delivers independently per subscriber — a slow or unavailable ERP does not block other subscribers.

Event types

SourceEvent typeTrigger
marktd master datade.markt.malo.updatedPUT /api/v1/malo/{malo_id}
marktd master datade.markt.malo.stammdaten-geaendertUTILMD Stammdatenänderung applied to a MaLo (GPKE Teil 4 / GeLi Gas) — carries the applied patch
marktd master datade.markt.stammdaten.geaendertUTILMD Stammdatenänderung applied to a non-MaLo object (MeLo/NeLo/Tranche) — carries objekt + the applied patch
marktd master datade.markt.partner.updatedPUT /api/v1/partners/{mp_id}
marktd NB contractde.markt.nb-contract.updatedPUT /api/v1/nb-contracts/{id} — carries vertragsart, version, tenant in data
marktd PRICATde.markt.pricat.publishedPUT /api/v1/preisblaetter/{nb_mp_id}
marktd supplyde.markt.versorgung.changedany VersorgungsStatus transition (announce/confirm/end/clear), incl. PIDs 55002/44002
marktd supplyde.markt.versorgung.gap-detected55005/44005 completed with no announced successor — §38 EnWG gap-closure trigger (consumer: processd)
marktd supplyde.markt.versorgung.eog-begonnen55013/44013 completed → begin_eog_supply (Ersatz-/Grundversorgung active; consumer: processd)
makod process relayde.mako.process.initiatedforwarded from makod ingest
makod process relayde.mako.aperak.acceptedforwarded from makod ingest
makod process relayde.mako.aperak.rejectedforwarded from makod ingest
makod process relayde.mako.aperak.timeoutforwarded from makod ingest
makod process relayde.mako.process.completedforwarded from makod ingest
makod process relayde.mako.process.failedforwarded from makod ingest
makod process relayde.mako.edifact.inboundforwarded from makod ingest

de.mako.* events carry the CloudEvents extensions makoconvid, makopid, makoworkflow, and marktrole (role of the counterparty: NB, LF, MSB, BIKO). Downstream services (invoicd, edmd, obsd) filter on makopid to select only the event types they care about.

Register a subscription

curl -X POST http://localhost:8180/api/v1/subscriptions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint_url": "https://erp.example.com/mdm/events",
    "secret":       "mysecret64hexchars",
    "event_types":  ["de.markt.malo.updated", "de.markt.pricat.published",
                     "de.mako.process.completed"]
  }'

event_types entries are matched with the canonical shared matcher (mako_events::matches — the same one agentd trigger patterns use): exact types, trailing-* prefixes (de.markt.*), full mid-pattern globs (de.*.rechnung.erstellt) and ? single-character wildcards all work; an empty list subscribes to everything.

Webhook payload

POST https://erp.example.com/mdm/events
Content-Type: application/cloudevents+json
X-Mako-Signature: sha256=<hmac-sha256-hex>
{
  "specversion":     "1.0",
  "id":              "01932a4f-7b3e-4c5d-8f6a-9e0b1c2d3e4f",
  "source":          "urn:mako:marktd:tenant:9900357000004",
  "type":            "de.mako.process.completed",
  "time":            "2025-10-01T08:15:00+02:00",
  "subject":         "018f3a2b-7c4e-7d5f-8a9b-0c1d2e3f4a5b",
  "datacontenttype": "application/json",
  "makoconvid":      "018f3a2b-7c4e-7d5f-8a9b-0c1d2e3f4a5b",
  "makopid":         55001,
  "makoworkflow":    "gpke-lieferbeginn",
  "marktrole":       "LF",
  "data": { "_typ": "MARKTLOKATION", "marktlokationsId": "51238696780", ... }
}

Signature verification

X-Mako-Signature carries an HMAC-SHA256 hex digest over the raw request body, prefixed with sha256=, computed with the secret registered in the subscription (the workspace-wide format emitted by mako_service::webhook::sign).

The subscription secret is an integrity key stored in plaintext in subscriptions.webhook_secret — protect it with least-privilege database grants and storage-level encryption; it never protects confidentiality of customer data.

import hmac, hashlib

def verify(body: bytes, secret: str, header: str) -> bool:
    received = header.removeprefix("sha256=")          # strip the algorithm prefix
    expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

Return 200 OK for duplicates — fan-out retries on non-2xx.

Durability & retry behaviour

Fan-out is persist-before-fan-out. Every produced event is written to the durable event_log outbox (the full CloudEvent envelope) before any delivery is attempted, so a marktd crash never loses an in-flight event. A two-phase worker drains it:

  1. Fan-out — claims pending event_log rows, resolves the matching subscribers, and snapshots one event_delivery row per subscriber (in one transaction with stamping fanned_out_at).
  2. Deliver — claims due event_delivery rows with a lease (FOR UPDATE SKIP LOCKED), signs + POSTs each, and on failure backs off (30 s → 5 m → 30 m → 2 h) or, after the attempt cap, marks dead_lettered_at.

A crash at any boundary is recoverable from the two tables; subscribers receive at-least-once and dedup on the CloudEvent id. This durability is required by § 147 AO / GoBD / §41 EnWG: a silent drop of a de.mako.process.initiated event to invoicd would mean the INVOIC plausibility check never runs.

Dead-letter queue (DLQ)

A delivery that exhausts its attempts is flagged dead_lettered_at on its event_delivery row (a status-column DLQ — no separate table). Operators inspect and remediate via the admin endpoints, keyed by (event_id, subscriber_id):

MethodPathDescription
GET/admin/fanout/dlqList dead-lettered deliveries (newest first, paged)
POST/admin/fanout/dlq/{event_id}/{subscriber_id}/retryRequeue for immediate redelivery
DELETE/admin/fanout/dlq/{event_id}/{subscriber_id}Discard without retry
# Inspect dead-lettered deliveries
curl http://localhost:8180/admin/fanout/dlq \
  -H "Authorization: Bearer $TOKEN" | jq '.[] | {event_id, subscriber_id, attempts, last_error}'

# Requeue a specific (event, subscriber) delivery
curl -X POST http://localhost:8180/admin/fanout/dlq/$EVENT_ID/$SUBSCRIBER_ID/retry \
  -H "Authorization: Bearer $TOKEN" | jq .

# Discard after manual ERP re-import
curl -X DELETE http://localhost:8180/admin/fanout/dlq/$EVENT_ID/$SUBSCRIBER_ID \
  -H "Authorization: Bearer $TOKEN"

Requeuing redelivers with the subscriber's current webhook secret.

Prometheus metrics (/metrics)

GET /metrics exposes operational counters in Prometheus text format:

MetricDescription
marktd_fanout_dlq_depthDead-lettered deliveries in event_delivery
marktd_active_subscriptionsRegistered EventBus subscribers
marktd_processed_events_totalEvents ingested from makod (all time)
marktd_db_pool_sizeCurrent PostgreSQL connection pool size
marktd_db_pool_idleIdle connections in the pool

Scrape via Prometheus static_configs or a ServiceMonitor in Kubernetes.


Process Correlations

Track which MaKo processes are currently running against a given MaLo:

curl "http://localhost:8180/api/v1/correlations/51238696780" \
  -H "Authorization: Bearer $TOKEN"
[
  {
    "malo_id":      "51238696780",
    "pid":          55001,
    "conv_id":      "018f3a2b-...",
    "initiated_at": "2026-07-01T08:00:00Z",
    "status":       "RUNNING"
  }
]

Docker Deployment

docker pull ghcr.io/hupe1980/marktd:0.12.0

docker run -d \
  --name marktd \
  -p 8180:8180 \
  -v /etc/marktd/marktd.toml:/etc/marktd/marktd.toml:ro \
  -e DATABASE_URL=postgres://marktd:secret@postgres/marktd \
  -e MAKOD_API_KEY=my-api-key \
  -e MAKOD_WEBHOOK_SECRET=my-webhook-secret \
  ghcr.io/hupe1980/marktd:0.12.0 \
  --config=/etc/marktd/marktd.toml

Health Checks

EndpointDB checkUse for
GET /health/livenoKubernetes livenessProbe
GET /health/readyyes (ping)Kubernetes readinessProbe

Common Issues

401 Unauthorized JWT validation failed. Check: correct --auth-issuer, token not expired, mako_tenant claim present.

403 Forbidden Cedar denied the request. Check: mako_tenant matches tenant GLN in URL, mako_roles contains required role (NB for write-preisblatt).

404 Not Found on GET preisblatt No price sheet valid on the requested date. Upload one first with PUT /api/v1/preisblaetter/{nb_mp_id}.

Price sheet not updating (mako source rejected) Intentional. An existing source=api row cannot be overwritten by source=mako — operator-override protection is working. Use the REST API to update operator-controlled price sheets.

Auto-responder dispatching ablehnen for all requests Check rule 3 (NB in grid): your operator MP-ID (tenant in marktd.toml) must appear in the MaLo's rollenzuordnung as zuordnungstyp = "NB". Upload the MaLo with PUT /api/v1/malo/{malo_id} including the NB entry.

Auto-responder deferring all requests (no commands dispatched) Check rule 1 (MaLo exists): the MaLo referenced in the UTILMD has not been pre-loaded into marktd. Use PUT /api/v1/malo/{malo_id} to register it.

Auto-responder rejecting with Z0C (Preisblatt missing) Only triggered when auto_accept = true. Upload a price sheet covering the process_date with PUT /api/v1/preisblaetter/{nb_mp_id}. Check that gueltigkeit.startdatum and gueltigkeit.enddatum in the BO4E payload bracket the process_date from the UTILMD.

relation "malo" does not exist Migrations have not run. Check DATABASE_URL and PostgreSQL connectivity. marktd runs sqlx migrate run automatically on startup.


MCP Server

marktd exposes an MCP (Model Context Protocol) Streamable HTTP server at POST /mcp / GET /mcp. The same OIDC + Cedar authorization layer applies.

Tools (18) — plus get_info for a self-describing capability summary:

ToolDescription
get_malo / list_maloFetch one MaLo / list MaLos (Sparte, Netzgebiet, NB)
get_melo / get_melo_standorteigenschaftenMeLo record / site properties
get_partner / list_partnersMarket-partner registry (MP-ID, name, roles)
get_preisblattPrice sheet for an NB MP-ID and date (Netznutzung)
list_pricat_versions / dispatch_pricatPRICAT version history / dispatch
get_versorgungsstatus / get_versorgungsstatus_history / get_versorgung_atSupply state — current, history, point-in-time
get_rollenzuordnungTemporal NB/MSB/LF role assignments for a MaLo
get_nb_contractNB contract record for a MaLo
get_nb_energiemix§42 EnWG grid-area energy mix
get_correlationProcess correlation for a MaLo
get_technische_ressource / get_steuerbare_ressource§14a device registry

Prompts (4): lookup_malo, versorgungswechsel, investigate_supply_gap, grid_topology — guided multi-tool investigations for LLM clients.


VersorgungsStatus

marktd maintains one VersorgungsStatus record per MaLo, derived automatically from inbound CloudEvents. Every write appends a row to versorgungsstatus_history in the same transaction, enabling full audit trails and ?at=YYYY-MM-DD point-in-time queries.

Supplier-transition lifecycle

A Lieferantenwechsel spans three distinct phases, each triggering a targeted partial update:

PhaseCloudEventPIDOperationEffect
Announceprocess.initiated55001 / 44001announce_lf_nextSets lf_mp_id_next (WHO) + lf_next_lieferbeginn (WHEN). Does not change lieferstatus.
Confirmprocess.completed55002 / 44002confirm_supplyAtomic SQL: lf_mp_id ← lf_mp_id_next, lieferbeginn ← lf_next_lieferbeginn, lieferstatus = Beliefert, clears lf_mp_id_next.
Endprocess.completed55005 / 44005end_supplylieferstatus = Unbeliefert, clears lf_mp_id/lieferbeginn/eog_seit — preserves lf_mp_id_next if another transition is already announced. No successor → emits de.markt.versorgung.gap-detected.
EoGprocess.completed55013 / 44013begin_eog_supplylieferstatus = Ersatzversorgung/Grundversorgung (per data.eog_art), lf_mp_id = E/G, eog_seit = Zuordnungsbeginn (may be retroactive — anchors §38 Abs. 4). Resolves the Bilanzkreis from the completion payload, else the NB's deposited default_bilanzkreis (EoG ohne Antwort). Emits de.markt.versorgung.eog-begonnen (incl. bilanzkreis).
Stammdatenänderungprocess.completedGPKE Teil 4 / GeLi Gas Änderung PIDspatch_stammdatenObject-generic apply. Dispatches by the data.objekt marker to the matching typed-column patch — MARKTLOKATIONmalo (incl. §14a fernsteuerbar), MESSLOKATIONmelo + the MSB-Zuordnung (zugeordneter MSB CAV+7111=Z91) recorded on the dated melo_msb_zuordnungen timeline via assign_msb effective the Änderungsdatum, NETZLOKATIONnelo (incl. §14a steuerkanal), TECHNISCHE_RESSOURCEtechnische_ressourcen (nutzung CCI+7059 Z17/Z50/Z56, verbrauchsart CAV+7111 Z64/Z65/ZE5/ZA8, ist_fernschaltbar), STEUERBARE_RESSOURCEsteuerbare_ressourcen (Konfigurationsprodukte — each SG8 SEQ+Z79 product group → a BO4E Konfigurationsprodukt with produktcode PIA+5 DE7140, zugeordneter Marktpartner CAV+Z91/ZF0, and leistungskurvendefinition from CCI+Z66; the full contracted array is replaced, not merged), TRANCHEtranche. Each Some field overwrites its column via COALESCE; JSONB payload and version untouched; no-op when the object is unknown locally. Emits de.markt.malo.stammdaten-geaendert (MaLo) / de.markt.stammdaten.geaendert (other objects). Deep MeLo standorteigenschaften are acknowledged without a typed apply (structural-MIG level).
Clearprocess.completed55003 / 44003clear_lf_nextLieferbeginn rejected (Ablehnung Anmeldung): resets lf_mp_id_next + lf_next_lieferbeginn so no consumer acts on a switch that will not happen. Idempotent — a no-op when nothing is announced.

All three operations are idempotent under at-least-once EventBus delivery.

Schema

VersorgungsStatusRecord
├── malo_id              — 11-digit Marktlokations-ID
├── lieferstatus         — Beliefert | Unbeliefert | Grundversorgung | Ersatzversorgung | Ruhend | Stillgelegt
├── lf_mp_id             — active Lieferant MP-ID (set when Beliefert)
├── lf_mp_id_next        — announced future Lieferant MP-ID (WHO; set on 55001/44001)
├── lf_next_lieferbeginn — announced Lieferbeginn date (WHEN; paired with lf_mp_id_next)
├── lieferbeginn         — current supply start date
├── lieferende           — announced supply end date
├── msb_mp_id            — active Messstellenbetreiber MP-ID
├── nb_mp_id             — Netzbetreiber MP-ID (partition key)
├── last_process_id      — UUID of the last process that triggered a state change
├── updated_at           — UTC timestamp of last write
└── version              — optimistic-concurrency counter (OCC)

State machine

stateDiagram-v2
    [*] --> Unbeliefert : MaLo registered

    Unbeliefert --> Unbeliefert : 55001/44001 process.initiated<br/>→ lf_mp_id_next + lf_next_lieferbeginn set
    Unbeliefert --> Beliefert   : 55003/44003 process.completed<br/>→ confirm_supply (lf_mp_id_next → lf_mp_id)

    Beliefert --> Beliefert     : 55001/44001 process.initiated<br/>→ next LF announced; current LF still active
    Beliefert --> Unbeliefert   : 55005/44005 process.completed<br/>→ end_supply (lf_mp_id_next preserved if set)

    Unbeliefert --> Ersatzversorgung : 55013/44013 process.completed<br/>→ begin_eog_supply (§38 EnWG, eog_seit set)
    Unbeliefert --> Grundversorgung  : 55013/44013 process.completed<br/>→ begin_eog_supply (§36 EnWG, Haushaltskunde)
    Ersatzversorgung --> Grundversorgung : 3 Monate um, Haushaltskunde<br/>(automatic by law, no market message)
    Ersatzversorgung --> Beliefert   : new supply confirmed<br/>→ confirm_supply (clears eog_seit)
    Grundversorgung --> Beliefert    : new supply confirmed<br/>→ confirm_supply

    Beliefert --> Ruhend    : MaKo suspended
    Ruhend --> Beliefert    : Reactivated
    Beliefert --> Stillgelegt : Decommissioned
    Stillgelegt --> [*]

NB gap-closure (§38 EnWG). When end_supply results in lieferstatus = Unbeliefert and lf_mp_id_next IS NULL, marktd emits de.markt.versorgung.gap-detected; the processd EoG module resolves the Grundversorger (GET /api/v1/grundversorger/{nb_mp_id}) and dispatches gpke.eog.anmelden (UTILMD 55013). When lf_mp_id_next IS NOT NULL, no gap exists — the announced transition proceeds.

GPKE rule A06. processd reads lf_mp_id_next before accepting a new 55001. If lf_mp_id_next IS NOT NULL, a second Anmeldung is already pending → Reject A06.

Optimistic concurrency. Every write uses WHERE malo_id = $1 AND tenant = $2 AND version = $3. Conflict → 412 Precondition Failed → retry after re-read.

REST API

# Current state
GET  /api/v1/versorgung/{malo_id}

# Point-in-time state (as of end-of-day on that German calendar date, CET/CEST)
GET  /api/v1/versorgung/{malo_id}?at=2025-10-01

# Full state-change history (newest first, paged)
GET  /api/v1/versorgung/{malo_id}/history?page=0&size=50

# Admin override or ERP-driven upsert; supply If-Match: "<version>" for OCC
PUT  /api/v1/versorgung/{malo_id}

Response shape (GET /api/v1/versorgung/{malo_id}):

{
  "malo_id": "51238696780",
  "lieferstatus": "Beliefert",
  "lf_mp_id": "4012345000023",
  "lf_mp_id_next": null,
  "lf_next_lieferbeginn": null,
  "lieferbeginn": "2026-10-01",
  "lieferende": null,
  "msb_mp_id": "9900000000002",
  "nb_mp_id": "9900357000004",
  "last_process_id": "...",
  "updated_at": "2026-07-10T08:23:41Z",
  "version": 5
}

Point-in-time query (?at=YYYY-MM-DD): Returns the supply state as it was at end-of-day on the given date in German local time (CET/CEST). Returns 404 when no history exists on or before that date.

sequenceDiagram
    participant ERP
    participant marktd
    participant PostgreSQL

    ERP->>marktd: GET /api/v1/versorgung/51238696780?at=2025-07-01
    marktd->>PostgreSQL: SELECT * FROM versorgungsstatus_history<br/>WHERE malo_id=$1 AND tenant=$2<br/>AND (valid_from AT TIME ZONE 'Europe/Berlin')::date <= '2025-07-01'<br/>ORDER BY valid_from DESC LIMIT 1
    PostgreSQL-->>marktd: snapshot (LieferStatus, LF, NB, …)
    marktd-->>ERP: 200 OK + VersorgungsStatusResponse

processd reads GET /api/v1/versorgung/{malo_id} to drive automated LFA E_0624 responses without ERP involvement (GPKE Teil 1 §5).


MSB Price Sheets — PreisblattMessung

marktd stores MSB metering price sheets (PreisblattMessung) in the preisblaetter_messung table. These cover Messentgelte per Messpreistyp and form the tariff basis for REQOTE/QUOTES (PIDs 35001–35005) and for invoicd plausibility checks on INVOIC 31009 (MSB-Rechnung).

The API mirrors PreisblattNetznutzung exactly but is keyed by msb_mp_id (the MSB's BDEW-Codenummer) instead of nb_mp_id.

# Upload an MSB price sheet (operator or ERP)
curl -s -X PUT "http://marktd:8180/api/v1/preisblaetter-messung/9900012345678" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "bo_typ": "PREISBLATT_MESSUNG",
      "bezeichnung": "Messentgelte 2026",
      "gueltigkeit": { "startdatum": "2026-10-01", "enddatum": "2027-09-30" },
      "preispositionen": [],
      "zeitvariablePreispositionen": [
        { "zaehlzeitregister": "HT", "preis": { "wert": "12.50", "einheit": "EUR_PRO_KWH" } },
        { "zaehlzeitregister": "NT", "preis": { "wert": "8.75",  "einheit": "EUR_PRO_KWH" } }
      ]
    },
    "bo4e_version": "v202607.0.0"
  }'

# Retrieve for a billing date — response includes typed zeitvariable_preispositionen
curl -s "http://marktd:8180/api/v1/preisblaetter-messung/9900012345678?date=2026-01-15" \
  -H "Authorization: Bearer <token>"
# → {
#     "data": { ... },
#     "zeitvariable_preispositionen": [
#       { "zaehlzeitregister": "HT", "preis": { "wert": "12.50", ... } },
#       { "zaehlzeitregister": "NT", "preis": { "wert": "8.75",  ... } }
#     ],
#     "auf_abschlaege": [],
#     "schema_drift_count": 0
#   }

§14a Modul 2 — zeitvariablePreispositionen

For MSBs that operate under §14a Modul 2 (time-of-use pricing for controllable loads), each ZeitvariablePreisposition element in the price sheet must carry a non-empty zaehlzeitregister band code (e.g. "HT", "NT", "ST"). The PUT endpoint validates this:

ValidationError
Missing zaehlzeitregister422 — mandatory per §14a Modul 2 (BK6-22-300)
bandNummer field present422 — does not exist in BO4E v202607
Invalid BO4E schema422 — serde_json schema error

invoic-checker check 4 uses the zaehlzeitregister codes to route INVOIC 31009 positions against the correct ToU band price, rather than guessing from positionstext keywords.

Source-override protection. Same as preisblaetter: an operator REST upload (source = 'api') is never silently overwritten by an engine ingest (source = 'mako').


MMM Settlement Prices — Gas MMMA + Strom Ausgleichsenergie

marktd stores monthly settlement reference prices for Mehr-/Mindermengenabrechnungen (MMM). These are B2B settlement prices — not B2C retail tariffs — and must therefore live in marktd, not in a retail tariff service. Both netzbilanzd (NB, generates MMM invoices) and invoicd (LF, validates inbound MMM invoices) need them, and they cannot share a database directly.

Gas MMM Abrechnungspreise — Trading Hub Europe (THE)

Published monthly by Trading Hub Europe (THE, the German gas market area operator since 2021). netzbilanzd auto-fetches these when mehr_preis_ct_per_kwh / minder_preis_ct_per_kwh are not supplied in the POST /api/v1/billing/run request. invoicd uses them for check 6 on inbound INVOIC 31007/31008.

# Import THE Gas MMMA prices for a billing month (operator monthly task)
curl -s -X PUT "http://marktd:8180/api/v1/mmma-preise/gas/2026/7" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "marktgebiet": "THE",
    "mehr_ct_kwh": "1.25",
    "minder_ct_kwh": "0.87",
    "source": "manual"
  }'

# Query (used by netzbilanzd and invoicd)
curl -s "http://marktd:8180/api/v1/mmma-preise/gas/2026/7" \
  -H "Authorization: Bearer <token>"
# → { "price_month": "2026-07-01", "marktgebiet": "THE", "mehr_ct_kwh": "1.25", ... }

# List all imported months
curl -s "http://marktd:8180/api/v1/mmma-preise/gas?limit=12" \
  -H "Authorization: Bearer <token>"

Strom MMM Ausgleichsenergie — ÜNB (GPKE (BK6-24-174) Teil 1 Kap. 8.4)

Published monthly per ÜNB (50Hertz, TenneT, Amprion, TransnetBW). Used by netzbilanzd for MMM INVOIC 31005/31006 and invoicd check 6 on inbound Strom MMM invoices.

# Import Strom MMM prices for TenneT (example)
curl -s -X PUT "http://marktd:8180/api/v1/mmm-preise/strom/2026/7" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "vnb_mp_id": "9900823780008",
    "mehr_ct_kwh": "2.10",
    "minder_ct_kwh": "1.45",
    "source": "manual"
  }'

# Query
curl -s "http://marktd:8180/api/v1/mmm-preise/strom/2026/7?vnb_mp_id=9900823780008" \
  -H "Authorization: Bearer <token>"

Operator task: import these prices monthly before running MMM billing. A missed monthly import causes netzbilanzd to require manual ERP input and invoicd to skip check 6 (logged at debug level, not a hard error).

Automated monthly import

marktd includes a background worker that automatically fetches and imports Gas MMMA and Strom MMM prices on the 1st of each month. Configure it in marktd.toml:

[mmma_import]
enabled       = true
gas_url       = "https://www.the-group.de/gas/mmma/export.csv"  # THE CSV endpoint
strom_url     = "https://www.netztransparenz.de/mmm/strom.json" # ÜNB JSON endpoint
check_hour_utc = 6   # import at 06:00 UTC (after THE typically publishes ~05:00 UTC)

The worker wakes every hour; on the 1st of the month at or after check_hour_utc it fetches, parses (CSV or JSON), and upserts — idempotent if prices already exist for the current month.

Supported formats:

# CSV (5-column)
year,month,marktgebiet,mehr_ct_kwh,minder_ct_kwh
2026,7,THE,1.25,0.87
// JSON (single object or array)
{ "mehr_ct_kwh": "1.25", "minder_ct_kwh": "0.87", "marktgebiet": "THE" }

Manual trigger (catch-up after downtime or testing):

curl -s -X POST "http://marktd:8180/api/v1/mmma-preise/import-trigger?year=2026&month=7" \
  -H "Authorization: Bearer <token>"
# → { "year": 2026, "month": 7, "import_enabled": true, "results": [...] }

CloudEvents emitted:

Event typeTrigger
de.markt.mmma.import.successSuccessful monthly import
de.markt.mmma.import.failedFetch or parse failure (requires operator action)

Device Registry — Zaehler + Geraete

marktd maintains a device registry for meters (Zähler) and their associated devices (Geräte). This is populated by WiM MSB/NB device-handover processes (ORDERS PIDs 17001–17011) and by operator REST uploads.

Hierarchy

MeLo ──► Zaehler ──► Geraete
         (1..n)       (0..n)

              └──► Zaehlwerk (0..n)
                   (OBIS registers)

A Zaehler carries:

  • zaehler_typ — BO4E Zaehlertyp, CHECK-constrained to the 14 v202607 wire values (DREHSTROMZAEHLER, INTELLIGENTES_MESSSYSTEM, MODERNE_MESSEINRICHTUNG, …). GASZAEHLER is not one of them. §42c Energy-Sharing eligibility reads this column, so an unrecognised value would silently degrade a delivery point to UNKNOWN; the schema_enum_guard test pins the list to rubo4e. Watch the spelling: Zaehlertyp uses INTELLIGENTES_MESSSYSTEM (three s), while Geraetetyp uses INTELLIGENTES_MESSYSTEM (two). That is a BO4E quirk.
  • eichung_bis — calibration valid-until date (Eichgültigkeitsdatum)
  • data — full BO4E Zaehler payload (the _typ discriminator is auto-injected to "Zaehler" if absent, ensuring every stored object is self-describing)
  • data.zaehlwerke — list of Zaehlwerk OBIS registers; exposed via GET /api/v1/zaehler/{id}/zaehlwerke as typed Vec<Zaehlwerk>

A Geraet carries:

  • geraet_typ — e.g. SMARTMETER_GATEWAY, WANDLER, MULTIPLEXANLAGE
  • data — full BO4E Geraet payload (_typ auto-injected to "Geraet" if absent)
  • konfigurationen — typed Vec<GeraetKonfiguration> for MSB device management (see below)

BO4E _typ discriminator. All four PUT device endpoints (zaehler, geraete, steuerbare-ressourcen, technische-ressourcen) automatically inject the correct _typ discriminator into the data JSONB blob if the caller omits it. Callers that include _typ in the request body have their value preserved.

Zaehlwerk registers

Each Zaehler stores 0..n Zaehlwerk objects in data["zaehlwerke"] (BO4E v202607). A Zaehlwerk is an individual measurement register on the meter, identified by its OBIS code. iMSyS (intelligent metering systems) expose multiple registers simultaneously — demand, reactive energy, export, time-of-use tariff splits.

GET /api/v1/zaehler/{zaehler_id}/zaehlwerke extracts the zaehlwerke array from data and returns it as typed Vec<Zaehlwerk>:

curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/zaehlwerke" \
  -H "Authorization: Bearer <token>" | jq '.[] | {
    obisKennzahl,
    richtung,
    verbrauchsart,
    anzahlAblesungen
  }'

Response shape:

[
  {
    "_typ": "ZAEHLWERK",
    "obisKennzahl": "1-0:1.8.0",
    "richtung":     "EINSP",
    "verbrauchsart": "WIRKARBEIT",
    "anzahlAblesungen": 1
  },
  {
    "_typ": "ZAEHLWERK",
    "obisKennzahl": "1-0:2.8.0",
    "richtung":     "AUSSP",
    "verbrauchsart": "WIRKARBEIT",
    "anzahlAblesungen": 1
  }
]

Returns [] (not 404) when no registers are stored. Cedar action: read-device.

Use cases:

  • TOU (time-of-use) billing: identify HT/NT registers before computing Arbeitspreis split
  • iMSyS demand management: enumerate active demand registers for wim.steuerungsauftrag.bestaetigen
  • MSB tariff selection: PreisblattMessung Preisstaffel matching uses richtung + OBIS

Endpoints

# List meters for a MeLo
curl -s "http://marktd:8180/api/v1/melos/DE00056789000000000000000012345678/zaehler" \
  -H "Authorization: Bearer <token>" | jq '.[] | {zaehler_id, zaehler_typ, eichung_bis}'

# Register or update a meter (include zaehlwerke in data for structured register access)
curl -s -X PUT "http://marktd:8180/api/v1/zaehler/Z001234567" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "melo_id": "DE00056789000000000000000012345678",
    "zaehler_typ": "DREHSTROMZAEHLER",
    "eichung_bis": "2030-12-31",
    "data": {
      "zaehlwerke": [
        { "_typ": "ZAEHLWERK", "obisKennzahl": "1-0:1.8.0", "richtung": "EINSP" }
      ]
    },
    "bo4e_version": "v202607.0.0"
  }'

# List Zaehlwerk registers for a meter (typed Vec<Zaehlwerk>)
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/zaehlwerke" \
  -H "Authorization: Bearer <token>" | jq .

# List devices for a meter
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/geraete" \
  -H "Authorization: Bearer <token>" | jq '.[] | {geraet_id, geraet_typ}'

# Get a single Geraet — full BO4E payload + typed konfigurationen
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/geraete/SMGW-2026-001" \
  -H "Authorization: Bearer <token>" | jq '{geraet_id, geraet_typ, konfigurationen}'

Geraet Konfigurationen — device configuration records (MsbG §23)

marktd maintains typed device-configuration entries for each Geraet, stored in the geraet_konfigurationen JSONB column (separate from the BO4E data payload so they can be updated atomically without rewriting the full Geraet).

The geraet_konfigurationen column has a GIN index, enabling fast SQL queries such as "find all devices with SMGW cert expiring within 30 days":

SELECT malo_id FROM geraete
WHERE geraet_konfigurationen @> '[{"parameter":"SMGW_CERT_ABLAUFDATUM"}]'
  AND tenant = '...'

Konfigurationsparameter enum

ValueTypeLegal basisPurpose
FIRMWARE_VERSIONstringBSI TR-03109-1 §4.3Current firmware version (e.g. "3.1.2")
HARDWARE_REVISIONstringMsbG §23Hardware revision string
KOMMUNIKATIONenum stringCommunication technology: "GPRS" / "PLC" / "ETHERNET" / "FUNK" / "FESTNETZ" / "GSM"
FERN_UPDATE_FAEHIGbool stringBSI TR-03109-4Supports OTA firmware update ("true" / "false")
CLS_FAEHIGbool string§14a EnWG BK6-22-300CLS channel capable ("true" / "false") — checked by processd §14a auto-acknowledge
SMGW_TLS_CERT_FINGERPRINThex stringBSI TR-03109-3SHA-256 fingerprint (64 hex chars) of the SMGW TLS cert
SMGW_CERT_ABLAUFDATUMISO dateBSI TR-03109-4 §6.3TLS cert expiry date — monitored by edmd cert-expiry worker
CLS_KANAL_IDstringBK6-24-174 §4.3CLS channel ID for §14a Steuerungsauftrag routing
GWA_CODENUMMER13-digitBDEWGWA (Gateway-Administrator) BDEW-Codenummer
HERSTELLERstringMsbG §23Manufacturer name
INBETRIEBNAHMEDATUMISO date§ 13 StromNZVCommissioning date
LETZTE_WARTUNGISO date§ 13 StromNZVLast maintenance date
NAECHSTE_WARTUNGISO date§ 13 StromNZVNext scheduled maintenance date
AUSLESEE_PROTOKOLLenum stringReadout protocol: "SML" / "DLMS" / "IEC62056"
MSB_VERTRAGSNUMMERstringMsbG §23MSB contract number for this device
SONSTIGESstringCustom parameter — use notiz for the actual key name

GeraetKonfiguration entry shape

{
  "parameter":  "SMGW_CERT_ABLAUFDATUM",
  "wert":       "2027-06-30",
  "updated_at": "2026-07-18T08:00:00Z",
  "notiz":      null
}

updated_at is always set server-side on write — callers must not include it in PUT requests. Duplicate parameter values in a single PUT body are deduplicated (last entry wins) before storage.

Endpoints

# Get current configuration entries for a device
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/geraete/SMGW-2026-001/konfigurationen" \
  -H "Authorization: Bearer <token>" | jq '.[] | {parameter, wert, updated_at}'

# Set SMGW configuration after BSI TR-03109-4 Admin session (§14a fleet rollout)
curl -s -X PUT "http://marktd:8180/api/v1/zaehler/Z001234567/geraete/SMGW-2026-001/konfigurationen" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "konfigurationen": [
      { "parameter": "FIRMWARE_VERSION",       "wert": "3.1.2"            },
      { "parameter": "HARDWARE_REVISION",      "wert": "Rev. C"           },
      { "parameter": "KOMMUNIKATION",          "wert": "GPRS"             },
      { "parameter": "CLS_FAEHIG",             "wert": "true"             },
      { "parameter": "CLS_KANAL_ID",           "wert": "CLS-00042"        },
      { "parameter": "SMGW_TLS_CERT_FINGERPRINT", "wert": "a1b2c3d4..."   },
      { "parameter": "SMGW_CERT_ABLAUFDATUM",  "wert": "2027-06-30"       },
      { "parameter": "GWA_CODENUMMER",         "wert": "9900000000099"    },
      { "parameter": "HERSTELLER",             "wert": "Sagemcom"         },
      { "parameter": "INBETRIEBNAHMEDATUM",    "wert": "2024-03-15"       }
    ]
  }'
# → 204 No Content + emits de.markt.geraet.konfiguration.updated CloudEvent
# If CLS_FAEHIG is set, processd auto-acknowledges §14a Steuerungsauftrag for this device.
# If SMGW_CERT_ABLAUFDATUM is set, edmd cert-expiry worker starts monitoring.

Integration with processd (§14a Steuerungsauftrag)

When CLS_FAEHIG = "true" is stored, processd auto-acknowledges §14a WimSteuerungsauftrag requests for this device (BK6-24-174 §4.3 rules). When CLS_FAEHIG = "false" or absent, processd rejects the Steuerungsauftrag (BK6-24-174 §4.3 — device not remotely controllable).

Integration with edmd (SMGW cert-expiry monitoring)

Setting SMGW_CERT_ABLAUFDATUM triggers the edmd daily compliance worker to monitor the device. When the expiry date is ≤ 30 days away, edmd emits de.messwert.cls.compliance-issue (severity WARNING); after expiry, severity CRITICAL.


ZaehlzeitDefinition — typed TOU definition for ERP and portals

GET /api/v1/zaehler/{zaehler_id}/zaehlzeitdefinitionen assembles a complete rubo4e::current::Zaehlzeitdefinition BO4E object from zaehler_register + zaehler_saisons rows and returns it in canonical JSON. This is the endpoint ERP systems and customer portals use to display ToU register schedules to end customers without custom ETL.

curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/zaehlzeitdefinitionen" \
  -H "Authorization: Bearer <token>" | jq .

Response shape (BO4E Zaehlzeitdefinition):

{
  "_typ": "ZAEHLZEITDEFINITION",
  "_id": "Z001234567",
  "saisons": [
    {
      "_typ": "ZAEHLZEITSAISON",
      "bezeichnung": "WINTER",
      "tagtypen": [
        {
          "_typ": "ZAEHLZEITTAGTYP",
          "tagtyp": "WERKTAGS",
          "umschaltzeiten": [
            { "_typ": "UMSCHALTZEIT", "registercode": "HT", "umschaltzeit": "07:00" },
            { "_typ": "UMSCHALTZEIT", "registercode": "NT", "umschaltzeit": "22:00" }
          ]
        },
        {
          "_typ": "ZAEHLZEITTAGTYP",
          "tagtyp": "WOCHENENDE",
          "umschaltzeiten": [
            { "_typ": "UMSCHALTZEIT", "registercode": "NT", "umschaltzeit": "00:00" }
          ]
        }
      ]
    }
  ]
}

The ?valid_only=true query parameter restricts the response to currently valid registers (valid_to IS NULL OR valid_to >= today).

Why this endpoint? ERP systems (Schleupen, SAP IS-U, powercloud) need the nested Zaehlzeitdefinition shape for customer portal display. Without it, clients must query two endpoints and assemble the hierarchy themselves. The endpoint returns canonical BO4E that can be schema-validated client-side.

§14a Modul 2 context. Under BK6-22-300, the NB assigns HT/NT registers to controllable loads at specific switching times communicated via WiM Stammdaten (ORDERS 17102–17133 ZAK+ZE segments). marktd auto-populates the underlying data from those events; this endpoint exposes it in BO4E form. See also billingd §14a Modul 2 billing.


ZaehlzeitRegister — iMSys TOU register definitions

ZaehlzeitRegister and ZaehlzeitSaison provide structured Time-of-Use (TOU) register definitions for iMSys (intelligent metering systems). They underpin §14a Modul 2 billing by enabling automatic classification of 15-min Lastgang intervals into HT/NT tariff bands without per-meter manual configuration.

Data model

Zaehler (1) ──► ZaehlzeitRegister (N)
                   zaehlerauspraegung: HT | NT | EINZEL
                   obis_kennzahl: "1-1:1.29.0" (HT), "1-1:1.49.0" (NT)
                   valid_from / valid_to  (seasonal changeover supported)

ZaehlzeitRegister (1) ──► ZaehlzeitSaison (N)
                               saison: SOMMER | WINTER | GESAMT
                               wochentage: [1,2,3,4,5] (Mon–Fri)
                               zeit_von: "07:00"   (inclusive, local German time CET/CEST)
                               zeit_bis: "22:00"   (exclusive)

A typical residential iMSys meter has two registers (HT + NT) each with two seasons (SOMMER, WINTER). Weekdays differ from weekends. marktd stores all combinations and resolves them efficiently via a single PostgreSQL JOIN with JSONB @> containment.

Endpoints

MethodPathDescription
GET/api/v1/zaehler/{id}/registerList all TOU registers for a Zaehler
PUT/api/v1/zaehler/{id}/registerUpsert a ZaehlzeitRegister
GET/api/v1/zaehler-register/{id}/saisonsList seasonal windows for a register
PUT/api/v1/zaehler-register/{id}/saisonsUpsert a ZaehlzeitSaison
GET/api/v1/zaehler/{id}/tariff-zoneResolve HT/NT/EINZEL at a given datetime

Setting up TOU registers

# 1. Create an HT register
REGISTER_ID=$(uuidgen)
curl -s -X PUT "http://marktd:8180/api/v1/zaehler/Z001234567/register" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d "{
    \"id\": \"${REGISTER_ID}\",
    \"bezeichnung\": \"HT\",
    \"zaehlerauspraegung\": \"HT\",
    \"obis_kennzahl\": \"1-1:1.29.0\",
    \"einheit\": \"KWH\",
    \"valid_from\": \"2025-01-01\"
  }"

# 2. Add winter season: Mon–Fri 07:00–22:00
curl -s -X PUT "http://marktd:8180/api/v1/zaehler-register/${REGISTER_ID}/saisons" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d "{
    \"id\": \"$(uuidgen)\",
    \"saison\": \"WINTER\",
    \"wochentage\": [1,2,3,4,5],
    \"zeit_von\": \"07:00\",
    \"zeit_bis\": \"22:00\"
  }"

# 3. Resolve tariff zone at a specific time
curl -s "http://marktd:8180/api/v1/zaehler/Z001234567/tariff-zone?datetime=2026-01-15T14:30:00" \
  -H "Authorization: Bearer <token>" | jq .
# → { "zaehler_id": "Z001234567", "local_datetime": "2026-01-15T14:30:00", "tariff_zone": "HT" }

The tariff-zone endpoint performs a single JOIN between zaehler_register and zaehler_saisons, filtering by JSONB array containment (wochentage @> $3::jsonb) and time range. No application-level iteration is needed on the consumer side.

Integration with billingd (§14a Modul 2): billingd calls GET /api/v1/zaehler/{id}/tariff-zone?datetime=<slot-start> for each 15-min slot in the billing month and aggregates kWh by zone. This eliminates the need for operators to maintain manual HT/NT time-window configuration per meter in the billing engine.

Automatic population from WiM Stammdaten (ORDERS 17102–17133)

marktd populates ZaehlzeitRegister and ZaehlzeitSaison automatically when makod receives an inbound WiM Stammdaten response (ORDERS PIDs 17102–17133) from the MSB.

The adapter extract_zak_ze_zaehlwerke() in makod parses the EDIFACT ZAK+ZE+ZD segments:

SegmentFieldCodes
ZAK element 0obis_kennzahlOBIS code (e.g. "1-1:1.8.0")
ZAK element 1zaehlerauspraegungZ01HT, Z02NT, Z03EINZEL
ZAK element 2bezeichnungHuman-readable register label
ZE element 0saisonZ01SOMMER, Z02WINTER, Z03GESAMT
ZD element 0tagtypZ01WERKTAG, Z02SAMSTAG, Z03SONNTAG_FEIERTAG
ZD elements 1..Ntime windows"HHMM:RegisterCode" switch-point pairs

After parsing, makod emits a ProcessCompleted outbox entry (CloudEvent type de.mako.process.completed, PID 17102–17133) carrying melo_id and zaehlwerke. When marktd's event_ingest handler receives this event, it:

  1. Looks up the Zaehler associated with the MeLo via list_zaehler_by_melo()
  2. For each zaehlwerk, calls upsert_register() (idempotent on zaehler_id + bezeichnung + valid_from)
  3. For each season window (ZE→ZD), calls upsert_saison() with a deterministic UUID derived from (register_id, saison, tagtyp, zeit_von) — safe for at-least-once delivery

This means operators do not need to manually provision ZaehlzeitRegister entries for meters where the MSB sends WiM ORDERS Stammdaten responses — marktd and makod handle it automatically.

Data flow:

sequenceDiagram
    participant MSB
    participant makod
    participant marktd
    participant billingd

    MSB->>makod: ORDERS PID 17102–17133<br/>ZAK+ZE+ZD segments
    makod->>makod: extract_zak_ze_zaehlwerke()<br/>parse OBIS / saisons / fenster
    makod--)marktd: de.mako.process.completed<br/>{melo_id, zaehlwerke:[...]}
    marktd->>marktd: list_zaehler_by_melo(melo_id)
    marktd->>marktd: upsert_register() + upsert_saison()<br/>(idempotent, deterministic UUID)
    billingd->>marktd: GET /zaehler/{id}/tariff-zone?datetime=...
    marktd-->>billingd: { "tariff_zone": "HT" }

SteuerbareRessource Registry

marktd stores steuerbare Ressourcen (SR) — iMS controllable resources per BK6-24-174 §6. An SR-ID has the format C[A-Z0-9]{9}[0-9] (Codetyp C + 9 alphanumeric chars + ASCII-Verfahren check digit).

Populated by WiM iMS Steuerungsauftrag processes (PID 55168) and by operator uploads. Linked optionally to a MaLo and/or MeLo.

The konfigurationsprodukte field stores the contracted iMS control products — used for pre-dispatch eligibility checks in wim.steuerungsauftrag.bestaetigen. The value is preserved across PUT calls unless explicitly replaced via the sub-resource endpoint.

Konfigurationsprodukte — typed API

The konfigurationsprodukte sub-resource has its own endpoints with full BO4E validation per BK6-24-174 §4.3:

# Retrieve typed Konfigurationsprodukte (returns Vec<ZeitvariablePreisposition> deserialized)
curl -s "http://marktd:8180/api/v1/steuerbare-ressourcen/C0001234567890/konfigurationsprodukte" \
  -H "Authorization: Bearer <token>"
# → {
#     "sr_id": "C0001234567890",
#     "konfigurationsprodukte": [{ "produktcode": "FLEX-001", ... }],
#     "count": 1,
#     "schema_drift": 0
#   }

# Replace all contracted products (validates each element + enforces non-empty produktcode)
curl -s -X PUT "http://marktd:8180/api/v1/steuerbare-ressourcen/C0001234567890/konfigurationsprodukte" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '[
    { "produktcode": "FLEX-PRODUCT-001", "zaehlzeitregister": "HT" }
  ]'
# → 204 No Content + emits de.markt.sr.konfigurationsprodukt.updated CloudEvent

# Remove a single product by produktcode
curl -s -X DELETE "http://marktd:8180/api/v1/steuerbare-ressourcen/C0001234567890/konfigurationsprodukte/FLEX-PRODUCT-001" \
  -H "Authorization: Bearer <token>"
# → 204 No Content

Validation rules (BK6-24-174 §4.3):

  • Each element must deserialize as rubo4e::current::Konfigurationsprodukt
  • produktcode must not be empty — every contracted product requires a unique code
  • bandNummer is rejected with 422 — it does not exist in BO4E v202607; use zaehlzeitregister

processd checks this list before auto-confirming a Steuerungsauftrag. An uncontracted produktcode triggers wim.steuerungsauftrag.ablehnen automatically.


PUT /api/v1/melo — MeLo Typed Columns & Schema Validation

Every PUT /api/v1/melo/{melo_id} call:

  1. Validates the incoming data as rubo4e::current::Messlokation: auto-injects _typ: "MESSLOKATION", rejects wrong _typ or invalid enum values with 422.
  2. Normalises to canonical camelCase BO4E form before storage.
  3. Extracts typed columns for efficient SQL queries:
ColumnSource fieldPurpose
netzebene_messungdata.netzebeneMessungVoltage/pressure level at the metering point (e.g. "NS", "MS")
regelzonedata.standorteigenschaften.eigenschaftenStrom[0].regelzoneRegelzone EIC code → maps MeLo to the ÜNB for Redispatch 2.0 Stammdaten forwarding and MABIS IFTSTA 21000 routing

MeloResponse (GET) returns data: rubo4e::current::Messlokation (typed):

{
  "melo_id": "DE00056789000000000000000012345678",
  "malo_id": "10001234567",
  "version": 2,
  "netzebene_messung": "NS",
  "regelzone": "10YDE-EON------1",
  "data": { "_typ": "MESSLOKATION", "netzebeneMessung": "NS", ... }
}

To populate regelzone from a NIS export, include the BO4E path in the PUT body:

{
  "malo_id": "10001234567",
  "data": {
    "_typ": "MESSLOKATION",
    "standorteigenschaften": {
      "eigenschaftenStrom": [
        { "regelzone": "10YDE-EON------1" }
      ]
    }
  }
}

Netz-Element-Lokationen (NeLo) — Redispatch 2.0

marktd maintains a registry of Netz-Element-Lokationen (NeLo) for BDEW Redispatch 2.0. A NeLo is a network element location identified by a 16-char EIC code (ENTSO-E, NAD DE3055 = ZEW) or a 13-digit BDEW Codenummer.

NeLos are owned by the Netzbetreiber (NB role) responsible for the network element. They carry structural metadata (Sparte, Netzebene) and an open-ended JSONB data payload for additional Redispatch 2.0 attributes.

REST API:

# List all NeLos for this tenant (optionally filter by Netzbetreiber GLN)
GET  /api/v1/nelo
GET  /api/v1/nelo?nb_mp_id=9900357000004&page=0&size=50

# Get a single NeLo by EIC or BDEW Codenummer
GET  /api/v1/nelo/{nelo_id}

# Insert or update a NeLo (NB role required; supply If-Match for OCC)
PUT  /api/v1/nelo/{nelo_id}

Request body for PUT (includes typed NeLo columns):

{
  "sparte": "STROM",
  "name": "Umspannwerk Musterstadt 110/20 kV",
  "netzebene": "HS",
  "nb_mp_id": "9900357000004",
  "steuerkanal": true,
  "eigenschaft_msb_lokation": "NB",
  "grundzustaendiger_msb_codenr": "9900357000004",
  "data": {
    "eic": "10XDE-EON-NETZ--G",
    "regelzone": "10YDE-EON------1"
  }
}

The steuerkanal, eigenschaft_msb_lokation, and grundzustaendiger_msb_codenr fields are extracted into typed SQL columns at write time for efficient Redispatch 2.0 queries (e.g. "find all controllable NeLos for a given NB").

Authorization: read-nelo is open to all authenticated users in the tenant. write-nelo requires the NB role (Cedar policy write-nelo).


Location Graph — Lokationszuordnung

The lokationszuordnungen table stores the full MaKo location graph as directed edges with temporal validity (valid_from, valid_to). Each edge connects two location nodes typed by the BO4E LokationstypMALO, MELO, NELO, SR (SteuerbareRessource), or TR (TechnischeRessource). Node types are the typed rubo4e::current::Lokationstyp end to end (request body, storage, and traversal results), not free strings.

graph LR
    MaLo["MaLo (51238696780)"] --> MeLo["MeLo (DE-MEL-001)"]
    MeLo --> NeLo["NeLo (EIC 10XDE-…)"]
    MeLo --> SR["SR (C0001234567)"]
    MeLo --> TR["TR (T0001234567)"]

Graph traversal

GET /api/v1/malo/{id}/lokationen runs a recursive-CTE BFS query (max depth 8) and returns all reachable edges from the given MaLo, ordered by depth.

# Full graph from a MaLo (all edges regardless of validity)
GET /api/v1/malo/51238696780/lokationen

# Graph valid on a specific date (temporal filter)
GET /api/v1/malo/51238696780/lokationen?at=2025-01-15

# Graph from a MeLo
GET /api/v1/melos/DE-MEL-001/lokationen?at=2025-01-15

Response: Vec<LokationszuordnungEdge> ordered by depth (0 = direct edges from root).

[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "tenant": "9900357000004",
    "von_id": "51238696780",
    "von_typ": "MALO",
    "nach_id": "DE-MEL-001",
    "nach_typ": "MELO",
    "valid_from": null,
    "valid_to": null,
    "data": {},
    "depth": 0
  }
]

Lokationsbündel

GET /api/v1/malos/{id}/buendel returns the Lokationsbündel (UTILMD Lokationsbündelstruktur) as a first-class aggregate — the set of MeLos, NeLos, SRs, and TRs bundled under a MaLo, projected from the typed graph — together with its structural-integrity status. The aggregate (mako_markt::repository::Lokationsbuendel) enforces the bundle invariants at the domain boundary: a consuming MaLo must carry at least one MeLo, and all MeLos of the bundle must share one MSB (validate / validate_msb_consistency). A bundle can be transiently incomplete mid-Einzug, so the endpoint reports valid: false with a validation_error rather than failing the request.

{
  "malo_id": "51238696780",
  "lokationsbuendelcode": "1S",
  "messlokationen": ["DE-MEL-001"],
  "netzlokationen": [],
  "steuerbare_ressourcen": [],
  "technische_ressourcen": [],
  "valid": true
}

Upsert and delete

# Upsert an edge (idempotent)
PUT /api/v1/lokationszuordnungen
Content-Type: application/json

{
  "von_id":    "51238696780",
  "von_typ":   "MALO",
  "nach_id":   "DE-MEL-001",
  "nach_typ":  "MELO",
  "valid_from": null,
  "valid_to":   null,
  "data":       {}
}

# Hard-delete an edge pair (all temporal variants)
DELETE /api/v1/lokationszuordnungen/51238696780/DE-MEL-001

Temporal succession: Multiple edges between the same (von_id, nach_id) pair are allowed when valid_from differs. One open-ended edge (valid_from IS NULL) is permitted per pair. Dated edges allow modelling supplier-switch driven reassignments.


TechnischeRessource (E-mobility, Generation, Storage)

TechnischeRessource records link E-mobility charging points, generation units, and storage to MaLos/MeLos. Required for WiM iMS Steuerungsauftrag routing and Redispatch 2.0 flexibility registration.

# Get a TechnischeRessource by TrId
GET  /api/v1/technische-ressourcen/{tr_id}

# Upsert
PUT  /api/v1/technische-ressourcen/{tr_id}

# List all TechnischeRessourcen linked to a MaLo
GET  /api/v1/malos/{malo_id}/technische-ressourcen

Request body for PUT:

{
  "data":              { "_typ": "TechnischeRessource", ... },
  "malo_id":           "51238696780",
  "melo_id":           "DE-MEL-001",
  "nutzung":           "STROMVERBRAUCHSART",
  "verbrauchsart":     "E_MOBILITAET",
  "ist_fernschaltbar": true,
  "bo4e_version":      "v202607.0.0"
}

BO4E-aligned classification (both fall back to the typed data payload when omitted; invalid values are rejected with 400 Bad Request): nutzung = TechnischeRessourceNutzung ("STROMVERBRAUCHSART" | "STROMERZEUGUNGSART" | "SPEICHER"); verbrauchsart = TechnischeRessourceVerbrauchsart, only for STROMVERBRAUCHSART ("KRAFT_LICHT" | "WAERME" | "E_MOBILITAET" | "STRASSENBELEUCHTUNG"). ist_fernschaltbar: true marks the resource as remotely controllable for Redispatch 2.0.


CloudEvent Replay Log

Every inbound CloudEvent is appended to the durable event_log table before fan-out, enabling full replay without data loss.

# Query the event log (all parameters optional)
GET /admin/events?from=2025-01-01T00:00:00Z&to=2025-02-01T00:00:00Z&type=de.mako.process.initiated&limit=500

Response: Vec<EventLogRow> ordered by received_at ASC (oldest first = deterministic replay).

Use cases:

  • New subscriber onboarding: replay all de.mako.process.initiated events since go-live
  • Bug fix replay: re-deliver specific event types after a invoicd fix
  • Post-incident forensics: trace which events were delivered to which subscriber

CloudEvents — outbound event catalog

marktd emits CloudEvents to all registered ERP webhook subscribers for the following domain events. Each event carries the markt* extension attributes listed below the table.

typesubjectTriggerConsumers
de.markt.malo.updatedmalo_idMaLo PUTedmd, processd, ERP
de.markt.malo.stammdaten-geaendertmalo_idUTILMD Stammdatenänderung appliedERP audit
de.markt.melo.updatedmelo_idMeLo PUTedmd, processd, ERP
de.markt.pricat.publishednb_mp_idPRICAT 27003 dispatchnetzbilanzd, invoicd, ERP
de.markt.nb-contract.updatedcontract_idNB contract PUTERP
de.markt.sr.konfigurationsprodukt.updatedsr_idSR Konfigurationsprodukt replaceprocessd (§14a eligibility check), ERP
de.markt.geraet.konfiguration.updatedgeraet_idGeraet konfigurationen PUTedmd cert-expiry worker, processd §14a auto-ack check, ERP
de.markt.partner.updatedmp_idMarktpartner PUTmakod partner sync, ERP
de.markt.versorgung.changedmalo_idVersorgungsStatus transitionvertragd, billingd, ERP
de.markt.versorgung.gap-detectedmalo_id55005/44005 completed, no announced successor (§38 EnWG)processd EoG module
de.markt.versorgung.eog-begonnenmalo_id55013/44013 completed → begin_eog_supplyprocessd EoG module, ERP
de.markt.mmma.import.successyear-month, commodityMonthly MMMA/MMM price import (Gas or Strom)netzbilanzd, invoicd
de.markt.mmma.import.failedyear-month, commodityMonthly import fetch/parse/store failureoperator

de.markt.geraet.konfiguration.updated data payload

{
  "specversion": "1.0",
  "type":        "de.markt.geraet.konfiguration.updated",
  "source":      "urn:mako:marktd:tenant:9900000000003",
  "subject":     "SMGW-2026-001",
  "id":          "a1b2c3d4-...",
  "time":        "2026-07-18T08:01:00Z",
  "data": {
    "geraet_id":  "SMGW-2026-001",
    "zaehler_id": "Z001234567",
    "count":      10
  }
}

edmd subscribes to this event type and, upon receipt, updates the smgw_sessions table for the corresponding MaLo if an existing session exists — ensuring the cert-expiry compliance worker always has fresh SMGW_CERT_ABLAUFDATUM data without polling marktd.

CloudEvents Extensions

All outbound events carry the markt* extension namespace (CloudEvents §3.3 — lowercase alphanumeric only):

ExtensionTypeDescription
marktmaloidstringResolved Marktlokations-ID
marktmeloidstringResolved Messlokations-ID
marktrolestringMarktrolle: NB, LF, MSB, BIKO, UNB
markterprefstringERP-supplied idempotency key
makopidu32Forwarded BDEW Prüfidentifikator
makoworkflowstringWorkflow family name
makoercstringBDEW ERC error code (on aperak.rejected)
traceparentstringW3C Trace Context — 00-<trace-id>-<parent-id>-<flags>
tracestatestringW3C Trace Context vendor bag — forwarded unchanged

marktrole derivation from makoworkflow:

PatternRole
ends with -lf or contains -lf-LF
starts with wim-MSB
starts with mabis-BIKO
everything elseNB

Event source: "urn:mako:marktd:tenant:{tenant}"


See Also

Edit this page ↗