ERP Integration

Integrate your ERP with mako over CloudEvents 1.0 webhooks, the Command API and typed BO4E market data — HMAC signatures, idempotency keys, topology.

On this page 14 sections

makod handles EDIFACT, BDEW process rules, AS4 delivery and regulatory deadlines; contract data, billing logic and master data live in the ERP. The contract between them is BO4E — the ERP never sees EDIFACT format versions or segment codes, so a new BDEW format version leaves its payloads unchanged.

ERP  ←─────────── BO4E JSON ───────────→  makod
                (ErpAdapter / POST /api/v1/commands)
                        ↕
              EDIFACT / AS4 / BDEW network

Outbound events are CloudEvents 1.0 structured-mode JSON, which SAP BTP, AWS EventBridge, Azure Event Grid, Google Eventarc and Knative route natively. With marktd deployed, makod pushes its process events to marktd (POST /api/v1/events) and marktd fans them out to registered ERP subscribers, so no webhook needs configuring in makod itself.


Quick-start: wire the ERP webhook in 5 minutes

makod POSTs an event to the ERP whenever a process reaches a significant state (APERAK received, process completed, …).

Step 1 — Generate a shared secret

openssl rand -hex 32
# → e.g. a3f8c1d2...  (64 hex chars)

Step 2 — Start makod with the webhook configured

makod \
  --config /etc/makod/makod.toml \
  --data-dir /var/lib/makod \
  --erp-webhook-url https://erp.example.com/mako/events \
  --erp-webhook-secret a3f8c1d2...

Or via makod.toml:

[erp]
webhook_url    = "https://erp.example.com/mako/events"
webhook_secret = "a3f8c1d2..."

Step 3 — Implement the ERP endpoint

Your ERP must accept POST requests at the configured URL:

POST /mako/events
Content-Type:      application/cloudevents+json
X-Idempotency-Key: 01932a4f-7b3e-4c5d-8f6a-9e0b1c2d3e4f   ← always sent
traceparent:       00-4bf92f…-00f067…-01                  ← when the inbound transport carried one
webhook-id:        01932a4f-7b3e-4c5d-8f6a-9e0b1c2d3e4f   ┐
webhook-timestamp: 1786012800                             ├ only when a secret is set
webhook-signature: v1,K5oT9r8GKYqrTwjUPD8ILPZIo2LaLaSw…   ┘

Body (CloudEvents 1.0 structured-mode JSON):

{
  "specversion": "1.0",
  "id": "01932a4f-7b3e-4c5d-8f6a-9e0b1c2d3e4f",
  "source": "urn:mako:makod:tenant:9900357000004",
  "type": "de.mako.aperak.accepted",
  "time": "2026-10-01T10:15:00+02:00",
  "subject": "018f3a2b-...",
  "dataschema": "https://raw.githubusercontent.com/BO4E/BO4E-Schemas/v202607.1.0/src/bo4e_schemas/bo/Marktlokation.json",
  "datacontenttype": "application/json",
  "makoconvid": "...",
  "makocausationid": "...",
  "makopid": 55001,
  "data": {
    "_typ": "MARKTLOKATION",
    "_version": "202501",
    "marktlokationsId": "51238696799",
    "sparte": "STROM",
    "bilanzierungsmethode": "SLP",
    "energierichtung": "VERBRAUCH",
    "netzbetreibercodenr": "9900357000004"
  }
}

webhook-id, X-Idempotency-Key and the CloudEvent id are the same value. webhook-id is what the signature covers; X-Idempotency-Key is sent unconditionally, for a deployment without a secret.

Step 4 — Verify the signature

mako signs with Standard Webhooks. Use a library; the code below shows what one checks. All three must hold:

  1. The signature matches base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}")). Note it covers the id and the timestamp, not the body alone.
  2. webhook-timestamp is within 5 minutes of now, in either direction. Without this a captured request replays forever.
  3. webhook-id has not been seen before. Reject or ignore the duplicate.
import base64, hmac, hashlib, time

TOLERANCE = 300  # seconds, each way

def verify_mako_webhook(body: bytes, secret: str, headers) -> str:
    """Returns the webhook id to deduplicate on; raises on refusal."""
    msg_id = headers["webhook-id"]
    ts     = int(headers["webhook-timestamp"])
    if abs(time.time() - ts) > TOLERANCE:
        raise ValueError("stale or future timestamp — possible replay")

    signed  = f"{msg_id}.{ts}.".encode() + body
    digest  = hmac.new(secret.encode(), signed, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(digest).decode()

    # The header is a space-separated list, so a key rotation presents both.
    if not any(hmac.compare_digest(expected, part)
               for part in headers["webhook-signature"].split()):
        raise ValueError("no signature matched")
    return msg_id   # ← persist this; reject a repeat
import { createHmac, timingSafeEqual } from "crypto";

const TOLERANCE = 300; // seconds, each way

function verifyMakoWebhook(body: Buffer, secret: string, headers: Record<string, string>): string {
  const id = headers["webhook-id"];
  const ts = Number(headers["webhook-timestamp"]);
  if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE) {
    throw new Error("stale or future timestamp — possible replay");
  }

  const signed = Buffer.concat([Buffer.from(`${id}.${ts}.`), body]);
  const expected = "v1," + createHmac("sha256", secret).update(signed).digest("base64");

  const ok = headers["webhook-signature"].split(" ").some((part) =>
    part.length === expected.length &&
    timingSafeEqual(Buffer.from(part), Buffer.from(expected)));
  if (!ok) throw new Error("no signature matched");
  return id; // ← persist this; reject a repeat
}

