---
title: Custom Plugin
description: 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:

```go
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:

```go
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()`:

```go
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:

```go
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:

```go
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:

```go
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:

```go
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:

```go
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:

```go
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):

```go
type PricingStrategy interface {
    Plugin
    StrategyName() string
    Compute(tiers []interface{}, usage, included int64, currency string) interface{}
}
```

```go
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:

```go
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:

```go
type TaxCalculator interface {
    Plugin
    CalculateTax(ctx context.Context, subtotal interface{}, tenantID string) (interface{}, error)
}
```

```go
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):

```go
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:

```go
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.

```go
// 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`:

```go
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.

```go
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.

```go
import "github.com/xraph/ledger/observability"

metricsPlugin := observability.NewMetricsExtension(metricsFactory)

engine := ledger.New(store, ledger.WithPlugin(metricsPlugin))
```

The `MetricFactory` interface is simple:

```go
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](/docs/ledger/guides/full-example) -- see plugins in action in the full billing pipeline.
- [Custom Store](/docs/ledger/guides/custom-store) -- implement a production storage backend.
- [Forge Extension](/docs/ledger/guides/forge-extension) -- mount Ledger into a Forge application.
