> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lerian.studio/llms.txt
> Use this file to discover all available pages before exploring further.

# Receiving events from Midaz

> Compare RabbitMQ direct bindings and the managed Streaming Hub for receiving Midaz ledger events, and choose the right transport for your integration setup.

Midaz emits a stream of domain events. It emits one each time something important happens in the ledger. The ledger posts a transaction, a balance draws overdraft, or you create an account. Your system can react to those changes for reconciliation, notifications, downstream projections, or analytics. To react, consume the event stream instead of polling the REST API.

This guide covers the two supported ways to receive Midaz events and how to choose between them:

1. **RabbitMQ direct** — bind your own queue to Midaz's AMQP exchanges.
2. **Streaming Hub** — subscribe through Lerian's managed fan-out service.

## The two approaches at a glance

***

Midaz publishes domain events over two transports:

* A **RabbitMQ (AMQP)** publish to topic exchanges that the Midaz deployment owns.
* An **internal event bus** publish that the Streaming Hub consumes and fans out to per-tenant subscribers.

Coverage differs by event. Transaction-lifecycle and overdraft events publish to **both** transports during the migration window. Audit append-log entries go to RabbitMQ only. The remaining catalog events — entity, route, holder, instrument, fee, limit, and rule events — go to the internal event bus only.

That gives you two integration surfaces for the *same* underlying events:

| Event          | RabbitMQ direct                      | Streaming Hub                                        |
| -------------- | ------------------------------------ | ---------------------------------------------------- |
| Transport      | AMQP 0-9-1                           | Webhook / Pull (HTTP) / SQS / RabbitMQ / EventBridge |
| You connect to | Midaz's broker                       | A control-plane REST API                             |
| Coupling       | Tight (Midaz infra)                  | Loose (managed edge)                                 |
| Best for       | Self-hosted / co-located deployments | External / SaaS integrators                          |

<Note>
  If you do not operate your own Midaz broker, use the Streaming Hub. Direct RabbitMQ is for integrators who run inside or adjacent to the Midaz deployment and own the broker.
</Note>

## Option A — RabbitMQ direct

***

### How it works

The Midaz ledger publishes events through an internal producer to a set of **topic exchanges**. A consumer binds its own queue to the relevant exchange with a routing-key pattern. The consumer then reads messages over AMQP.

Publisher facts (from the ledger service):

* **Content type:** `application/json`
* **Delivery mode:** persistent
* **Headers:** the producer injects OpenTelemetry trace-context into every message
* **Tenancy:** the producer is multi-tenant and publishes messages to a **tenant-specific vhost**

Event exchanges and their routing keys:

| Exchange                                  | Routing key pattern                                                 | Emits                           |
| ----------------------------------------- | ------------------------------------------------------------------- | ------------------------------- |
| `transaction.transaction_events.exchange` | `midaz.transaction.<STATUS>` (e.g. `midaz.transaction.APPROVED`)    | Transaction lifecycle envelopes |
| `transaction.overdraft_events.exchange`   | `midaz.balance.overdraft.<action>` (`drawn` / `repaid` / `cleared`) | Overdraft events                |
| `audit.append_log.exchange`               | `audit.append_log.key`                                              | Audit append-log entries        |

<Note>
  **Midaz enables these exchanges by default.** Midaz treats each flag as enabled unless you explicitly set it to `false`. The bundled example environment configuration ships all three flags set to `false`. A stack that starts from that example emits nothing until you override the flags.
</Note>

### For self-hosted operators

A dedicated environment flag controls each exchange. Midaz treats a flag as enabled unless you explicitly set it to `false`. The bundled example configuration ships all three flags set to `false`:

| Exchange                                  | Env flag                              |
| ----------------------------------------- | ------------------------------------- |
| `transaction.transaction_events.exchange` | `RABBITMQ_TRANSACTION_EVENTS_ENABLED` |
| `transaction.overdraft_events.exchange`   | `RABBITMQ_OVERDRAFT_EVENTS_ENABLED`   |
| `audit.append_log.exchange`               | `AUDIT_LOG_ENABLED`                   |