Key rotation. webhook-signature is a space-separated list. mako emits one v1 entry; its own verifier (mako_service::webhook::verify_request) accepts any matching entry and skips other versions. Implement the receiver the same way so a secret can be rotated without a flag-day.

Step 5 — Return HTTP 200 for duplicates

makod retries on 429, 5xx and network errors; any other 4xx is permanent and dead-letters the message. Persist the id and answer a duplicate with 200 OK without re-processing.


Integration surfaces

DirectionMechanismDescription
makod → ERP--erp-webhook-url / WebhookErpAdapterPOST BO4E JSON on every process event
ERP → makodPOST /api/v1/commandsInitiate a MaKo process (Lieferbeginn, Gerätewechsel, …)
ERP → makodPUT /admin/malo/{malo_id}Push MaLo master data to the local cache
ERP → makodPUT /admin/partners/{mp_id}Register or update a trading-partner endpoint
marktd → invoicdPOST /webhook CloudEventsGPKE/WiM billing notifications for automatic plausibility check
invoicd → makodPOST /api/v1/commandsthe answering PID's accept/reject command (gpke.abrechnung.*, wim.*, gabi.*, geli.*, invoic.*)
invoicd → ERPde.invoic.receipt.settled/disputed CloudEventsDurable at-least-once payment notifications
invoicd → ERPde.invoic.payment.overdue CloudEventsAnnounced once per receipt when pay_by passes without confirm-payment
ERP → invoicdPOST /api/v1/receipts/{id}/confirm-paymentClose § 147 AO / GoBD payment audit trail when bank transfer confirmed
ERP → invoicdGET /api/v1/zahlungsstatus/{malo_id}AR reconciliation — settled / pending / overdue counts
edmd API → ERPGET /api/v1/deliveries/{malo_id}BO4E Vec<Energiemenge> — typed meter readings for billing import
edmd API → ERPGET /api/v1/lastgang/{malo_id}BO4E Lastgang — interval time series grouped by OBIS register
edmd API → ERPGET /api/v1/billing-period/{malo_id}MeterBillingPeriod — arbeitsmenge, spitzenleistung, brennwert
ERP → marktdPUT /api/v1/nb-contracts/{id}Upsert NB contract with full BO4E Vertrag payload
obsd API → ERPGET /obs/kpisBNetzA KPI report — § 7a Abs. 5 EnWG Gleichbehandlung parity, STP rates, decision times

ERP-facing integration topology

The services an ERP integration touches directly; the full mesh is in Service topology.

graph TB
    ERP["ERP System<br/>(SAP / Powercloud / custom)"]
    makod["makod :8080 / :4080<br/>EDIFACT ↔ AS4 · SlateDB<br/>GPKE / WiM / GeLi Gas / MABIS"]
    marktd["marktd :8180 · PostgreSQL<br/>MaLo/MeLo/contracts · Vertrag<br/>typed rubo4e::current API<br/>durable fan-out"]
    invoicd["invoicd :8280 · PostgreSQL<br/>INVOIC plausibility · REMADV<br/>§ 147 AO / GoBD receipts"]
    edmd["edmd :8380 · PostgreSQL<br/>Vec<Energiemenge> deliveries<br/>Lastgang · MeterBillingPeriod"]
    obsd["obsd :8480 · PostgreSQL<br/>process projections<br/>BNetzA §20 KPI reports"]
    processd["processd :8580<br/>NB STP `mako-pruefung`<br/>LF answers 55007 / 55010"]
    billingd["billingd :9280 · PostgreSQL<br/>energy billing · EN 16931<br/>XRechnung CII / PEPPOL UBL"]
    outputd["outputd :9880<br/>template store · ZUGFeRD PDF/A-3<br/>Textform proofs"]
    accountingd["accountingd :9380 · PostgreSQL<br/>double-entry ledger · Kontokorrent<br/>SEPA pain.008 / camt.05x"]

    ERP -->|"PUT /api/v1/malos/{id}<br/>PUT /api/v1/nb-contracts/{id}<br/>(typed BO4E payload)"| marktd
    ERP -->|"POST /api/v1/commands<br/>(initiate Lieferbeginn, …)"| makod
    ERP -->|"POST /receipts/{id}/confirm-payment"| invoicd

    makod -->|"de.mako.process.*<br/>HMAC-signed CloudEvents"| marktd
    marktd -->|"fan-out<br/>de.mako.* + de.markt.*"| ERP
    marktd -->|"de.mako.process.initiated"| invoicd
    marktd -->|"de.mako.process.initiated"| processd
    marktd -->|"de.mako.*"| edmd
    marktd -->|"de.mako.*"| obsd

    invoicd -->|"de.invoic.receipt.settled/disputed<br/>de.invoic.payment.overdue"| ERP
    invoicd -->|"gpke.abrechnung.annehmen/.ablehnen"| makod
    processd -->|"gpke.lieferbeginn.bestaetigen/.ablehnen<br/>geli.lieferbeginn.anmelden"| makod

    ERP -->|"GET /api/v1/deliveries/{malo_id}<br/>(Vec<Energiemenge>)<br/>GET /obs/kpis"| edmd
    ERP -.->|"GET /obs/kpis<br/>GET /obs/overdue"| obsd

    billingd -->|"render · pin template hash"| outputd
    billingd -->|"de.billing.rechnung.erstellt"| accountingd
    ERP -.->|"GET /open-items<br/>GET /trial-balance"| accountingd

