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 is a protocol processor, not a business system. It handles EDIFACT parsing, BDEW process rules, AS4 delivery, and regulatory deadlines. All contract data, billing logic, and master data live in your ERP.

The integration contract between the two is BO4E — not raw EDIFACT. Your ERP never sees EDIFACT format versions or segment codes. When BDEW releases a new format version (FV2026-10-01), the BO4E payload your ERP receives is unchanged.

ERP  ←─────────── BO4E JSON ───────────→  makod
                (ErpAdapter / POST /api/v1/commands)

              EDIFACT / AS4 / BDEW network

Master data management via marktd — if you deploy the companion marktd service, configure makod to push process lifecycle events to marktd's ingest endpoint (POST /api/v1/events). marktd then fans out to your registered ERP subscribers, eliminating the need to configure a webhook endpoint directly in makod.

Outbound webhook events are delivered as CloudEvents 1.0 structured-mode JSON. CloudEvents is a CNCF-graduated vendor-neutral standard for event metadata. It is natively supported by SAP BTP, AWS EventBridge, Azure Event Grid, Google Eventarc, and Knative — making makod events directly routeable by any cloud event bus without custom glue code.


Quick-start: wire the ERP webhook in 5 minutes

This is the minimum configuration to get outbound ERP notifications working. makod will POST a JSON event to your ERP endpoint every time a MaKo process reaches a significant state (APERAK received, process completed, etc.).

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's own id are the same value — one identity, not three headers that can disagree. webhook-id is what the signature covers; X-Idempotency-Key is what a signature-less deployment deduplicates on, and is sent unconditionally.

Step 4 — Verify the signature

mako signs with Standard Webhooks. Use a library — there is one for every language an ERP is written in, and it will get the parts below right. The manual implementation is here so you can see what it does, not as a recommendation.

Three things must all 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
}

CloudEvents deliberately excludes signing from its specification — signing semantics vary by use case. Standard Webhooks is the security layer mako puts on top of it.

Key rotation. webhook-signature is a space-separated list. mako's own senders currently emit a single v1 entry, but its verifier (mako_service::webhook::verify_signature) accepts any matching entry and skips entries of another version — so implement your receiver the same way and a secret can later be rotated without a flag-day across every integration.

Step 5 — Return HTTP 200 for duplicates

makod retries on any non-2xx response. Your endpoint must persist idempotency_key and return 200 OK for duplicate deliveries without re-processing. Any 4xx except 429 is treated as a permanent error and the message is dead-lettered.


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
ERP → makodErpCommandSource traitFully event-driven inbound (Kafka, SFTP, CDC, …)
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 below are the ones an ERP integration touches directly. They are a selection, not the whole platform — see Architecture — Service topology for all 17 services and how they relate.

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

Every de.mako.* event from makod flows through marktd's durable fan-out, which is persist-before-fan-out: the event lands in the durable event_log outbox before any delivery, and each per-subscriber delivery is tracked (retried, then dead-lettered) in event_delivery so no regulatory notification is lost — even across a crash.

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 are written atomically. If makod crashes between the two writes, the event is replayed on restart and the outbox entry is re-enqueued — no lost APERAK.

The two workers are independent on purpose: an ERP that is down cannot delay an APERAK to a market partner, and a counterparty that is unreachable cannot stall the ERP feed.

The payload-key seam

An outbox entry is a bare string plus untyped JSON. A domain workflow writes PendingOutbox::new("GabiFinalAllocationOverdue", recipient, json!({…})), and map_message_type_to_erp_event in makod/src/core/erp_adapter.rs reads that string back to pick an ErpEventType. Nothing in the type system links the two: renaming the constant on either side compiles cleanly and turns the notification into an unrecognised type the worker silently skips.

The same holds one level down, for the data payload. Keys such as gas_day, tx_id or zustimmung are agreed by convention between the crate that writes them and the consumer that reads them — billingd's POST /api/v1/webhooks/vpp-dispatch for the VPP payload, processd for the Abmeldeanfrage one. A serde struct on the reading side does not fail on a renamed key; it reads None.