On a stack that starts from the example configuration, set the flag to `true` in your Midaz deployment configuration. You can also remove the `false` value instead. This keeps the matching exchange active.

### Message shape

Transaction events wrap the domain object in an envelope:

```json theme={null}
{
  "source": "midaz",
  "eventType": "transaction",
  "action": "APPROVED",
  "timestamp": "2026-07-06T12:00:00Z",
  "version": "<midaz build version>",
  "organizationId": "...",
  "ledgerId": "...",
  "payload": { "...full transaction JSON..." }
}
```

The routing key encodes the action, for example `midaz.transaction.APPROVED` or `midaz.balance.overdraft.drawn`. You can bind narrowly and avoid a filter in code.

### Setup example

Connection parameters come from the Midaz deployment's RabbitMQ configuration. These include `RABBITMQ_HOST`, `RABBITMQ_PORT_HOST` (the AMQP port used to dial the broker — `3003` in the bundled infrastructure; `RABBITMQ_PORT_AMQP` is the management port despite its name), a consumer user such as `RABBITMQ_CONSUMER_USER`, and `RABBITMQ_VHOST`. In multi-tenant deployments the producer resolves a per-tenant vhost; a single-tenant deployment uses the one static `RABBITMQ_VHOST`. For TLS, set `RABBITMQ_TLS=true` in multi-tenant mode, or `RABBITMQ_URI=amqps` in single-tenant mode.

```python theme={null}
import pika

params = pika.URLParameters("amqps://consumer:***@midaz-rabbitmq:5671/<tenant-vhost>")
conn = pika.BlockingConnection(params)
ch = conn.channel()

# The transaction and overdraft exchanges ship in Midaz's bundled RabbitMQ
# definitions; declare passively or match their topology. The balance-operation
# exchange also ships there but is internal — it feeds Midaz's own async balance
# pipeline (RABBITMQ_TRANSACTION_BALANCE_OPERATION_*), so do not consume from it.
# The audit append-log exchange is NOT in those definitions — declare it yourself.
exchange = "transaction.transaction_events.exchange"

# Bind a durable queue you own to the routing keys you care about.
ch.queue_declare(queue="my-consumer.transactions", durable=True)
ch.queue_bind(
    queue="my-consumer.transactions",
    exchange=exchange,
    routing_key="midaz.transaction.*",   # all statuses
)

def on_message(chan, method, props, body):
    handle(body)                          # application/json
    chan.basic_ack(method.delivery_tag)   # native, per-message ack

ch.basic_qos(prefetch_count=10)
ch.basic_consume(queue="my-consumer.transactions", on_message_callback=on_message)
ch.start_consuming()
```

### When to use it

* You **operate or co-locate** the Midaz deployment and already own the broker.
* You want **native AMQP semantics** — per-message `ack`/`nack`, prefetch/QoS, dead-letter exchanges you control, competing consumers on one queue.
* Your stack is already RabbitMQ-native and you want the lowest-latency, in-cluster hop.

### Trade-offs

* Couples you to Midaz's internal broker topology and credentials.
* The bundled example configuration ships the exchanges **disabled**. A stack that starts from it needs the operator to re-enable them.
* No managed retry/dead-letter/auto-disable — you own delivery reliability on the consumer side.
* Not viable for a third party that has only network access to a hosted Midaz.

## Option B — Streaming Hub

***

### How it works

The Streaming Hub is Lerian's fan-out delivery edge. It consumes events from Midaz's internal event bus and delivers each matched event to a **per-tenant subscriber sink** that you register. You never touch Kafka or the broker. You register a subscription through a REST control plane and choose a delivery method.

Supported sink kinds:

* **`webhook`** — the hub POSTs each event to your HTTPS endpoint, **HMAC-signed** with a per-subscription signing secret.
* **`pull`** — you poll `GET /v1/events`. The read is the acknowledgment (cursor-as-ack), with no inbound endpoint.
* **`sqs`**, **`rabbitmq`**, **`eventbridge`** — the hub delivers into *your* AWS queue, *your* RabbitMQ broker, or *your* EventBridge bus.