marktd's fan-out is persist-before-fan-out: an event lands in event_log before any delivery, and each per-subscriber delivery is tracked, retried and dead-lettered in event_delivery.

Delivery pipeline

graph TB
    H["Workflow::handle()"] --> WO["WorkflowOutput<br/>events + outbox_messages"]
    WO --> WBsingle atomic WriteBatch<br/>SSI-isolated
    WB --> ES[("EventStore")]
    WB --> OS[("OutboxStore")]
    OS --> EW["OutboxErpWorker<br/>polls every 5 s"]
    OS --> AW["OutboxWorker<br/>polls every 5 s, separate"]
    EW -->|"CloudEvents 1.0<br/>application/cloudevents+json"| ERP["ERP webhook"]
    AW -->|"EDIFACT"| AS4["AS4 sender"]
    AS4 --> CP["BDEW counterparty"]

Events and outbox entries land in one WriteBatch, so there is no state with one and not the other. The two workers are independent: an ERP that is down cannot delay an APERAK, and an unreachable counterparty cannot stall the ERP feed.

The payload-key seam

An EDIFACT outbox entry carries a typed payload from mako_engine::payload (AperakPayload, UtilmdPayload, …), so producer and renderer share one struct. An ERP notification entry is still a message_type string plus JSON: a workflow writes PendingOutbox::new("GabiFinalAllocationOverdue", recipient, json!({…})) and map_message_type_to_erp_event (services/makod/src/core/erp_adapter.rs) reads the string back to pick an ErpEventType. Keys inside data — gas_day, tx_id, zustimmung — are a convention between writer and consumer.

every_enqueued_message_type_maps_to_a_cloud_event asserts that every message_type a workflow enqueues resolves, and the_gabi_overdue_notification_carries_the_alocat_missing_type pins the mapping an agent subscribes to by name. When you add an ERP message type, add it to that list — an unmapped type is delivered to nobody.

PID → event mapping

PID familyProcessCloudEvents type sequence
GPKE 55001Lieferbeginn LF-ANde.mako.process.initiated → de.mako.aperak.accepted → de.mako.process.completed
GPKE 55004Lieferende / Abmeldung (LFN → NB)same
GPKE 55016Kündigung Lieferbeginn (LFN → LFA)same
GPKE 31001–31005Abrechnung INVOICde.mako.process.initiated → de.mako.process.completed + de.invoic.receipt.settled/disputed
WiM 31009MSB-Rechnung (LF payer)de.mako.process.initiated → REMADV → de.invoic.receipt.settled/disputed
WiM 55039, 55042, 55051, 55168Gerätewechsel / MSB-Wechselde.mako.process.initiated → de.mako.aperak.accepted → de.mako.process.completed
WiM 55168 (Konfiguration confirmed)Steuerungsauftrag positive Endantwortde.mako.process.completed + de.vpp.dispatch.confirmed → auto-billing in billingd
GeLi Gas 44001–44006Lieferbeginn Gas (LFN-initiated)de.mako.process.initiated → de.mako.aperak.accepted → de.mako.process.completed
GeLi Gas 44016–44018Kündigung Lieferbeginn Gas (LFN ↔ LFA)same
GeLi Gas 17103Gas Datenabruf (Brennwert/Zustandszahl)de.mako.process.initiated → de.mako.process.completed
GeLi Gas 17115, 17117Sperr-/Entsperrauftrag Gas (LF-initiated)de.mako.process.initiated → de.mako.process.completed or de.mako.process.failed
MABIS 13003Bilanzkreisabrechnung Stromde.mako.process.initiated → de.mako.process.completed or de.mako.process.failed

invoicd payment CloudEvents

After each checked INVOIC, invoicd posts payment CloudEvents to the ERP when [erp] webhook_url is set in invoicd.toml.

TypeTriggerUse case
de.invoic.receipt.settledInvoice acceptedBook received invoice
de.invoic.receipt.disputedInvoice rejectedFlag for manual review
de.invoic.receipt.dispatchedSelf-issued 31006 sentTrack self-issued invoice

