# Monetization Policy Reference

:::note{title="Beta"}

API Monetization is in beta and free to try. The APIs are stable but should be
evaluated in non-production environments first. To go to production, contact
[sales@zuplo.com](mailto:sales@zuplo.com). Production pricing has not yet been
announced.

:::

The `MonetizationInboundPolicy` is the gateway enforcement mechanism. It runs on
every request to a protected route, authenticates the API key, checks the
customer's subscription and payment status, enforces quota, meters the request,
and allows or blocks access.

## Basic configuration

Add the policy to your `policies.json`:

```json
{
  "name": "monetization-inbound",
  "policyType": "monetization-inbound",
  "handler": {
    "export": "MonetizationInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "meters": {
        "api_requests": 1
      }
    }
  }
}
```

Then reference it in your route's inbound policy pipeline:

```json
{
  "x-zuplo-route": {
    "policies": {
      "inbound": ["monetization-inbound"]
    }
  }
}
```

:::note

The `MonetizationInboundPolicy` handles API key authentication internally. It
reads the API key from the `Authorization` header, validates it, and sets
`request.user`. You do not need a separate API key authentication policy
(`api-key-inbound`) on monetized routes — the monetization policy replaces it.

:::

## Configuration options

| Option               | Type               | Default           | Description                                       |
| -------------------- | ------------------ | ----------------- | ------------------------------------------------- |
| `meters`             | object             | _(none)_          | Map of meter keys to increment values             |
| `meterOnStatusCodes` | string or number[] | `"200-299"`       | Status code range to meter                        |
| `authHeader`         | string             | `"authorization"` | Header to read the API key from                   |
| `authScheme`         | string             | `"Bearer"`        | Expected auth scheme prefix                       |
| `cacheTtlSeconds`    | number             | `60`              | How long to cache subscription data (minimum 60s) |

### `meters`

The `meters` option defines which meters to increment and by how much when a
request is processed. Values must be non-negative numbers.

