Midaz emits a stream of domain events every time something meaningful happens in the ledger — a transaction is posted, a balance draws overdraft, an account is created. If your system needs to react to those changes (reconciliation, notifications, downstream projections, analytics), you can consume that 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:
- RabbitMQ direct — bind your own queue to Midaz’s AMQP exchanges.
- Streaming Hub — subscribe through Lerian’s managed fan-out service.
The two approaches at a glance
Midaz publishes each domain event to two transports in parallel:
- A RabbitMQ (AMQP) publish to topic exchanges owned by the Midaz deployment.
- An internal event bus publish, which the Streaming Hub consumes and fans out to per-tenant subscribers.
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 |
If you are not operating 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.
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 and consumes over AMQP.
Publisher facts (from the ledger service):
- Content type:
application/json
- Delivery mode: persistent
- Headers: OpenTelemetry trace-context is injected into every message
- Tenancy: the producer is multi-tenant — messages are published 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 |
These exchanges ship disabled by default. The operator running Midaz must enable each exchange before it will emit events.
For self-hosted operators
Each exchange is controlled by a dedicated environment flag, all defaulting 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 |
Set the flag to true in your Midaz deployment configuration to activate the corresponding exchange.
Message shape
Transaction events wrap the domain object in an envelope:
{
"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 (midaz.transaction.APPROVED, midaz.balance.overdraft.drawn), so you can bind narrowly instead of filtering in code.
Setup example
Connection parameters come from the Midaz deployment’s RabbitMQ configuration (RABBITMQ_HOST, RABBITMQ_PORT_AMQP, a consumer user such as RABBITMQ_CONSUMER_USER, the per-tenant RABBITMQ_VHOST, and optional TLS via RABBITMQ_TLS).
import pika
params = pika.URLParameters("amqps://consumer:***@midaz-rabbitmq:5671/<tenant-vhost>")
conn = pika.BlockingConnection(params)
ch = conn.channel()
# The exchange is declared by Midaz; declare passively or match its topology.
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 exchanges are off by default and must be enabled by the operator.
- 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 pick how you want events delivered.
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 itself is the acknowledgment (cursor-as-ack). No inbound endpoint required.
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: a durable inbox with consume-once dedup, per-sink retry with backoff, dead-lettering on exhaustion, and auto-disable of persistently failing endpoints.
Events available
Query the catalog to see the event types you can subscribe to:
GET /v1/catalog
Authorization: Bearer <jwt>
{
"events": [
{
"eventType": "transaction.created",
"topic": "lerian.streaming.transaction.created",
"schemaVersion": "1.0.0",
"schemaMajor": 1,
"description": "A transaction was created."
}
]
}
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:
Authorization: Bearer <plugin-auth JWT>
The tenant is derived only from the validated JWT claims (tenantId, falling back to owner), never from the request body. A machine client obtains its token from plugin-auth’s client-credentials flow (an application id/secret exchanged for a short-lived access token).
Setup example — webhook subscription
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, it can only be rotated, never re-read:
{
"subscription": { "id": "0190b8e2-...", "verification_state": "active", "...": "..." },
"signingSecret": "whsec_...ONCE..."
}
Verify the HMAC signature on every inbound webhook using that secret before trusting the payload. Use POST /v1/subscriptions/:id/ping to send a signed synthetic event and confirm your endpoint is wired correctly.
Setup example — pull subscription (serverless-friendly)
Create it with "sink_kind": "pull" (omit endpoint — the server synthesizes one), then poll:
GET /v1/events?subscription_id=<uuid>&limit=100
Authorization: Bearer <jwt>
{
"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)
Queue-kind subscriptions are created without a credential and are born pending_verification — they emit nothing until you supply an outbound credential via PUT /v1/subscriptions/:id/credential, which the hub probes (connect + auth) and, on success, flips the subscription to active. For a RabbitMQ sink the endpoint is "<exchange>/<routingKey>" and the broker host lives in the encrypted credential; for AWS sinks, fetch the IAM trust policy + ExternalId from GET /v1/subscriptions/:id/setup-artifacts and 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 acting on a webhook. Treat an unsigned or mismatched request as hostile.
- Guard the signing secret. It is shown once at create/rotate and is never retrievable afterward. Store it in a secrets manager; if leaked, rotate via
POST /v1/subscriptions/:id/secret/rotate (24h dual-sign overlap).
- HTTPS-only endpoints. Webhook sinks must be
https://. The hub SSRF-validates endpoints at create and credential PUT; plaintext/private targets are rejected.
- Tenant isolation is claim-derived. The hub reads tenant identity only from validated JWT claims (or
ce-tenantid on ingest), never from a request body — do not attempt to pass a tenant_id in payloads.
- Idempotency & dedup. Send
X-Idempotency on mutating control-plane calls. On the data plane, dedup on ceId (Streaming Hub) or the message id / your own key (RabbitMQ), since both transports are at-least-once.
- Don’t expose Midaz internals. The internal balance-operation exchange and broker credentials must never be shared with external consumers; front third parties with the Streaming Hub instead.