The dispatched field says whether the market answer went out — do not pay against a settled invoice whose REMADV never left.

Payment events use source: "urn:mako:invoicd:tenant:{tenant}" and subject: "{process_id}".

Durable at-least-once. The first attempt runs inline after the market answer is dispatched; a transport error or 5xx is retried (30 s → 5 min → 30 min → 2 h, dead-lettered after 5 attempts); a 4xx dead-letters at once. Batches are claimed with a lease. Track invoic_receipts.erp_notified_at and the invoicd_erp_dead_lettered_total gauge.

Request signing: when [erp] hmac_secret is configured, every POST includes Standard Webhooks headers computed over {webhook-id}.{webhook-timestamp}.{body}.

CloudEvents envelope schema

Required CloudEvents attributes:

AttributeValueNotes
specversion"1.0"Always
id<idempotency_key>Stable dedup key — persist in ERP
source"urn:mako:makod:tenant:<tenant_id>"Operator MP-ID
type"de.mako.<domain>.<action>"See event type table below
timeRFC 3339 with timezone offsetWall-clock time of domain event
subject<process_id> UUIDThe mako process that fired the event

Optional CloudEvents attributes:

AttributeValueNotes
dataschemaBO4E JSON Schema URLPresent when data carries a BO4E object
datacontenttype"application/json"Always present when data is non-null

mako extension attributes (lowercase alphanumeric per CloudEvents spec §3.3):

ExtensionTypeDescription
makoconvidstringBDEW Vorgangsnummer
makocausationidstringmako domain event UUID that caused this notification
makopidintegerPrüfidentifikator
makofailreasonstringOnly present on de.mako.process.failed

data field:

A BO4E object, or null where none applies (e.g. de.mako.contrl.received).

Event type → CloudEvents type mapping:

CloudEvents typeTriggerPrimary BO4E payload
de.mako.process.initiatedNew inbound UTILMD receivedMarktlokation
de.mako.aperak.acceptedCounterparty accepted our UTILMDMarktlokation
de.mako.aperak.rejectedCounterparty rejected our UTILMDMarktlokation + rejection reason
de.mako.aperak.timeoutNo APERAK within regulatory SLAMarktlokation
de.mako.contrl.receivedCONTRL syntax acknowledgement— (null data)
de.mako.process.completedLieferbeginn/Lieferende confirmedMarktlokation + Vertrag
de.mako.process.failedFatal error / regulatory deadline exceededMarktlokation
de.mako.malo.identifiedMaLo-ID lookup resolvedMarktlokation
de.mako.abmeldeanfrage.beantwortetThe LFA answered the NB's Anfrage zur Beendigung der Zuordnung (55010/55011/55012), or its 09:00 window lapsed — a Zustimmung per GPKE Teil 2 § 2.1.2 Nr. 4. processd resumes the Anmeldung decision on it{pid, malo_id, grid_operator, lfa_mp_id, anmeldung_process_id, zustimmung, fristablauf} plus antwortcode, grund and zuordnungsende when the LFA named them
de.vpp.dispatch.confirmedMSB positively confirmed a WiM Steuerungsauftrag (PID 55168) of type Konfiguration — triggers VPP auto-billing in billingd{tx_id, location_id, location_type, max_power_kw, execution_time_from, execution_time_until, command_type, sender_mp_id, produkt_code}
de.gabi.alocat.missingThe § 47 Ziffer 1 KoV XV final-allocation window (M+14 Werktage) closed with no final ALOCAT on file; open a Clearingfall with the FNB/MGV{gas_day, deadline_label, sender_eic, receiver_eic, pruefidentifikator}
de.gabi.nomination.curtailedThe FNB/MGV confirmed less than was nominated; the portfolio is short until the BKV re-nominates or buys the gap{gas_day, nominated_kwh, confirmed_kwh, curtailed_kwh, sender_eic, receiver_eic, pruefidentifikator, nomination_ref}
de.gabi.nomination.rejectedThe FNB/MGV refused the nomination; nothing flows on it{gas_day, reason, nominated_kwh, sender_eic, receiver_eic, pruefidentifikator, nomination_ref}
de.gabi.nomres.missingThe KoV NOMRES window closed unanswered, so the nomination's status is unknown at gas-day start{gas_day, deadline_label, sender_eic, receiver_eic, pruefidentifikator, nomination_ref}

Retry and back-off

OutboxErpWorker retries with exponential back-off:

AttemptDelay
1st failure5 min
2nd failure10 min
3rd failure20 min
4th failure40 min
5th+ failure60 min (capped)
After 10 failuresDead-lettered; WARN logged

HTTP response codes:

CodeInterpretation
2xxSuccess — message acknowledged
4xx except 429Permanent error — message dead-lettered immediately
429Transient — rescheduled with back-off
5xxTransient — rescheduled with back-off
Network timeout / errorTransient — rescheduled with back-off

No-secret mode

Without --erp-webhook-secret all three Standard Webhooks headers are omitted (mako_service::webhook::headers produces them as a set); dedup on X-Idempotency-Key. Use this only in local development.

LogErpAdapter (development / logging only)

