Custom Plugin
Write custom plugins to extend Ledger with lifecycle hooks.
Ledger's plugin system lets you hook into every lifecycle event in the billing pipeline -- from plan creation to invoice payment. Plugins are non-blocking, timeout-protected, and dispatched in O(1) time via a type-cached registry.
Plugin basics
Every plugin implements the plugin.Plugin interface, which has a single method:
package plugin
type Plugin interface {
Name() string
}Name() returns a unique string identifying the plugin. The registry rejects duplicate names.
To add behaviour, implement one or more optional hook interfaces alongside Plugin. The plugin system uses Go interface assertion to discover which hooks your plugin supports -- you only pay for what you implement.
Lifecycle hooks overview
Ledger defines 21 hook interfaces organized into 6 categories:
System lifecycle
| Interface | Method | When it fires |
|---|---|---|
OnInit | OnInit(ctx, ledger) | Engine starts (engine.Start) |
OnShutdown | OnShutdown(ctx) | Engine stops (engine.Stop) |
Plan lifecycle
| Interface | Method | When it fires |
|---|---|---|
OnPlanCreated | OnPlanCreated(ctx, plan) | A new plan is created |
OnPlanUpdated | OnPlanUpdated(ctx, oldPlan, newPlan) | A plan is updated |
OnPlanArchived | OnPlanArchived(ctx, planID) | A plan is archived |
Subscription lifecycle
| Interface | Method | When it fires |
|---|---|---|
OnSubscriptionCreated | OnSubscriptionCreated(ctx, sub) | A new subscription is created |
OnSubscriptionChanged | OnSubscriptionChanged(ctx, sub, oldPlan, newPlan) | Subscription changes plans (upgrade/downgrade) |
OnSubscriptionCanceled | OnSubscriptionCanceled(ctx, sub) | A subscription is canceled |
OnSubscriptionExpired | OnSubscriptionExpired(ctx, sub) | A subscription expires |
Usage / metering
| Interface | Method | When it fires |
|---|---|---|
OnUsageIngested | OnUsageIngested(ctx, events) | Usage events are ingested |
OnUsageFlushed | OnUsageFlushed(ctx, count, elapsed) | A batch is flushed to the store |
Entitlements
| Interface | Method | When it fires |
|---|---|---|
OnEntitlementChecked | OnEntitlementChecked(ctx, result) | An entitlement check completes |
OnQuotaExceeded | OnQuotaExceeded(ctx, tenantID, featureKey, used, limit) | A hard limit is reached |
OnSoftLimitReached | OnSoftLimitReached(ctx, tenantID, featureKey, used, limit) | A soft limit is reached (overage) |
Invoice lifecycle
| Interface | Method | When it fires |
|---|---|---|
OnInvoiceGenerated | OnInvoiceGenerated(ctx, inv) | An invoice is generated |
OnInvoiceFinalized | OnInvoiceFinalized(ctx, inv) | An invoice is finalized |
OnInvoicePaid | OnInvoicePaid(ctx, inv) | An invoice is paid |
OnInvoiceFailed | OnInvoiceFailed(ctx, inv, err) | Invoice payment fails |
OnInvoiceVoided | OnInvoiceVoided(ctx, inv, reason) | An invoice is voided |
Payment provider
| Interface | Method | When it fires |
|---|---|---|
OnProviderSync | OnProviderSync(ctx, provider, success, err) | Provider sync completes |
OnWebhookReceived | OnWebhookReceived(ctx, provider, payload) | A webhook is received |
Implementing hooks
Create a struct that implements plugin.Plugin plus whichever hook interfaces you need:
package myplugin
import (
"context"
"log/slog"
"github.com/xraph/ledger/plugin"
)
// Compile-time interface checks
var (
_ plugin.Plugin = (*MyPlugin)(nil)
_ plugin.OnPlanCreated = (*MyPlugin)(nil)
_ plugin.OnInvoiceGenerated = (*MyPlugin)(nil)
)
type MyPlugin struct {
logger *slog.Logger
}
func New(logger *slog.Logger) *MyPlugin {
return &MyPlugin{logger: logger}
}
func (p *MyPlugin) Name() string { return "my-plugin" }
func (p *MyPlugin) OnPlanCreated(ctx context.Context, plan interface{}) error {
p.logger.Info("plan created", "plan", plan)
return nil
}
func (p *MyPlugin) OnInvoiceGenerated(ctx context.Context, inv interface{}) error {
p.logger.Info("invoice generated", "invoice", inv)
return nil
}The var _ plugin.OnPlanCreated = (*MyPlugin)(nil) lines are compile-time checks. If you forget to implement a method, the compiler tells you.
Registering plugins
Pass plugins to the engine with ledger.WithPlugin():
engine := ledger.New(store,
ledger.WithPlugin(myplugin.New(logger)),
ledger.WithPlugin(anotherPlugin),
)You can register as many plugins as you need. Each plugin is registered once; duplicate names are rejected.
Error handling
Plugins are designed to never block the billing pipeline:
- Non-blocking execution: Each hook call runs in a separate goroutine.
- 5-second timeout: If a plugin does not return within 5 seconds, the call is abandoned and a warning is logged.
- Error logging: If a plugin returns an error, the registry logs it as a warning and continues. The billing operation (plan creation, invoice generation, etc.) is not affected.
- No panics: If a plugin panics, the goroutine recovers and logs the panic. The engine continues operating.
This means your plugin should handle its own errors gracefully. If you need to retry a failed notification, do so asynchronously within your plugin:
func (p *SlackNotifier) OnInvoicePaid(ctx context.Context, inv interface{}) error {
// Fire and forget with retry
go func() {
for attempt := 0; attempt < 3; attempt++ {
if err := p.sendSlackMessage(inv); err == nil {
return
}
time.Sleep(time.Duration(attempt+1) * time.Second)
}
p.logger.Error("slack notification failed after 3 retries")
}()
return nil
}Example: Slack notifier
A plugin that sends Slack notifications when invoices are paid or when a tenant exceeds their quota:
package slacknotifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/xraph/ledger/plugin"
)
var (
_ plugin.Plugin = (*Notifier)(nil)
_ plugin.OnInvoicePaid = (*Notifier)(nil)
_ plugin.OnQuotaExceeded = (*Notifier)(nil)
)
type Notifier struct {
webhookURL string
channel string
logger *slog.Logger
}
func New(webhookURL, channel string, logger *slog.Logger) *Notifier {
return &Notifier{
webhookURL: webhookURL,
channel: channel,
logger: logger,
}
}
func (n *Notifier) Name() string { return "slack-notifier" }
func (n *Notifier) OnInvoicePaid(ctx context.Context, inv interface{}) error {
msg := fmt.Sprintf("Invoice paid: %v", inv)
return n.send(ctx, msg)
}
func (n *Notifier) OnQuotaExceeded(ctx context.Context, tenantID, featureKey string, used, limit int64) error {
msg := fmt.Sprintf("Quota exceeded: tenant=%s feature=%s used=%d limit=%d",
tenantID, featureKey, used, limit)
return n.send(ctx, msg)
}
func (n *Notifier) send(_ context.Context, text string) error {
payload := map[string]string{
"channel": n.channel,
"text": text,
}
body, _ := json.Marshal(payload)
resp, err := http.Post(n.webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
n.logger.Warn("slack send failed", "error", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("slack: status %d", resp.StatusCode)
}
return nil
}Register it:
engine := ledger.New(store,
ledger.WithPlugin(slacknotifier.New(
"https://hooks.slack.com/services/T00/B00/xxx",
"#billing-alerts",
logger,
)),
)Example: Custom metrics collector
A plugin that tracks billing metrics using your own metrics library:
package metrics
import (
"context"
"time"
"github.com/xraph/ledger/plugin"
)
var (
_ plugin.Plugin = (*Collector)(nil)
_ plugin.OnSubscriptionCreated = (*Collector)(nil)
_ plugin.OnSubscriptionCanceled = (*Collector)(nil)
_ plugin.OnUsageFlushed = (*Collector)(nil)
_ plugin.OnInvoiceGenerated = (*Collector)(nil)
_ plugin.OnQuotaExceeded = (*Collector)(nil)
)
type Collector struct {
subsCreated func()
subsCanceled func()
flushLatency func(float64)
invoices func()
quotaBlocks func()
}
func New(
subsCreated, subsCanceled func(),
flushLatency func(float64),
invoices, quotaBlocks func(),
) *Collector {
return &Collector{
subsCreated: subsCreated,
subsCanceled: subsCanceled,
flushLatency: flushLatency,
invoices: invoices,
quotaBlocks: quotaBlocks,
}
}
func (c *Collector) Name() string { return "custom-metrics" }
func (c *Collector) OnSubscriptionCreated(_ context.Context, _ interface{}) error {
c.subsCreated()
return nil
}
func (c *Collector) OnSubscriptionCanceled(_ context.Context, _ interface{}) error {
c.subsCanceled()
return nil
}
func (c *Collector) OnUsageFlushed(_ context.Context, _ int, elapsed time.Duration) error {
c.flushLatency(float64(elapsed.Milliseconds()))
return nil
}
func (c *Collector) OnInvoiceGenerated(_ context.Context, _ interface{}) error {
c.invoices()
return nil
}
func (c *Collector) OnQuotaExceeded(_ context.Context, _, _ string, _, _ int64) error {
c.quotaBlocks()
return nil
}Example: Webhook emitter
A plugin that emits HTTP webhooks for billing events to an external URL:
package webhookemitter
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/xraph/ledger/plugin"
)
var (
_ plugin.Plugin = (*Emitter)(nil)
_ plugin.OnSubscriptionCreated = (*Emitter)(nil)
_ plugin.OnSubscriptionCanceled = (*Emitter)(nil)
_ plugin.OnInvoiceGenerated = (*Emitter)(nil)
_ plugin.OnInvoicePaid = (*Emitter)(nil)
_ plugin.OnQuotaExceeded = (*Emitter)(nil)
)
type WebhookEvent struct {
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`
Data any `json:"data"`
}
type Emitter struct {
endpoint string
secret string
client *http.Client
logger *slog.Logger
}
func New(endpoint, secret string, logger *slog.Logger) *Emitter {
return &Emitter{
endpoint: endpoint,
secret: secret,
client: &http.Client{Timeout: 10 * time.Second},
logger: logger,
}
}
func (e *Emitter) Name() string { return "webhook-emitter" }
func (e *Emitter) OnSubscriptionCreated(ctx context.Context, sub interface{}) error {
return e.emit(ctx, "subscription.created", sub)
}
func (e *Emitter) OnSubscriptionCanceled(ctx context.Context, sub interface{}) error {
return e.emit(ctx, "subscription.canceled", sub)
}
func (e *Emitter) OnInvoiceGenerated(ctx context.Context, inv interface{}) error {
return e.emit(ctx, "invoice.generated", inv)
}
func (e *Emitter) OnInvoicePaid(ctx context.Context, inv interface{}) error {
return e.emit(ctx, "invoice.paid", inv)
}
func (e *Emitter) OnQuotaExceeded(_ context.Context, tenantID, featureKey string, used, limit int64) error {
return e.emit(context.Background(), "quota.exceeded", map[string]any{
"tenant_id": tenantID,
"feature_key": featureKey,
"used": used,
"limit": limit,
})
}
func (e *Emitter) emit(_ context.Context, eventType string, data any) error {
event := WebhookEvent{
Type: eventType,
Timestamp: time.Now().UTC(),
Data: data,
}
body, err := json.Marshal(event)
if err != nil {
return err
}
// Sign the payload with HMAC-SHA256
mac := hmac.New(sha256.New, []byte(e.secret))
mac.Write(body)
signature := hex.EncodeToString(mac.Sum(nil))
req, err := http.NewRequest("POST", e.endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Ledger-Signature", signature)
req.Header.Set("X-Ledger-Event", eventType)
resp, err := e.client.Do(req)
if err != nil {
e.logger.Warn("webhook delivery failed", "event", eventType, "error", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("webhook: %s returned %d", e.endpoint, resp.StatusCode)
}
return nil
}Register it:
engine := ledger.New(store,
ledger.WithPlugin(webhookemitter.New(
"https://api.example.com/webhooks/billing",
"whsec_your_secret_here",
logger,
)),
)Strategy plugins
Beyond lifecycle hooks, Ledger defines strategy interfaces for customizing core billing logic. These are called synchronously during billing operations:
PricingStrategy
Implement custom pricing calculation (e.g. usage-based, tiered, dynamic):
type PricingStrategy interface {
Plugin
StrategyName() string
Compute(tiers []interface{}, usage, included int64, currency string) interface{}
}type PercentageDiscount struct{}
func (p *PercentageDiscount) Name() string { return "percentage-discount" }
func (p *PercentageDiscount) StrategyName() string { return "percentage" }
func (p *PercentageDiscount) Compute(tiers []interface{}, usage, included int64, currency string) interface{} {
// Custom pricing calculation
// Return a types.Money value
return types.USD(usage * 10) // example: $0.10 per unit
}UsageAggregator
Custom logic for aggregating raw usage events into a single number:
type UsageAggregator interface {
Plugin
AggregatorName() string
Aggregate(ctx context.Context, events []interface{}) (int64, error)
}Useful for unique counting (e.g. unique active users), percentile-based metering, or weighted aggregation.
TaxCalculator
Calculate tax amounts for invoices:
type TaxCalculator interface {
Plugin
CalculateTax(ctx context.Context, subtotal interface{}, tenantID string) (interface{}, error)
}type SimpleTax struct {
rate float64 // e.g. 0.08 for 8%
}
func (t *SimpleTax) Name() string { return "simple-tax" }
func (t *SimpleTax) CalculateTax(_ context.Context, subtotal interface{}, _ string) (interface{}, error) {
money := subtotal.(types.Money)
taxCents := int64(float64(money.Amount) * t.rate)
return types.Money{Amount: taxCents, Currency: money.Currency}, nil
}InvoiceFormatter
Render invoices in custom formats (PDF, HTML, CSV):
type InvoiceFormatter interface {
Plugin
Format() string
Render(ctx context.Context, inv interface{}, w interface{}) error
}The Format() method returns a format name (e.g. "pdf", "html"). The registry indexes formatters by name so they can be looked up by format.
CouponValidator
Custom coupon validation logic beyond the built-in checks:
type CouponValidator interface {
Plugin
ValidateCoupon(ctx context.Context, coupon interface{}, sub interface{}) error
}Return nil to approve the coupon, or an error to reject it.
Plugin registry internals
The plugin registry uses type-cached dispatch for O(1) hook invocation:
- When you call
registry.Register(plugin), the registry checks each hook interface with Go type assertion (plugin.(OnPlanCreated)). - Matching plugins are appended to typed slices (e.g.
registry.onPlanCreated). - When the engine emits an event (e.g.
EmitPlanCreated), it iterates only the pre-filtered slice -- no reflection, no interface discovery at call time.
Strategy plugins (PricingStrategy, UsageAggregator, InvoiceFormatter) are stored in maps keyed by their strategy/format name for direct lookup.
// Internals of the registry (simplified)
type Registry struct {
plugins []Plugin
onPlanCreated []OnPlanCreated // pre-filtered at registration
onInvoicePaid []OnInvoicePaid // pre-filtered at registration
pricingStrategies map[string]PricingStrategy // keyed by StrategyName()
invoiceFormatters map[string]InvoiceFormatter // keyed by Format()
// ... etc
}Each hook call is wrapped in callWithTimeout:
func (r *Registry) callWithTimeout(ctx context.Context, pluginName string, fn func() error) error {
done := make(chan error, 1)
go func() { done <- fn() }()
select {
case err := <-done:
return err
case <-time.After(5 * time.Second):
return fmt.Errorf("plugin timeout: %s", pluginName)
case <-ctx.Done():
return ctx.Err()
}
}Built-in plugins
Ledger ships with two production-ready plugins:
audithook
The audit_hook package bridges Ledger lifecycle events to an audit trail backend. It implements 13 hook interfaces covering plans, subscriptions, invoices, entitlements, and quota events.
import audithook "github.com/xraph/ledger/audit_hook"
recorder := audithook.RecorderFunc(func(ctx context.Context, event *audithook.AuditEvent) error {
// Forward to your audit trail (Chronicle, database, file, etc.)
log.Printf("[AUDIT] %s %s %s", event.Action, event.Resource, event.Outcome)
return nil
})
auditPlugin := audithook.New(recorder,
audithook.WithLogger(logger),
audithook.WithEnabledActions( // optional: only audit specific actions
audithook.ActionInvoicePaid,
audithook.ActionQuotaExceeded,
audithook.ActionSubscriptionCanceled,
),
)
engine := ledger.New(store, ledger.WithPlugin(auditPlugin))The audit hook supports filtering by action. Use WithEnabledActions to audit only specific events, or WithDisabledActions to exclude noisy ones like entitlement.checked.
Available audit actions:
| Constant | Value |
|---|---|
ActionPlanCreated | plan.created |
ActionPlanUpdated | plan.updated |
ActionPlanArchived | plan.archived |
ActionSubscriptionCreated | subscription.created |
ActionSubscriptionUpgraded | subscription.upgraded |
ActionSubscriptionDowngraded | subscription.downgraded |
ActionSubscriptionCanceled | subscription.canceled |
ActionSubscriptionExpired | subscription.expired |
ActionUsageIngested | usage.ingested |
ActionUsageFlushed | usage.flushed |
ActionEntitlementChecked | entitlement.checked |
ActionEntitlementDenied | entitlement.denied |
ActionQuotaExceeded | quota.exceeded |
ActionSoftLimitReached | soft_limit.reached |
ActionInvoiceGenerated | invoice.generated |
ActionInvoiceFinalized | invoice.finalized |
ActionInvoicePaid | invoice.paid |
ActionInvoiceFailed | invoice.failed |
ActionInvoiceVoided | invoice.voided |
ActionProviderSync | provider.sync |
ActionWebhookReceived | webhook.received |
ActionWebhookProcessed | webhook.processed |
observability
The observability package provides a metrics extension that tracks counters and histograms for all lifecycle events. It implements 16 hook interfaces.
import "github.com/xraph/ledger/observability"
metricsPlugin := observability.NewMetricsExtension(metricsFactory)
engine := ledger.New(store, ledger.WithPlugin(metricsPlugin))The MetricFactory interface is simple:
type MetricFactory interface {
Counter(name string) Counter
Histogram(name string) Histogram
}Metrics emitted:
| Metric | Type | Description |
|---|---|---|
ledger.plan.created | Counter | Plans created |
ledger.plan.updated | Counter | Plans updated |
ledger.plan.archived | Counter | Plans archived |
ledger.subscription.created | Counter | Subscriptions created |
ledger.subscription.upgraded | Counter | Subscription upgrades |
ledger.subscription.downgraded | Counter | Subscription downgrades |
ledger.subscription.canceled | Counter | Subscriptions canceled |
ledger.subscription.expired | Counter | Subscriptions expired |
ledger.usage.events.ingested | Counter | Total usage events ingested |
ledger.usage.batch.size | Histogram | Batch sizes per flush |
ledger.usage.flush.latency_ms | Histogram | Flush latency in milliseconds |
ledger.entitlement.checks | Counter | Entitlement checks performed |
ledger.entitlement.denied | Counter | Entitlement checks denied |
ledger.invoice.generated | Counter | Invoices generated |
ledger.invoice.finalized | Counter | Invoices finalized |
ledger.invoice.paid | Counter | Invoices paid |
ledger.provider.sync.success | Counter | Successful provider syncs |
ledger.provider.sync.failure | Counter | Failed provider syncs |
Next steps
- End-to-End Billing Example -- see plugins in action in the full billing pipeline.
- Custom Store -- implement a production storage backend.
- Forge Extension -- mount Ledger into a Forge application.