Two tests hold the seam where it has broken before: every_enqueued_message_type_maps_to_a_cloud_event enumerates every message_type a workflow enqueues and asserts each resolves, and the_gabi_overdue_notification_carries_the_alocat_missing_type pins the one mapping an agent subscribes to by name. When you add an outbox message type, add it to that list — a type absent from both is delivered to nobody and reports nothing.

Event types

See the event type table in the CloudEvents envelope schema section below.

PID → event mapping

PID familyProcessCloudEvents type sequence
GPKE 55001Lieferbeginn LF-ANde.mako.process.initiatedde.mako.aperak.acceptedde.mako.process.completed
GPKE 55004Lieferende / Abmeldung (LFN → NB)same
GPKE 55016Kündigung Lieferbeginn (LFN → LFA)same
GPKE 31001–31005Abrechnung INVOICde.mako.process.initiatedde.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.initiatedde.mako.aperak.acceptedde.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.initiatedde.mako.aperak.acceptedde.mako.process.completed
GeLi Gas 44016–44018Kündigung Lieferbeginn Gas (LFN ↔ LFA)same
GeLi Gas 17103Gas Datenabruf (Brennwert/Zustandszahl)de.mako.process.initiatedde.mako.process.completed
GeLi Gas 17115, 17117Sperr-/Entsperrauftrag Gas (LF-initiated)de.mako.process.initiatedde.mako.process.completed or de.mako.process.failed
MABIS 13003Bilanzkreisabrechnung Stromde.mako.process.initiatedde.mako.process.completed or de.mako.process.failed

invoicd payment CloudEvents

After each validated INVOIC, invoicd emits payment CloudEvents directly to your ERP webhook when [erp] webhook_url is configured in invoicd.toml. These events enable accounts-payable automation without polling the REST API.

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

Every checked invoice is announced, accepted or disputed. The dispatched field says whether the market answer actually went out — a settled invoice whose REMADV never left is not one to pay against.

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

Delivery guarantee — durable at-least-once: The first attempt runs inline after the market answer is dispatched. On any failure (transport error, HTTP 5xx), the outbox worker retries with backoff (30 s → 5 min → 30 min → 2 h → dead-letter after 5 attempts). HTTP 4xx is dead-lettered immediately — the ERP rejected these exact bytes. Batches are claimed with a lease, so replicas do not double-deliver. Track delivery via 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}.

Request format

POST <erp_webhook_url>
Content-Type: application/cloudevents+json
webhook-id:        <event.id>                     ← dedup key; always sent
webhook-timestamp: <unix seconds>                 ← only when a secret is set
webhook-signature: v1,<base64>                    ← only when a secret is set

Body is a CloudEvents 1.0 structured-mode JSON object (see below).

CloudEvents envelope schema

All outbound webhook events are CloudEvents 1.0 structured-mode JSON with Content-Type: application/cloudevents+json.

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:

BO4E-typed JSON object. Deserialise using the ERP's own BO4E library. null when no primary BO4E object 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, which GPKE Teil 2 § 2.1.2 Nr. 4 reads as a Zustimmung. processd resumes the Anmeldung decision on it (E_0623 Prüfschritte 30–50){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.confirmedWiM Steuerungsauftrag (PID 55168) positively confirmed by MSB — triggers VPP auto-billing in billingd. Only for Konfiguration (load-reduction) commands, never for InitialZustand resets{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.missing§ 47 Ziffer 1 KoV XV final-allocation window (M+14 Werktage) closed with no binding final ALOCAT — the gas day's imbalance cannot be settled; 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 — NOMRES states no status, so the shortfall shows up only as a reduced quantity; 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}

Full example:

{
  "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"
  }
}

Retry and back-off

makod retries failed webhook deliveries 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

Signature verification

When --erp-webhook-secret is set, every POST includes:

webhook-signature: v1,<base64 HMAC-SHA256 of {id}.{timestamp}.{raw body}>

The header is a space-separated list — accept if any entry matches, which is what makes key rotation possible. The key is the UTF-8 encoding of the shared secret. Always use a constant-time comparison (e.g. hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js) to prevent timing attacks.

No-secret mode

If --erp-webhook-secret is omitted, all three Standard Webhooks headers are omitted — webhook-id included, because the id, the timestamp and the signature are produced together by mako_service::webhook::headers and only make sense as a set. X-Idempotency-Key carries the same value unconditionally, so dedup on that and your receiver works either way.