Without --erp-webhook-url, makod uses LogErpAdapter, which logs every event at INFO:

INFO mako::erp: ErpAdapter: event logged (no delivery configured)
    idempotency_key=01932a4f-...
    event_type=aperak_accepted   ← short label for structured logs/metrics
    process_id=018f3a2b-...
    tenant_id=9900357000004
    pid=55001

event_type is ErpEventType::label(), the snake_case key for logs and metrics; the wire type is set only by WebhookErpAdapter.


Inbound: ERP initiates a MaKo process

REST (POST /api/v1/commands)

A command envelope carries the dotted command name, the issuing Marktrolle and a command-specific payload; makod resolves the PID. Most GPKE/GeLi commands need only the MaLo-ID and the process date — NB, MSB and MeLo come from the master-data cache:

POST /api/v1/commands
Content-Type: application/json
Idempotency-Key: erp-order-991234
Authorization: Bearer <token>

{
  "command": "gpke.lieferbeginn.anmelden",
  "marktrolle": "LF",
  "payload": {
    "malo_id":            "51238696799",
    "lieferbeginn_datum": "2026-10-01"
  }
}

marktrolle is required only for a command more than one role may issue (such as wim.geraetewechsel.beauftragen, permitted for NB and MSB); for a single-role command it is inferred from the name. Never include sender_party_id, receiver_party_id, pruefidentifikator or message_ref — those are engine-owned.

A billing command carries a BO4E object in payload:

{
  "command": "invoic.netznutzungsrechnung.senden",
  "marktrolle": "NB",
  "payload": {
    "_typ": "RECHNUNG",
    "_version": "202607.1.0",
    "rechnungsnummer": "NN-2026-000123",
    "gesamtnetto":  { "_typ": "BETRAG", "wert": "300.00", "waehrung": "EUR" },
    "gesamtsteuer": { "_typ": "BETRAG", "wert": "57.00",  "waehrung": "EUR" },
    "gesamtbrutto": { "_typ": "BETRAG", "wert": "357.00", "waehrung": "EUR" }
  }
}

_version is optional and spelt without a v (202607.1.0); mako reads the series (202607), so a producer one patch ahead is accepted.

Response — 202 Accepted:

{
  "idempotency_key": "erp-order-991234",
  "command": "gpke.lieferbeginn.anmelden",
  "marktrolle": "LF",
  "status": "accepted",
  "process_id": "018f3a2b-..."
}

What Idempotency-Key does

The header is required (422 missing_idempotency_key without it). Use one stable value per business request — your ERP order or correlation ID. makod stores the accepted response for 24 hours and replays it verbatim on a retry, with no second dispatch. The same key with a different command or payload is 422 idempotency_key_reuse.

// 422 — the key already belongs to a different request.
{ "error": "idempotency_key_reuse", "detail": "…" }

A storage failure on the lookup is 503 idempotency_unavailable — retry.

Beneath the key sits the business-level guard: a second anmelden while a process is active for the same business key is 409 duplicate_process even under a fresh key, carrying the existing process_id. Treat it as success and adopt the process_id. The other 409, invalid_state, is a real error and carries none.

// 409 — your retry already succeeded. Adopt the process_id.
{ "error": "duplicate_process", "malo_id": "51238696799", "process_id": "018f3a2b-..." }

// 409 — genuine rejection. Do not retry.
{ "error": "invalid_state", "detail": "cannot bestaetigen a process in state Abgeschlossen" }

The guard covers a running process of the same family only, decided from the process's replayed state: a rejected Anmeldung does not block the corrected one, an executed Sperrung does not block the Entsperrung, and a process of a different family on the same MaLo is allowed. Derive keys from the business event (MaLo and process date), not the MaLo alone.

BO4E _typ → PID mapping:

BO4E _typmarktrolle / contextPID family
VERTRAG (Beginn, Strom)LFGPKE 55001
VERTRAG (Ende, Strom)LFGPKE 55004
VERTRAG (Beginn, Gas)LFGeLi Gas 44001
ZAEHLER (Geräteübernahme)MSBWiM ORDERS 17001/17002
RECHNUNGBKVMABIS 13003

MaLo master data cache

makod answers POST /maloId/request/v1 (BDEW API-Webdienste Strom) from a local cache; the ERP is the authoritative master. The body wraps a BDEW MaloIdentResultPositive (the energy-api model):

PUT /admin/malo/51238696799
Authorization: Bearer <token>
Content-Type: application/json

{
  "result": { "dataMarketLocation": { "maloId": "51238696799", … }, … },
  "valid_from": "2026-10-01",
  "source": "erp-sync"
}

Push it on contract activation, address change, contract end and every grid assignment change — a wrong grid operator is a common cause of APERAK rejections.

Cache admin

GET    /admin/malo/stats            ← record count + last-upsert timestamp per tenant
DELETE /admin/malo/51238696799      ← remove on contract end

Trading-partner directory

PUT /admin/partners/9900000000001
Authorization: Bearer <token>
Content-Type: application/json

