Events¶
Internal event schema and Kafka topics. Last updated: March 2026
Overview¶
Every change in a billing system is translated by a connector into an internal event, published to Kafka, and consumed by the core state manager and metrics.
Internal events are the single source of truth for the ingestion architecture (Stripe). They are immutable, ordered, and replayable.
Same-database mode (Lago, Kill Bill)
When using same-database connectors, metrics query the billing engine's PostgreSQL directly and do not consume Kafka events. The event schema below applies to ingestion-mode connectors (Stripe and any future webhook-based connector). See Connectors for details.
Event Envelope¶
Every event has the same envelope:
@dataclass(frozen=True)
class Event:
id: UUID # unique event ID (idempotency key)
source_id: UUID # which connector_source produced this
type: str # e.g. "subscription.activated"
occurred_at: datetime # when it happened in the billing system
published_at: datetime # when we published to Kafka
customer_id: str # external customer ID (Kafka partition key)
payload: dict # type-specific data (see below)
customer_id is the Kafka partition key. All events for a given customer land on the same partition, preserving ordering per customer.
Event Types¶
Customer Events¶
| Type | Trigger | Payload |
|---|---|---|
customer.created |
New customer in billing system | {external_id, name, email, currency, country, metadata} |
customer.updated |
Customer details changed | {external_id, changed_fields} |
customer.deleted |
Customer removed | {external_id} |
Catalog Events¶
Catalog events (products and plans) are global — they are not tied to a single
customer. Connectors set customer_id to the empty string, so all catalog
events land on the same Kafka partition (volume is low compared to
subscriptions). They are emitted by webhook (product.* / price.* from
Stripe) and by backfill() on first sync, and they populate the product
and plan core tables that the MRR / Churn cubes use as dimensions
(plan name, interval, billing scheme, usage type, product name).
| Type | Trigger | Payload |
|---|---|---|
product.created |
New product in catalog | {external_id, name, description, active, metadata} |
product.updated |
Product fields changed | {external_id, name, description, active, metadata} |
product.deleted |
Product removed | {external_id} — handler marks the row inactive (plans may still reference it) |
plan.created |
New price/plan in catalog | {external_id, product_external_id, name, interval, interval_count, amount_cents, currency, billing_scheme, usage_type, trial_period_days, active, metadata} |
plan.updated |
Price/plan fields changed | Same as plan.created |
plan.deleted |
Price/plan archived | {external_id} — handler marks the row inactive (subscriptions may still reference it) |
Subscription Events¶
The most important events for metric computation.
| Type | Trigger | Payload |
|---|---|---|
subscription.created |
New subscription | {external_id, customer_external_id, plan_external_id, status, mrr_cents, quantity, started_at, trial_start, trial_end, current_period_start, current_period_end} |
subscription.activated |
Trial → active, or pending → active | {external_id, mrr_cents} |
subscription.changed |
Plan or quantity changed | {external_id, prev_plan_external_id, new_plan_external_id, prev_mrr_cents, new_mrr_cents, prev_quantity, new_quantity} |
subscription.canceled |
Subscription set to cancel at period end | {external_id, mrr_cents, canceled_at, ends_at, cancel_reason} |
subscription.churned |
Subscription ended (no longer active) | {external_id, prev_mrr_cents, cancel_reason} |
subscription.reactivated |
Previously churned customer re-subscribes | {external_id, mrr_cents} |
subscription.trial_started |
Free trial begins | {external_id, trial_start, trial_end} |
subscription.trial_converted |
Trial → paid | {external_id, mrr_cents} |
subscription.trial_expired |
Trial ended without conversion | {external_id} |
subscription.paused |
Subscription paused | {external_id, mrr_cents} |
subscription.resumed |
Subscription resumed from pause | {external_id, mrr_cents} |
MRR classification: The difference between prev_mrr_cents and new_mrr_cents in subscription.changed determines whether it's expansion (new > prev) or contraction (new < prev). The MRR metric uses this to compute net new MRR breakdown.
Invoice Events¶
| Type | Trigger | Payload |
|---|---|---|
invoice.created |
New invoice generated | {external_id, customer_external_id, subscription_external_id, status, currency, subtotal_cents, tax_cents, total_cents, period_start, period_end, line_items[]} |
invoice.paid |
Invoice payment succeeded | {external_id, paid_at, amount_cents} |
invoice.voided |
Invoice canceled | {external_id, voided_at} |
invoice.uncollectible |
Invoice marked uncollectible | {external_id} |
Payment Events¶
| Type | Trigger | Payload |
|---|---|---|
payment.succeeded |
Payment completed | {external_id, invoice_external_id, customer_external_id, amount_cents, currency, payment_method_type} |
payment.failed |
Payment attempt failed | {external_id, invoice_external_id, customer_external_id, amount_cents, failure_reason, attempt_count} |
payment.refunded |
Payment refunded | {external_id, amount_cents, refunded_at} |
Usage Events¶
Tidemill does not emit a dedicated usage.recorded event today. For Stripe (the reference connector), metered usage rolls onto invoice line items at billing finalization, so the same invoice.created / invoice.paid flow that drives LTV also drives the trailing-3m usage component of MRR — see tidemill/metrics/mrr/usage.py and the MRR usage component spec.
If a future connector exposes raw meter events (Lago has structured events), a usage.recorded event with payload {customer_external_id, subscription_external_id, metric_code, quantity, timestamp} would be the place to add them. Treated as a future hook; not currently produced or consumed.
Expense-side Events (QuickBooks Online et al.)¶
These are emitted by ExpenseConnector subclasses (today: QuickBooks Online; future: Xero, FreshBooks, Wave, Sage). For these events Event.customer_id carries the realm/tenant ID of the accounting source so events for the same QBO company stay on one Kafka partition.
| Type | Trigger | Payload |
|---|---|---|
vendor.created |
Vendor created in source | {external_id, name, email, country, currency, active, metadata} |
vendor.updated |
Vendor edited | Same fields as vendor.created |
vendor.deleted |
Vendor deleted | {external_id} |
account.created |
Chart-of-accounts entry created | {external_id, name, account_type, account_subtype, parent_external_id, currency, active, metadata} |
account.updated |
Account edited | Same fields as account.created |
bill.created |
New A/P bill | {external_id, vendor_external_id, status, doc_number, currency, subtotal_cents, tax_cents, total_cents, txn_date, due_date, memo, lines: [{account_external_id, description, amount_cents, currency, dimensions}]} |
bill.updated |
Bill modified | Same shape as bill.created |
bill.paid |
Bill marked paid | {external_id, paid_at} |
bill.voided |
Bill voided | {external_id, voided_at} |
expense.created |
Direct purchase recorded | {external_id, vendor_external_id, payment_type, doc_number, currency, subtotal_cents, tax_cents, total_cents, txn_date, memo, lines: [{account_external_id, description, amount_cents, currency, dimensions}]} |
expense.updated |
Direct purchase edited | Same shape as expense.created |
expense.voided |
Direct purchase voided | {external_id, voided_at} |
bill_payment.created |
Payment applied to a bill | {external_id, bill_external_id, paid_at, amount_cents, currency} |
account_type is one of the canonical enums (see expenses.md). Native source values are preserved on metadata.
Kafka Topics¶
| Topic | Partition Key | Description |
|---|---|---|
tidemill.events |
customer_id |
All internal events (single topic) |
tidemill.events.dlq |
customer_id |
Dead letter queue for failed processing |
A single topic keeps event ordering simple. Consumers use the type field to filter.
For high-volume deployments, events can be split into separate topics per entity type (tidemill.events.subscription, tidemill.events.invoice, etc.) at the cost of weaker cross-entity ordering guarantees.
Consumer Groups¶
| Group | Consumes | Purpose |
|---|---|---|
tidemill.state |
All events | Updates core PostgreSQL tables (current state — including product / plan catalog) |
tidemill.metric.mrr |
subscription.*, invoice.paid |
MRR metric (subscription lifecycle + trailing-3m usage component) |
tidemill.metric.churn |
subscription.*, customer.* |
Churn metric |
tidemill.metric.retention |
subscription.*, customer.* |
Retention metric |
tidemill.metric.ltv |
subscription.*, invoice.*, payment.* |
LTV metric |
tidemill.metric.trials |
subscription.trial_*, subscription.activated |
Trials metric |
tidemill.metric.usage_revenue |
(no events) | Reads metric_mrr_usage_component populated by MRR's invoice.paid handler — no own event consumption |
Each metric runs in its own consumer group, so it maintains its own offset. This means:
- Metrics process events independently (a slow metric doesn't block others)
- A new metric can be added and replayed from offset 0 to backfill
- A metric can be reset (seek to beginning) to recompute from scratch
Idempotency¶
Events carry a unique id (UUID). Consumers must be idempotent — processing the same event twice produces the same result. This is enforced by:
- Storing the last processed
event.idper consumer group - Using
INSERT ... ON CONFLICT DO NOTHINGfor append-only tables - Using
INSERT ... ON CONFLICT DO UPDATEfor current-state tables
Event Persistence¶
Events are persisted in two places:
- Kafka — the primary log, retained for a configurable period (default: 30 days)
- PostgreSQL
event_logtable — permanent archive for replay and audit
CREATE TABLE event_log (
id UUID PRIMARY KEY,
source_id UUID NOT NULL REFERENCES connector_source(id),
type TEXT NOT NULL,
customer_id TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
published_at TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL
);
CREATE INDEX ix_event_log_type_time ON event_log(type, occurred_at);
CREATE INDEX ix_event_log_customer ON event_log(customer_id, occurred_at);
When Kafka retention expires, or when bootstrapping a new deployment, the event_log table is the replay source.
Dead-Letter Handling¶
Whenever a consumer's handle_event raises, the event is recorded in the
dead_letter_event table (one row per (event_id, consumer) pair) and
mirrored to the Kafka DLQ topic. The offset is then committed so the
worker keeps making progress instead of getting stuck on the bad event.
The most common case is FxRateMissingError — an MRR / LTV event whose
currency has no fx_rate row yet. Once you backfill the rate:
tidemill dlq-list --error-type fx_rate_missing # confirm what's queued
tidemill dlq-replay --error-type fx_rate_missing # republish to Kafka
The worker reprocesses the events normally; on success the consumer
sets dead_letter_event.resolved_at so the rows drop out of the
default dlq-list view.
CREATE TABLE dead_letter_event (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
source_id TEXT NOT NULL,
event_type TEXT NOT NULL,
consumer TEXT NOT NULL, -- 'state', 'metric:mrr', …
error_type TEXT NOT NULL, -- 'fx_rate_missing', 'unknown', …
error_message TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
dead_lettered_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
UNIQUE (event_id, consumer)
);