---
title: Entitlement Checking
description: Sub-millisecond access control with Redis caching for feature gates and usage limits.
---

**Entitlement Checking** determines whether a customer has access to a feature or has exceeded usage limits. It's optimized for sub-millisecond response times using aggressive Redis caching and read-optimized data structures.

## Structure

```go
type Check struct {
    SubscriptionID id.SubscriptionID
    CustomerID     id.CustomerID
    Feature        string
    Allowed        bool
    Reason         string
    UsageCurrent   int64
    UsageLimit     int64
    CachedAt       time.Time
}
```

## Checking entitlements

The primary API is `entitlement.Check`:

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

result, err := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "api-calls",
})

if !result.Allowed {
    return fmt.Errorf("access denied: %s", result.Reason)
}

// Feature is allowed - proceed
processAPICall(ctx)
```

## Check reasons

When `Allowed` is `false`, `Reason` explains why:

| Reason | Description |
|--------|-------------|
| `no_subscription` | Customer has no active subscription |
| `feature_not_included` | Feature not in current plan |
| `limit_exceeded` | Usage limit reached |
| `subscription_inactive` | Subscription is canceled/past_due |
| `trial_expired` | Trial ended without payment method |

## Caching strategy

Entitlement checks are cached in Redis with a 60-second TTL:

```go
// First check: queries database and caches result
check1, _ := entitlement.Check(ctx, req) // ~10ms

// Second check: returns from cache
check2, _ := entitlement.Check(ctx, req) // <1ms
```

Cache keys are structured as:

```
entitlement:{customer_id}:{feature}
```

The cache is invalidated when:
- Subscription changes (upgrade, downgrade, cancel)
- Usage is recorded for the feature
- Plan is modified

## Feature types

### Boolean features

Simple on/off access:

```go
check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "priority-support",
})

if check.Allowed {
    routeToSupport("priority-queue")
}
```

### Metered features

Features with usage limits:

```go
check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "api-calls",
})

if !check.Allowed {
    return fmt.Errorf("quota exceeded: %d/%d calls used",
        check.UsageCurrent, check.UsageLimit)
}

fmt.Printf("Remaining: %d calls\n", check.UsageLimit - check.UsageCurrent)
```

### Licensed features

Seat-based features:

```go
check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "team-members",
    Quantity:   1, // Adding 1 member
})

if !check.Allowed {
    return fmt.Errorf("team full: %d/%d seats used",
        check.UsageCurrent, check.UsageLimit)
}
```

## Batch checking

Check multiple features at once:

```go
results, err := entitlement.CheckBatch(ctx, &entitlement.BatchRequest{
    CustomerID: customer.ID,
    Features:   []string{"api-calls", "team-members", "priority-support"},
})

for feature, check := range results {
    if !check.Allowed {
        log.Printf("%s: denied - %s", feature, check.Reason)
    }
}
```

Batch checks are performed in parallel and return a map.

## Soft limits vs hard limits

Ledger supports both enforcement modes:

### Hard limit (default)

Access is denied when limit is reached:

```go
check, _ := entitlement.Check(ctx, req)
if !check.Allowed {
    return ErrQuotaExceeded
}
```

### Soft limit (monitor only)

Access is allowed but overage is tracked:

```go
check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "api-calls",
    Enforce:    false, // Soft limit
})

// Always allowed, but check.UsageCurrent may exceed check.UsageLimit
if check.UsageCurrent > check.UsageLimit {
    // Log overage event
    events.Emit(ctx, &events.OverageEvent{
        CustomerID: customer.ID,
        Feature:    "api-calls",
        Overage:    check.UsageCurrent - check.UsageLimit,
    })
}
```

## Real-time usage updates

When usage is metered, entitlements are recalculated automatically:

```go
// Record usage
meter.Record(ctx, &meter.Event{
    SubscriptionID: subscription.ID,
    Meter:          "api-calls",
    Value:          1,
})

// Cache invalidated, next check queries fresh usage
check, _ := entitlement.Check(ctx, &entitlement.Request{
    CustomerID: customer.ID,
    Feature:    "api-calls",
})
```

## Middleware integration

Use entitlement checks in HTTP middleware:

```go
func EntitlementMiddleware(feature string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            customerID := getCustomerID(r.Context())

            check, err := entitlement.Check(r.Context(), &entitlement.Request{
                CustomerID: customerID,
                Feature:    feature,
            })

            if err != nil || !check.Allowed {
                http.Error(w, check.Reason, http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

// Usage:
router.Handle("/api/v1/analytics", EntitlementMiddleware("analytics-access")(analyticsHandler))
```

## Pre-warming the cache

For critical features, pre-warm the cache on subscription creation:

```go
func onSubscriptionCreated(ctx context.Context, sub *subscription.Subscription) {
    plan, _ := store.GetPlan(ctx, sub.PlanID)

    for _, feature := range plan.Features {
        // Prime the cache
        entitlement.Check(ctx, &entitlement.Request{
            CustomerID: sub.CustomerID,
            Feature:    feature.Name,
        })
    }
}
```

## Performance targets

Ledger's entitlement system is optimized for:

| Metric | Target | Actual |
|--------|--------|--------|
| Cache hit latency | &lt;1ms | ~0.3ms |
| Cache miss latency | &lt;10ms | ~8ms |
| Cache hit rate | &gt;95% | ~98% |
| Concurrent checks | 10K/sec | 15K/sec |

## Store interface

```go
type Store interface {
    CheckEntitlement(ctx context.Context, req *Request) (*Check, error)
    CheckBatch(ctx context.Context, req *BatchRequest) (map[string]*Check, error)
    InvalidateCache(ctx context.Context, customerID id.CustomerID, feature string) error
    GetEntitlements(ctx context.Context, customerID id.CustomerID) ([]*Check, error)
}
```

## API routes

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/ledger/entitlements/check` | Check single entitlement |
| `POST` | `/ledger/entitlements/check-batch` | Check multiple entitlements |
| `GET` | `/ledger/entitlements/{customerId}` | List all entitlements |
| `POST` | `/ledger/entitlements/{customerId}/invalidate` | Invalidate cache |