{
  "mp_id": "9900000000001",
  "display_name": "Stadtwerke Beispiel GmbH",
  "channels": [
    { "qualifier": "AK", "address": "https://partner.example/as4/inbox" }
  ],
  "roles": ["NB"]
}

Or bulk-import from a PARTIN EDIFACT interchange:

POST /admin/partners/import
Authorization: Bearer <token>
Content-Type: text/plain; charset=utf-8

<raw PARTIN interchange>

Receiving CloudEvents — ERP implementation guide

Checklist

  1. Accept POST from makod's IP or via your load balancer.
  2. Verify the Standard Webhooks signature before touching the body — and with it the webhook-timestamp, or a captured request replays forever.
  3. Deserialise the CloudEvents envelope.
  4. Persist webhook-id (identical to the envelope's id) before acting.
  5. Check it against your dedup log — discard duplicates without error.
  6. Dispatch on type to your business logic.
  7. Return 200 OK (or any 2xx) — makod retries on 429/5xx.

Python (FastAPI)

import json
from fastapi import FastAPI, Request, HTTPException, status
# In production use a Standard Webhooks library; `verify_mako_webhook` from
# Step 4 is shown so you can see what it checks.

app     = FastAPI()
SECRET  = "your-shared-secret"           # same as --erp-webhook-secret

@app.post("/mako/events")
async def receive(request: Request):
    body = await request.body()

    # 1. Signature + timestamp freshness, in one call. Returns the id.
    try:
        msg_id = verify_mako_webhook(body, SECRET, request.headers)
    except ValueError:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)

    event = json.loads(body)

    # 2. Idempotency — `webhook-id` is the envelope's `id`, so either works.
    if already_processed(msg_id):
        return {"ok": True}          # return 2xx; makod won't retry

    mark_as_processed(msg_id)

    # 3. Dispatch on CloudEvents type
    match event["type"]:
        case "de.mako.process.initiated":
            await on_process_initiated(event)
        case "de.mako.aperak.accepted":
            await on_aperak_accepted(event)
        case "de.mako.aperak.rejected":
            await on_aperak_rejected(event)
        case "de.mako.aperak.timeout":
            await on_aperak_timeout(event)
        case "de.mako.process.completed":
            await on_process_completed(event)
        case "de.mako.process.failed":
            await on_process_failed(event, reason=event.get("makofailreason"))
        case _:
            pass  # forward-compatible: ignore unknown types

    return {"ok": True}

TypeScript / Node.js (Express)

import express, { Request, Response } from "express";
// In production use a Standard Webhooks library; `verifyMakoWebhook` from
// Step 4 is shown so you can see what it checks.

const app    = express();
const SECRET = process.env.MAKO_WEBHOOK_SECRET!;

app.post(
  "/mako/events",
  express.raw({ type: "application/cloudevents+json" }),
  async (req: Request, res: Response) => {
    // 1. Signature + timestamp freshness, in one call. Returns the id.
    let msgId: string;
    try {
      msgId = verifyMakoWebhook(req.body, SECRET, req.headers as Record<string, string>);
    } catch {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString());

    // 2. Idempotency — `webhook-id` is the envelope's `id`, so either works.
    if (await alreadyProcessed(msgId)) return res.json({ ok: true });
    await markAsProcessed(msgId);

    // 3. Dispatch
    switch (event.type) {
      case "de.mako.process.initiated":  await onInitiated(event);  break;
      case "de.mako.aperak.accepted":    await onAccepted(event);   break;
      case "de.mako.aperak.rejected":    await onRejected(event);   break;
      case "de.mako.aperak.timeout":     await onTimeout(event);    break;
      case "de.mako.process.completed":  await onCompleted(event);  break;
      case "de.mako.process.failed":     await onFailed(event);     break;
    }
    res.json({ ok: true });
  }
);

SAP BTP / Cloud Integration

SAP Business Technology Platform can consume CloudEvents 1.0 natively via SAP Event Mesh or the SAP Integration Suite. Configure a webhook receiver channel with:

  • Channel type: HTTP-based receiver
  • Authentication: Standard Webhooks (webhook-signature)
  • Content type: application/cloudevents+json
  • Format: JSON

The type attribute (de.mako.*) maps to an SAP Event Mesh topic; you can route events to different integration flows using the topic filter.

AWS EventBridge

Register makod as a partner event source or configure a direct API Gateway → EventBridge target. CloudEvents type maps to an EventBridge detail-type. Set the HMAC verification in an API Gateway Lambda authorizer.


Idempotency implementation notes

Delivery is at-least-once, so the receiver must be idempotent. A dedup table (PostgreSQL):

CREATE TABLE mako_webhook_dedup (
  id         TEXT PRIMARY KEY,          -- CloudEvents `id` = idempotency key
  received_at TIMESTAMPTZ DEFAULT now(),
  type       TEXT NOT NULL
);

-- Expire after 30 days (beyond any realistic retry window)
CREATE INDEX ON mako_webhook_dedup (received_at)
  WHERE received_at < now() - INTERVAL '30 days';