The hub adds delivery reliability on top of the raw stream. It provides a durable inbox with consume-once dedup, per-sink retry with backoff, dead-lettering on exhaustion, and auto-disable of failing endpoints.

### Events available

Query the catalog to see the event types you can subscribe to:

```http theme={null}
GET /v1/catalog
Authorization: Bearer <jwt>
```

```json theme={null}
{
  "events": [
    {
      "eventType": "transaction.posted",
      "topic": "lerian.streaming.ledger_transaction.posted",
      "schemaVersion": "1.0.0",
      "schemaMajor": 1,
      "description": "A transaction was posted."
    }
  ]
}
```

Midaz publishes a broad catalog keyed as `<resource>.<event>` (all at schema `1.0.0`), including:

* `organization.*`, `ledger.*`, `account.*`, `asset.*`, `portfolio.*`, `segment.*` (`created` / `updated` / `deleted`)
* `operation-route.*`, `transaction-route.*` (`created` / `updated` / `deleted`)
* `balance.created`, `balance.config-changed`, `balance.deleted`
* `balance.overdraft-drawn`, `balance.overdraft-repaid`, `balance.overdraft-cleared`
* `transaction.posted`, `transaction.committed`, `transaction.canceled`, `transaction.reverted`

### Authentication

The control plane is **100% `lib-auth` JWT (Bearer)** — the hub mints no credential of its own. Present a plugin-auth-issued JWT on every `/v1` call:

```http theme={null}
Authorization: Bearer <plugin-auth JWT>
```

The hub derives the tenant only from the validated JWT claims (`tenantId`, then `owner` as a fallback), never from the request body. A machine client obtains its token from plugin-auth's **client-credentials** flow. It exchanges an application id and secret for a short-lived access token.

### Setup example — webhook subscription

```http theme={null}
POST /v1/subscriptions
Authorization: Bearer <jwt>
X-Idempotency: <unique-key>
Content-Type: application/json

{
  "name": "my-webhook",
  "sink_kind": "webhook",
  "endpoint": "https://hooks.example.com/lerian",
  "event_types": ["transaction.posted", "transaction.committed"],
  "schema_major": 1,
  "plan_tier": "standard"
}
```

`201 Created` returns the subscription **and the signing secret exactly once**. Store it immediately. You can rotate the secret later, but you can never read it again:

```json theme={null}
{
  "subscription": { "id": "0190b8e2-...", "verification_state": "active", "...": "..." },
  "signingSecret": "whsec_...ONCE..."
}
```

Verify the HMAC signature on every inbound webhook with that secret. Do this before you trust the payload. Use `POST /v1/subscriptions/:id/ping` to send a signed synthetic event. This confirms that your endpoint works.

### Setup example — pull subscription (serverless-friendly)

Create it with `"sink_kind": "pull"` (omit `endpoint` — the server synthesizes one), then poll:

```http theme={null}
GET /v1/events?subscription_id=<uuid>&limit=100
Authorization: Bearer <jwt>
```

```json theme={null}
{
  "events": [
    { "seq": 42, "ceId": "...", "eventType": "transaction.posted",
      "receivedAt": "2026-07-06T12:00:00Z", "payload": { "...": "..." } }
  ],
  "next_cursor": 42
}
```

The read is the ack: the highest `seq` returned advances a durable, monotonic cursor. Replay the server-issued `next_cursor` as `?after=` on the next call. Each event carries `ceId` for your own dedup.

### Queue sinks (SQS / RabbitMQ / EventBridge)

You create a queue-kind subscription without a credential, and it starts in `pending_verification`. It emits nothing until you supply an outbound credential via `PUT /v1/subscriptions/:id/credential`. The hub probes that credential (connect and auth) and, on success, flips the subscription to `active`. For a RabbitMQ sink, set `endpoint` to `"<exchange>/<routingKey>"`. The broker host lives in the encrypted credential. For AWS sinks, fetch the IAM trust policy and `ExternalId` from `GET /v1/subscriptions/:id/setup-artifacts`, then wire the cross-account grant before the credential PUT.