If `meters` is omitted, the policy still authenticates the API key and validates
the subscription's payment status, but no usage is recorded. If `meters` is
provided, it must contain at least one entry — an empty object throws a
configuration error. To track usage at runtime instead of from static config,
see [Dynamic metering](#dynamic-metering).

```json
// Increment the api_requests meter by 1 per request
{ "meters": { "api_requests": 1 } }

// Increment multiple meters simultaneously
{ "meters": { "api_requests": 1, "api_credits": 5 } }

// Increment by a fixed amount per request (expensive endpoint)
{ "meters": { "api_credits": 10 } }
```

### `meterOnStatusCodes`

Controls which responses count toward metering. By default, only successful
responses (2xx) are metered.

```json
// Only meter successful responses (default)
{ "meterOnStatusCodes": "200-299" }

// Only meter 200 OK
{ "meterOnStatusCodes": "200" }

// Meter success and redirects
{ "meterOnStatusCodes": "200-399" }

// Comma-separated ranges
{ "meterOnStatusCodes": "200, 201, 300-304" }

// Array of specific status codes
{ "meterOnStatusCodes": [200, 201, 202] }
```

:::caution

The wildcard `"*"` is not a valid value for `meterOnStatusCodes` and throws a
configuration error. Use a specific range like `"200-599"` if you want to meter
most responses.

:::

This is important for fairness: if your backend returns a 500 error, you
probably don't want to charge the customer for that request.

### `authHeader`

The header to read the API key from. Defaults to `"authorization"`.

### `authScheme`

The expected auth scheme prefix. Defaults to `"Bearer"`. The policy expects the
header value in the format `{authScheme} {apiKey}`.

### `cacheTtlSeconds`

How long to cache subscription and entitlement data, in seconds. Defaults to
`60` with a minimum of `60`. Increasing this value reduces calls to the gateway
service but means entitlement changes take longer to propagate.

## Subscription and payment validation

The policy checks payment status on every request. Access is granted when:

- The subscription is active and not expired
- Payment status is `paid` or `not_required` (free plans)

When payment fails, a configurable grace period (default 3 days) allows
continued access while retries are attempted. After the grace period, access is
blocked until payment succeeds.

The grace period resolves in this order, with each level overriding the one
below it:

1. **Customer metadata** — `zuplo_max_payment_overdue_days` on the customer
2. **Plan metadata** — `zuplo_max_payment_overdue_days` on the plan
3. **Bucket configuration** —
   [`maxPaymentOverdueDays`](./api-access.mdx#bucket-monetization-configuration)
   on the bucket's monetization configuration
4. **Default** — `3` days

Set the value to `0` to block requests immediately when payment is overdue.

## Multiple policies for different routes

Different routes can have different metering configurations. Define multiple
policy instances in `policies.json`:

```json
[
  {
    "name": "monetization-standard",
    "policyType": "monetization-inbound",
    "handler": {
      "export": "MonetizationInboundPolicy",
      "module": "$import(@zuplo/runtime)",
      "options": {
        "meters": { "api_requests": 1 }
      }
    }
  },
  {
    "name": "monetization-ai",
    "policyType": "monetization-inbound",
    "handler": {
      "export": "MonetizationInboundPolicy",
      "module": "$import(@zuplo/runtime)",
      "options": {
        "meters": { "api_requests": 1, "tokens": 1 }
      }
    }
  },
  {
    "name": "monetization-premium",
    "policyType": "monetization-inbound",
    "handler": {
      "export": "MonetizationInboundPolicy",
      "module": "$import(@zuplo/runtime)",
      "options": {
        "meters": { "api_credits": 10 }
      }
    }
  }
]
```

Apply each to the appropriate routes:

```json
// Simple lookup -> 1 request meter
"/api/v1/search": { "inbound": ["monetization-standard"] }

// AI endpoint -> 1 request + token metering
"/api/v1/analyze": { "inbound": ["monetization-ai"] }

// Premium endpoint -> 10 credits
"/api/v1/bulk-export": { "inbound": ["monetization-premium"] }
```

## Dynamic metering

For AI APIs and other variable-cost endpoints, you may need to meter based on
the response — for example, counting the number of tokens returned by an LLM.

Use the `setMeters` and `addMeters` static methods to set meter values
programmatically at runtime from a custom policy or handler:

```typescript
import { MonetizationInboundPolicy } from "@zuplo/runtime";

// In a custom outbound policy, set meters based on the response
export default async function meterTokens(response, request, context) {
  if (response.ok) {
    const body = await response.json();
    const tokens = body.usage?.total_tokens ?? 0;

    MonetizationInboundPolicy.setMeters(context, {
      tokens_used: tokens,
    });
  }
  return response;
}
```

You can also use `addMeters` to add to existing meter values rather than
replacing them:

```typescript
MonetizationInboundPolicy.addMeters(context, {
  api_credits: creditsConsumed,
});
```

You can also read the current runtime meter values at any point:

```typescript
const meters = MonetizationInboundPolicy.getMeters(context);
// { tokens_used: 150 }
```

### How meter values are merged

The final metering hook combines static and runtime values before usage is sent:

- `options.meters` provides the static base values
- `setMeters` replaces the current runtime meter map, overriding matching static
  keys
- `addMeters` accumulates into the runtime meter map, then combines additively
  with static values
- If both static and runtime maps are empty, metering is skipped

For a meter key like `api` with `options.meters.api = 1`:

- `setMeters(context, { api: 50 })` sends `api: 50` (replaces static value)
- `addMeters(context, { api: 50 })` sends `api: 51` (adds to static value)

## Error responses

The policy returns `403 Forbidden` for all error conditions. Responses follow
the RFC 7807 Problem Details format:

```json
{
  "type": "https://httpproblems.com/http-status/403",
  "title": "Forbidden",
  "status": 403,
  "detail": "API Key has exceeded the allowed limit for \"api_requests\" meter.",
  "instance": "/api/v1/resource",
  "trace": {
    "timestamp": "2026-01-15T10:00:00Z",
    "requestId": "req_abc123",
    "buildId": "build_xyz"
  }
}
```

Common error details:

| Condition                          | `detail` message                                                    |
| ---------------------------------- | ------------------------------------------------------------------- |
| No auth header                     | `"No Authorization Header"`                                         |
| Wrong auth scheme                  | `"Invalid Authorization Scheme"`                                    |
| No key after the auth scheme       | `"No key present"`                                                  |
| Cached invalid key or upstream 401 | `"Authorization Failed"`                                            |
| Invalid API key                    | `"API Key is invalid or does not have access to the API"`           |
| Expired API key                    | `"API Key has expired."`                                            |
| Expired subscription               | `"API Key has an expired subscription."`                            |
| Missing payment status             | `"Subscription payment status is not available."`                   |
| Payment not made                   | `"Payment has not been made."`                                      |
| Payment overdue                    | `"Payment is overdue. Please update your payment method."`          |
| Quota exhausted                    | `"API Key has exceeded the allowed limit for \"X\" meter."`         |
| Meter not in subscription          | `"API Key does not have \"X\" meter provided by the subscription."` |

## Pipeline ordering

The monetization policy should be the first policy in the inbound pipeline since
it handles authentication:

```
1. monetization-inbound  → Authenticates, checks subscription, enforces quota, meters usage
2. rate-limiting         → (Optional) Per-second/per-minute spike protection
3. caching               → (Optional) Response caching
4. → Route handler       → Your API logic
```

If you still want per-second or per-minute rate limiting on top of monthly
quotas, add a standalone rate-limiting policy after the monetization policy.
These serve different purposes: monetization enforces billing quotas, while rate
limiting protects against traffic spikes.