-- Check + insert atomically; return whether this is new
INSERT INTO mako_webhook_dedup (id, type)
VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING
RETURNING id;
-- Rows returned = 1 → new event; 0 → duplicate, skip

Writing a custom ErpAdapter

For mTLS, a message-bus sink or an ERP SDK, implement the trait:

use mako_engine::erp::{ErpAdapter, ErpAdapterError, ErpEvent};

struct MySapAdapter { client: SapHttpClient }

impl ErpAdapter for MySapAdapter {
    async fn notify(&self, event: ErpEvent) -> Result<(), ErpAdapterError> {
        // event.payload contains the BO4E object (same as CloudEvents `data`).
        let malo: MyMalo = serde_json::from_value(event.payload)
            .map_err(ErpAdapterError::payload)?;

        // event.idempotency_key = CloudEvents `id` — use for dedup.
        self.client
            .post_event(&event.idempotency_key, malo.id, event.event_type.label())
            .await
            .map_err(|e| {
                if e.is_retryable() {
                    ErpAdapterError::transport(e)
                } else {
                    ErpAdapterError::permanent(e)
                }
            })
    }
}

Wire it in services/makod/src/core/erp_adapter.rs beside WebhookErpAdapter.

Error classification contract

ReturnWorker behaviour
Ok(())Acknowledged — removed from outbox
Err(ErpAdapterError::Transport(_))Retried with exponential back-off
Err(ErpAdapterError::Permanent(_))Dead-lettered immediately
Err(ErpAdapterError::Payload(_))Dead-lettered immediately

Configuration reference

CLI flagEnv varTOML keyDefaultDescription
--erp-webhook-urlMAKOD_ERP_WEBHOOK_URLerp.webhook_url—ERP endpoint URL (enables HTTP delivery)
--erp-webhook-secretMAKOD_ERP_WEBHOOK_SECRETerp.webhook_secret—HMAC-SHA256 signing key

makod.toml example:

[erp]
webhook_url    = "https://erp.internal/mako/events"
webhook_secret = "env:ERP_WEBHOOK_SECRET"   # read from environment at startup

Testing

mako-engine's testing feature provides NoopErpAdapter (succeeds without delivering) and LogErpAdapter (logs at INFO). After a Process::run, assert on InMemoryOutboxStore::pending_now for the ERP-targeted entry.


Why BO4E (not EDIFACT)

BO4E is the open German standard for energy-market data models, with MIT-licensed implementations for Python, C#, Go, Kotlin, TypeScript and PHP. makod absorbs EDIFACT format changes; the ERP receives Marktlokation.marktlokationsId directly and dispatches on reverse-DNS CloudEvents types. The BO4E schema tag is v202607.1.0; a payload's _version is 202607.1.0 and dispatch keys on the series 202607.


The integration surface

ComponentWhereRole
ErpAdapter / ErpEvent traitsmako-engine/src/erp.rsOutbound event contract
ErpEventType (14 variants)mako-engine/src/erp.rscloud_event_type() resolves each to a mako-events constant; label() gives the snake_case log/metric key
WebhookErpAdapter (HMAC-SHA256 signed)makod/src/core/erp_adapter.rsDelivers CloudEvents to the ERP webhook
OutboxErpWorker (exponential back-off)makod/src/core/erp_adapter.rsAt-least-once delivery with retry + dead-letter
POST /api/v1/commandsmakod/src/orchestrator/commands_api/ERP-initiated process commands
PUT /admin/malo/{malo_id} · PUT /admin/partners/{mp_id}makodMaster-data cache sync
BO4E typed model (rubo4e)workspace dependencyrubo4e = "0.14", BO4E schema v202607.1.0; typed BOs at every API boundary, strict-decoded on ingest (Bo4eStrict::ensure_known_enums) and checked against BO4E's own rules (.validate())

TopicFile
makod operator referencemakod
marktd operator referencemarktd
invoicd operator guideinvoicd
edmd operator guideedmd
obsd operator guideobsd
Engine architectureEngine
API-Webdienste Strom (MaLo-ID)API-Webdienste
Annual release workflowAnnual release workflow

Automated Billing Settlement

invoicd checks every received INVOIC — ten PIDs across four answer-command families: GPKE (31001, 31002, 31005, 31006), WiM (31003, 31009), GaBi Gas (31007, 31008), and the Sparte-neutral 31004 Storno and 31011 sonstige Leistung. It subscribes to de.mako.process.initiated from marktd, runs invoic-checker, persists the receipt (a Buchungsbeleg — § 147 Abs. 3 AO / § 14b UStG) and issues the settlement command without ERP involvement.

Full billing flow

NB (counterparty)
    │  INVOIC (one of the ten routed PIDs; 31001/31002/31005/31006 shown)
    │  AS4/EDIFACT push
    ▼
makod :8080
    │  GpkeAbrechnungWorkflow.handle(ReceiveInvoic)
    │  emits: de.mako.process.initiated
    │  outbox: invoice_ref, Rechnung BO4E object
    ▼
marktd :8180
    │  fan-out to registered subscribers
    ▼