Do not use no-secret mode in production. Use it only in local development with loopback-only ERP endpoints.

LogErpAdapter (development / logging only)

When --erp-webhook-url is not set, makod falls back to LogErpAdapter which emits every event at INFO level. Useful for verifying event flow during development without a running ERP.

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 in the log line is ErpEventType::label() — a short snake_case key intended for log filters and metric labels. The CloudEvents wire type (de.mako.aperak.accepted) is only set when delivering via WebhookErpAdapter.


Inbound: ERP initiates a MaKo process

REST (POST /api/v1/commands)

Submit a command envelope to trigger a MaKo process: the dotted command name, the Marktrolle issuing it, and a command-specific payload. makod resolves the PID from the command and the process context.

The envelope is not a bare BO4E object. Most GPKE/GeLi commands need only the MaLo-ID and the process date — the engine resolves the NB, the MSB and the MeLo data from its own master-data cache, so an ERP does not repeat them:

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 instead, because there the invoice is the master data and there is no cache entry to resolve it from:

{
  "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 the wire spelling — 202607.1.0, with no v. mako reads the series (202607), so a producer one BO4E patch ahead is accepted rather than refused; it is not required in the payload at all, because mako parses with its own generated types regardless of what a request claims.

Response202 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 — a request without one is rejected with 422 missing_idempotency_key. Use one stable value per business request (your ERP order or correlation ID), not one per attempt and not one per session.

makod stores the accepted response under the key for 24 hours and replays it verbatim on a retry: the same 202, the same process_id, and no second dispatch. A client that lost the reply to a timeout can simply send the request again.

The key is bound to the request it was first used with. Sending the same key with a different command or payload is refused with 422 idempotency_key_reuse rather than answered with the first request's process_id — which is what catches an ERP that generates one key per session instead of one per order.

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

A storage failure on the lookup answers 503 idempotency_unavailable: "I could not tell whether this already ran" is not a licence to run it again. Retry.

The record covers exact retries. The stronger guard is the business-level one underneath it, which no idempotency scheme can replace: a second anmelden while a process is still active for the same business key is refused with 409 duplicate_process even under a fresh key, and that response carries the existing process_id.

The practical consequence for a client: treat 409 duplicate_process as success. It means your earlier attempt took effect and names the process it created — adopt the process_id and carry on. Distinguish it from the other 409, invalid_state, which means the command is not legal in the process's current state and carries no process_id; that one is a real error and retrying it will fail identically.

// 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 is scoped to a process that is still running, and to one process family. A GPKE Lieferbeginn the NB rejected is finished, so the corrected Anmeldung that follows is accepted as a new process — a rejection never retires the MaLo. The same holds across the other families: an executed Sperrung does not block the Entsperrung that follows, a completed Gerätewechsel does not block the next meter change, and a delivered or cancelled ESA Wertebestellung does not block a new order. A concurrent process of a different family on the same MaLo was always allowed and still is.

Whether a given state still holds the MaLo is declared per workflow, so the two answers a client can get — 202 or 409 duplicate_process — follow from the process's replayed state rather than from bookkeeping.

Keys are per-command-instance, not per-lifetime: derive them from the business event (MaLo and the process date), not from the MaLo alone, so that a genuine second Lieferbeginn is not mistaken for a replay of the first.

The separate InboxStore dedup window applies to inbound AS4 messages, not to this API.

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

Event-driven inbound (ErpCommandSource)

For ERP systems with a message bus, implement ErpCommandSource to feed BO4E business objects into the engine without polling:

pub trait ErpCommandSource: Send + Sync + 'static {
    async fn next(&self) -> Result<Option<InboundErpCommand>, ErpAdapterError>;
    async fn ack(&self, id: &str) -> Result<(), ErpAdapterError>;
    async fn nack(&self, id: &str, reason: &str) -> Result<(), ErpAdapterError>;
}

Register at startup:

EngineBuilder::new()
    .with_erp_command_source(Arc::new(MyKafkaSource::new(&config)))
    .build()

MaLo master data cache

makod answers POST /maloId/request/v1 (BDEW API-Webdienste Strom) from a local cache. The ERP is the authoritative master — keep the cache current.