### When to use it

* You are an **external integrator** with only network access to a hosted Midaz.
* You want **serverless** delivery — a webhook endpoint or an HTTP pull loop, no broker to run.
* You want **managed reliability** — dedup, retry/backoff, dead-lettering, auto-disable, signed webhooks — without building it yourself.
* You want to fan the same events into **AWS-native** infrastructure (SQS/EventBridge).

### Limitations

* No SSE or WebSocket transport — delivery is webhook push or HTTP pull (plus the queue sinks).
* The catalog is **global** (identical regardless of which tenant authenticates).
* Pull consumers own their cursor position — a forward seek past the cursor permanently skips the gap. Use `?after=` to replay from any prior `seq`.

***

## Decision table

| Criterion               | RabbitMQ direct                                             | Streaming Hub                                                    |
| ----------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- |
| **Your stack**          | Already RabbitMQ / AMQP-native, in-cluster                  | Anything that speaks HTTPS (or AWS SQS/EventBridge)              |
| **Deployment model**    | You run / co-locate Midaz and own the broker                | Hosted / SaaS Midaz, external integrator                         |
| **Granular ack**        | ✅ Native per-message `ack`/`nack`, prefetch, DLX            | Partial — webhook 2xx or pull cursor-as-ack; no per-message nack |
| **Serverless**          | ❌ Needs a long-lived AMQP consumer                          | ✅ Webhook endpoint or stateless pull loop                        |
| **Simplicity to start** | ❌ Broker creds, vhost, exchange must be enabled             | ✅ One authenticated `POST /v1/subscriptions`                     |
| **Managed reliability** | ❌ You own retry / DLQ / backoff                             | ✅ Dedup, retry/backoff, dead-letter, auto-disable                |
| **Latency**             | Lowest (in-cluster hop)                                     | Slightly higher (fan-out edge)                                   |
| **Auth model**          | Broker username/password (+ optional TLS)                   | plugin-auth JWT (Bearer); signed webhooks                        |
| **Event scope**         | Transaction / overdraft / audit exchanges (must be enabled) | Full Midaz catalog via `/v1/catalog`                             |
| **Fan into AWS**        | ❌ Build it yourself                                         | ✅ SQS / EventBridge sinks                                        |

**Rule of thumb:** if you own the broker and want raw AMQP control, go **RabbitMQ direct**. In every other case — external integration, serverless, managed reliability, AWS delivery — use the Streaming Hub.

***

## Security considerations

* **Least-privilege credentials.** For RabbitMQ direct, connect with a **consumer-scoped** user (not the publisher/default user), restricted to the tenant vhost, and enable TLS (`amqps://`). For the Streaming Hub, scope the plugin-auth application to the tenant it represents and rotate the client secret.
* **Verify webhook signatures.** Always validate the HMAC-v1 signature with your per-subscription signing secret before you act on a webhook. Treat an unsigned or mismatched request as hostile.
* **Guard the signing secret.** The hub shows it once at create or rotate, and never again. Store it in a secrets manager, then rotate it via `POST /v1/subscriptions/:id/secret/rotate` (24h dual-sign overlap) if the secret leaks.
* **HTTPS-only endpoints.** Webhook sinks must be `https://`. The hub SSRF-validates endpoints at create and at credential PUT, and it rejects plaintext or private targets.
* **Tenant isolation is claim-derived.** The hub reads tenant identity only from validated JWT claims (or `ce-tenantid` on ingest), never from the request body, so never send `tenant_id` in a payload.
* **Idempotency & dedup.** Send `X-Idempotency` on mutating control-plane calls. On the data plane, dedup on `ceId` (Streaming Hub), the message id, or your own key (RabbitMQ), because both transports are at-least-once.
* **Don't expose Midaz internals.** Never share the internal balance-operation exchange or broker credentials with external consumers, and front third parties with the Streaming Hub instead.
