Ledger

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

InterfaceMethodWhen it fires
OnInitOnInit(ctx, ledger)Engine starts (engine.Start)
OnShutdownOnShutdown(ctx)Engine stops (engine.Stop)

Plan lifecycle

InterfaceMethodWhen it fires
OnPlanCreatedOnPlanCreated(ctx, plan)A new plan is created
OnPlanUpdatedOnPlanUpdated(ctx, oldPlan, newPlan)A plan is updated
OnPlanArchivedOnPlanArchived(ctx, planID)A plan is archived

Subscription lifecycle

InterfaceMethodWhen it fires
OnSubscriptionCreatedOnSubscriptionCreated(ctx, sub)A new subscription is created
OnSubscriptionChangedOnSubscriptionChanged(ctx, sub, oldPlan, newPlan)Subscription changes plans (upgrade/downgrade)
OnSubscriptionCanceledOnSubscriptionCanceled(ctx, sub)A subscription is canceled
OnSubscriptionExpiredOnSubscriptionExpired(ctx, sub)A subscription expires

Usage / metering

InterfaceMethodWhen it fires
OnUsageIngestedOnUsageIngested(ctx, events)Usage events are ingested
OnUsageFlushedOnUsageFlushed(ctx, count, elapsed)A batch is flushed to the store

Entitlements

InterfaceMethodWhen it fires
OnEntitlementCheckedOnEntitlementChecked(ctx, result)An entitlement check completes
OnQuotaExceededOnQuotaExceeded(ctx, tenantID, featureKey, used, limit)A hard limit is reached
OnSoftLimitReachedOnSoftLimitReached(ctx, tenantID, featureKey, used, limit)A soft limit is reached (overage)

Invoice lifecycle

InterfaceMethodWhen it fires
OnInvoiceGeneratedOnInvoiceGenerated(ctx, inv)An invoice is generated
OnInvoiceFinalizedOnInvoiceFinalized(ctx, inv)An invoice is finalized
OnInvoicePaidOnInvoicePaid(ctx, inv)An invoice is paid
OnInvoiceFailedOnInvoiceFailed(ctx, inv, err)Invoice payment fails
OnInvoiceVoidedOnInvoiceVoided(ctx, inv, reason)An invoice is voided

Payment provider

InterfaceMethodWhen it fires
OnProviderSyncOnProviderSync(ctx, provider, success, err)Provider sync completes
OnWebhookReceivedOnWebhookReceived(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:

  1. When you call registry.Register(plugin), the registry checks each hook interface with Go type assertion (plugin.(OnPlanCreated)).
  2. Matching plugins are appended to typed slices (e.g. registry.onPlanCreated).
  3. 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:

ConstantValue
ActionPlanCreatedplan.created
ActionPlanUpdatedplan.updated
ActionPlanArchivedplan.archived
ActionSubscriptionCreatedsubscription.created
ActionSubscriptionUpgradedsubscription.upgraded
ActionSubscriptionDowngradedsubscription.downgraded
ActionSubscriptionCanceledsubscription.canceled
ActionSubscriptionExpiredsubscription.expired
ActionUsageIngestedusage.ingested
ActionUsageFlushedusage.flushed
ActionEntitlementCheckedentitlement.checked
ActionEntitlementDeniedentitlement.denied
ActionQuotaExceededquota.exceeded
ActionSoftLimitReachedsoft_limit.reached
ActionInvoiceGeneratedinvoice.generated
ActionInvoiceFinalizedinvoice.finalized
ActionInvoicePaidinvoice.paid
ActionInvoiceFailedinvoice.failed
ActionInvoiceVoidedinvoice.voided
ActionProviderSyncprovider.sync
ActionWebhookReceivedwebhook.received
ActionWebhookProcessedwebhook.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:

MetricTypeDescription
ledger.plan.createdCounterPlans created
ledger.plan.updatedCounterPlans updated
ledger.plan.archivedCounterPlans archived
ledger.subscription.createdCounterSubscriptions created
ledger.subscription.upgradedCounterSubscription upgrades
ledger.subscription.downgradedCounterSubscription downgrades
ledger.subscription.canceledCounterSubscriptions canceled
ledger.subscription.expiredCounterSubscriptions expired
ledger.usage.events.ingestedCounterTotal usage events ingested
ledger.usage.batch.sizeHistogramBatch sizes per flush
ledger.usage.flush.latency_msHistogramFlush latency in milliseconds
ledger.entitlement.checksCounterEntitlement checks performed
ledger.entitlement.deniedCounterEntitlement checks denied
ledger.invoice.generatedCounterInvoices generated
ledger.invoice.finalizedCounterInvoices finalized
ledger.invoice.paidCounterInvoices paid
ledger.provider.sync.successCounterSuccessful provider syncs
ledger.provider.sync.failureCounterFailed provider syncs

Next steps

On this page