invoicd :8280
    │  InvoicCheckEngine::check(tariff_store, check_config, &rechnung)
    │  upsert_receipt(pool, row)  ← persist to PostgreSQL BEFORE dispatching
    │
    ├─ no dispute findings ──► POST /api/v1/commands
    │                              {"command": "gpke.abrechnung.annehmen",
    │                               "payload": {"invoice_ref": "..."}}
    │                              ↓  mark_dispatched(pool, process_id)
    │                          makod emits REMADV 33001 (Zahlungsavis)
    │                          AS4 → NB
    │
    └─ dispute findings ─────► POST /api/v1/commands
                                   {"command": "gpke.abrechnung.ablehnen",
                                    "payload": {"invoice_ref": "...",
                                                "ablehnungsgrund": "..."}}
                                   ↓  mark_dispatched(pool, process_id)
                               makod emits REMADV 33002 — or 33003/33004
                               when the tree answers positionsscharf
                               AS4 → NB

Settlement is „ganz oder gar nicht". REMADV AHB 1.0a § 3 admits no Teilzahlung: only 33001 confirms; 33002 refuses with one code (§ 3.1.1), 33003 („Abweisung Kopf und Summe") and 33004 („Abweisung Position") carry one code per disputed position (§ 3.1.2). RemadvAntwort::remadv_pid picks from the shape of the decision tree's answer.

COMDIS 29001 is the invoicer's refusal of a payer's REMADV (COMDIS AHB 1.0) and not part of this flow; APERAK uses the same number for a different message.

The plausibility pipeline

InvoicCheckEngine::check runs eight stages in a fixed order, plus one for a single PID, each appending to one Vec<Finding>:

#StageWhat it verifies
1Storno referenceist_storno = true must name the original in original_rechnungsnummer. Source: BK6-24-174 §5; Allgemeine Festlegungen §8
2Period validityrechnungsperiode.startdatum ≤ enddatum; line-item periods via lieferungszeitraum
3Zahlungszielfaelligkeitsdatum is not before rechnungsdatum (dispute) and does not exceed max_zahlungsziel_days — 30 days by default (warn). Source: §7 Allgemeine Festlegungen V6.1d
3aWiM 31003 send windowA Dienstleistungsrechnung dated more than 20 Werktage after the period it bills. Source: WiM Teil 1 Kap. 3.7.2 Nr. 1. Only PID 31003, and a Warn that does not escalate on the invoice's value
4Currency agreementRuns before the arithmetic, so a CHF Betrag is not compared as EUR
5Position arithmeticposition.positions_menge × einzelpreis ≈ gesamtpreis (within arithmetic_tolerance)
6Document totalSum of rechnungspositionen[*].gesamtpreis ≈ gesamtnetto (within total_tolerance)
7UmsatzsteuerThe per-rate steuerbetraege breakdown sums to gesamtsteuer — the figure the recipient's Vorsteuerabzug is computed from
8Tariff matchEach position's unit price falls within the registered tariff band ± tariff_tolerance; a missing tariff entry for the MaLo + period is a finding whose severity follows require_tariff

Stage 3 compares calendar dates in the offset the payload carries (rechnungsdatum_date(), faelligkeitsdatum_date()), not timestamps. Stage 8 is skipped for a Stornorechnung, which carries negated amounts rather than tariff positions. Beside the pipeline, check_mmm_settlement prices MMM invoices (31005–31008) against the MMMA reference prices in marktd, and check_msb_rechnung / check_esa_rechnung price a WiM or ESA invoice against a PreisblattMessung or the accepted Angebot.

Payment deadline tracking (pay_by)

invoicd extracts the faelligkeitsdatum from each Rechnung and stores it as pay_by TIMESTAMPTZ in invoic_receipts.

Approaching REMADV deadline — GET /api/v1/overdue-remadv lists receipts with pay_by < now() + 3 days and no REMADV dispatched:

GET http://invoicd:8280/api/v1/overdue-remadv
Authorization: Bearer ${TOKEN}

Overdue payment — the payment_overdue worker (every 6 h) emits de.invoic.payment.overdue when pay_by passes without a payment confirmation:

{
  "type":    "de.invoic.payment.overdue",
  "data":    { "receipt_id": "...", "malo_id": "...", "pay_by": "2026-10-15" }
}

Payment confirmation — when the bank transfer clears, POST to close the § 147 AO / GoBD audit trail:

POST http://invoicd:8280/api/v1/receipts/{id}/confirm-payment

Tariff seeding

Price sheets (PreisblattNetznutzung) live in marktd:

curl -X PUT http://marktd:8180/api/v1/preisblaetter/9904234560001 \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d @preisblatt_netznutzung.json   # rubo4e::current::PreisblattNetznutzung (BO4E v202607)

invoicd fetches it at check time (1-hour TTL cache, circuit breaker: 3 failures → 30 s open). The ERP's part is uploading price sheets when rates change and consuming de.mako.process.completed / de.mako.process.failed; the settlement decision needs no ERP response.

Edit this page ↗