Upsert a MaLo

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

{
  "malo_id": "51238696799",
  "metering_point_operator": "9904357000003",
  "grid_operator": "9900357000004",
  "network_area": "DE-NET-001",
  "address": {
    "street": "Musterstraße", "house_number": "42",
    "postal_code": "10115", "city": "Berlin", "country_code": "DE"
  }
}

Trigger this from the ERP on contract activation, address change, and contract end. Call on every grid assignment change — wrong grid-operator routing is a common source 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

{
  "gln": "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

This section shows how to implement the receiver side of the webhook in common languages. The same pattern works regardless of your ERP stack.

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

makod guarantees at-least-once delivery: the same event may be delivered more than once (e.g. after a crash before the delivery was acknowledged, or during back-off retry). Your receiver must be idempotent.

Recommended dedup table schema (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

If the built-in WebhookErpAdapter does not fit (e.g. you need mTLS, a message-bus sink, or a proprietary ERP SDK), implement the trait directly:

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 makod/src/core/erp_adapter.rs alongside WebhookErpAdapter, or inject it into a custom makod binary.

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

All options can be set via CLI flag, environment variable, or makod.toml.

CLI flagEnv varTOML keyDefaultDescription
--erp-webhook-urlMAKOD_ERP_WEBHOOK_URLerp.webhook_urlERP endpoint URL (enables HTTP delivery)
--erp-webhook-secretMAKOD_ERP_WEBHOOK_SECRETerp.webhook_secretHMAC-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 ships test helpers gated behind feature = "testing":

[dev-dependencies]
mako-engine = { path = "...", features = ["testing"] }

Available types:

TypePurpose
NoopErpAdapterSucceeds without delivering; use in unit tests
LogErpAdapterLogs at INFO; use when you want to see events in test output
NoopErpCommandSourceAlways idle; no inbound commands

Integration test pattern:

use mako_engine::erp::NoopErpAdapter;

#[tokio::test]
async fn aperak_accepted_triggers_erp_notification() {
    let store     = InMemoryEventStore::new();
    let outbox    = InMemoryOutboxStore::new();
    let erp       = NoopErpAdapter;

    // Build engine under test.
    let ctx = EngineBuilder::new()
        .with_event_store(store.clone())
        .with_outbox_store(outbox.clone())
        .build();

    // Execute workflow step.
    ctx.execute(tenant, workflow_id, receive_aperak_cmd()).await.unwrap();

    // Assert outbox contains an ERP-targeted message.
    let pending = outbox.pending_now(10).await.unwrap();
    let erp_msg = pending.iter().find(|m| m.payload_schema.is_some()).unwrap();
    assert_eq!(erp_msg.message_type, "AperakAccepted");
}

Why BO4E (not EDIFACT)

BO4E (Business Objects for Energy, bo4e.de) is the open standard for energy market data models in Germany. Implementations exist for Python, C#, Go, Kotlin, TypeScript, and PHP — all MIT-licensed.

Without BO4E an ERP adapter must understand D_7143 segment positions, maintain identity translation tables, re-implement status code mappings per vendor, and update on every BDEW format release.

With BO4E:

  • makod absorbs EDIFACT format changes internally.
  • The ERP receives Marktlokation.marktlokationsId — already the canonical German MaLo ID; no translation table needed.
  • CloudEvents type carries reverse-DNS semantic identifiers (de.mako.aperak.accepted, de.mako.process.completed) — not raw EDIFACT codes.
  • ErpEventType::label() provides short snake_case labels (aperak_accepted) for structured logging and metric dimensions.
  • BO4E versioning is independent of BDEW format versions. The schema tag is v202607.1.0; the _version a payload carries is 202607.1.0, without the v, and dispatch keys on the series 202607 alone.

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
ErpCommandSource traitmako-engine/src/erp.rsInbound command contract
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

A received INVOIC requires a plausibility check before settlement. invoicd covers ten inbound PIDs across four answer-command families — the GPKE Netznutzungs- and Mehr-/Mindermengen set (31001, 31002, 31005, 31006), the WiM pair (31003, 31009), GaBi Gas MMM (31007, 31008), and the two Sparte-neutral ones (31004 Storno, 31011 sonstige Leistung); the full table is in the invoicd section of the architecture page. Rather than routing every invoice through the ERP, deploy invoicd as an autonomous sidecar. It subscribes to de.mako.process.initiated events from marktd, runs the invoic-checker pipeline, persists every receipt to PostgreSQL (a received INVOIC is a Buchungsbeleg — 8-year retention under § 147 Abs. 3 AO / § 14b UStG), and issues the settlement command — all without any 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, so of the four REMADV Prüfidentifikatoren only 33001 confirms and 33002/33003/33004 are all Abweisungen: 33002 is the plain refusal that answers with one code (§ 3.1.1), while § 3.1.2's 33003 („Abweisung Kopf und Summe") and 33004 („Abweisung Position") carry a set of codes, one per disputed position. RemadvAntwort::remadv_pid picks between them from the shape of the decision tree's answer.

COMDIS 29001 is the other direction and is not part of this flow: it is the invoicer's refusal of a payer's REMADV — e.g. because the stated payment amount is wrong (COMDIS AHB 1.0). The same number is also the APERAK PID for a technical processing failure, which is a different message on a different clock.

The plausibility pipeline

InvoicCheckEngine::check runs eight stages in a fixed order, plus one that runs for a single PID, each appending to one Vec<Finding>. Where the order is load-bearing, the row says so.

#StageWhat it verifies
1Storno referenceist_storno = true must name the original in original_rechnungsnummer. Source: BK6-24-174 §5; Allgemeine Festlegungen §8
2Period validityrechnungsperiode.startdatumenddatum; 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, which would otherwise read a CHF Betrag as EUR and find every later comparison consistent
5Position arithmeticposition.positions_menge × einzelpreisgesamtpreis (within arithmetic_tolerance)
6Document totalSum of rechnungspositionen[*].gesamtpreisgesamtnetto (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, not timestamps. BO4E types both fields date-time and senders pin the timestamp to midnight in their own offset, so subtracting two OffsetDateTimes measures the offsets as well as the days — a 30-day Zahlungsziel reads as 29 or as 30-plus-two-hours depending on who sent it. rechnungsdatum_date() and faelligkeitsdatum_date() take the date in the offset the payload carries.

Stage 8 is skipped for a Stornorechnung. A Storno carries the negated original amounts, not tariff positions, so comparing them would raise a TariffDeviation dispute on every correct correction.

Two checks sit beside the pipeline, entered through their own functions rather than by check: check_mmm_settlement (MMM invoices — PIDs 31005, 31006, 31007, 31008 — whose mehr_preis / minder_preis positions must match the MMMA reference prices held in marktd, within tolerance) and check_msb_rechnung / check_esa_rechnung, which price a WiM or ESA invoice against a PreisblattMessung or the accepted Angebot.

BO4E field names: Rechnungsposition carries the line total in gesamtpreis and the delivery period in lieferungszeitraum. Convenience methods .gesamtpreis_decimal(), .lieferung_von_date() and .lieferung_bis_date() bridge the structural access.

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) — receipts where pay_by < now() + 3 days and the REMADV has not yet been dispatched. Poll this from your ERP for a rolling Zahlungsziel alert:

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

Overdue payment (de.invoic.payment.overdue CloudEvent) — after REMADV is dispatched, the payment_overdue background worker (runs every 6 h) emits a CloudEvent to the ERP webhook when pay_by passes without ERP payment confirmation. Subscribe to trigger your accounts-payable dunning flow automatically:

{
  "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) are managed in marktd, not in invoicd. Upload a price sheet to make it available for the plausibility check:

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 the price sheet from marktd at check time (1-hour TTL cache, circuit breaker: 3 failures → 30 s open). No static tariff file or in-process store is needed.

ERP involvement

With invoicd deployed, the ERP's billing integration is narrowed to:

  1. Uploading the price sheet to marktd when rates change — invoicd fetches it from there at check time.
  2. Receiving de.mako.process.completed (settlement confirmed) or de.mako.process.failed (manual review required) events from marktd to update the ERP's payment status.

No ERP webhook response is required for the settlement decision itself — that is handled end-to-end between invoicd, makod, and the counterparty AS4 channel.

Edit this page ↗