# Programmatic Monetization

Pricing rules often depend on _who's_ calling or _what_ your API returns. A few
examples:

- Restrict bulk export to the Enterprise plan.
- Offer advanced search only to plans that include it.
- Bill an AI endpoint by the tokens it returns, and a search endpoint by the
  rows it matches — not a flat amount per call.

You can enforce rules and meter usage declaratively with
[features and entitlements](./features.mdx) on your plans, in code with custom
policies, or both together. This guide covers the code path, for decisions that
need runtime logic. It shows two techniques, then combines them:

- **[Gate operations by plan](#gate-operations-by-plan)** — read the caller's
  subscription and allow or block the request based on their plan or
  entitlements.
- **[Meter from the response](#meter-from-the-response)** — compute usage from
  the response body, report it, and enforce a quota on it.

Both build on the [`MonetizationInboundPolicy`](./monetization-policy.md): the
[subscription data](./subscription-data.md) it exposes and its
[dynamic metering](./dynamic-metering.md) methods. Add that policy to your
monetized routes first.

## Gate operations by plan

To make an operation available on some plans but not others, read the
subscription in a custom code inbound policy and block the request when the plan
doesn't qualify. Place the policy _after_ `monetization-inbound` so the
subscription data exists on the context.

```ts
// modules/plan-gate.ts
import {
  MonetizationInboundPolicy,
  HttpProblems,
  ZuploContext,
  ZuploRequest,
} from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  const subscription = MonetizationInboundPolicy.getSubscriptionData(context);

  if (!subscription) {
    return HttpProblems.forbidden(request, context, {
      detail: "No active subscription",
    });
  }

  // Bulk export is an Enterprise-only operation
  if (subscription.plan.key !== "enterprise") {
    return HttpProblems.forbidden(request, context, {
      detail: "Bulk export requires the Enterprise plan",
    });
  }

  return request;
}
```

To gate on a specific feature instead of the whole plan, check its entitlement:

```ts
const advancedSearch = subscription.entitlements["advanced_search"];
if (!advancedSearch?.hasAccess) {
  return HttpProblems.forbidden(request, context, {
    detail: "Your plan does not include advanced search",
  });
}
```

Register the policy and apply it after `monetization-inbound` on the routes you
want to protect:

```json
// config/policies.json
{
  "name": "plan-gate",
  "policyType": "custom-code-inbound",
  "handler": {
    "export": "default",
    "module": "$import(./modules/plan-gate)"
  }
}
```

```json
// On the route
{
  "x-zuplo-route": {
    "policies": {
      "inbound": ["monetization-inbound", "plan-gate"]
    }
  }
}
```

## Meter from the response

For variable-cost endpoints, meter a request by something you only know once the
backend responds — the number of records returned, items processed, or tokens
generated. Compute the value in a custom code outbound policy and report it with
`MonetizationInboundPolicy.addMeters`.

```ts
// modules/count-records.ts
import {
  MonetizationInboundPolicy,
  ZuploContext,
  ZuploRequest,
} from "@zuplo/runtime";

export default async function (
  response: Response,
  request: ZuploRequest,
  context: ZuploContext,
) {
  if (!response.ok) {
    return response;
  }

  // Reading the body consumes it, so rebuild the response afterward
  const data = (await response.json()) as { records: unknown[] };
  const recordCount = data.records?.length ?? 0;

  MonetizationInboundPolicy.addMeters(context, { records: recordCount });

  return new Response(JSON.stringify(data), {
    status: response.status,
    headers: response.headers,
  });
}
```

`addMeters` accumulates with any static `meters` and earlier calls; use
`setMeters` to replace the runtime value instead. The policy sends the combined
total once the response goes out, and only for the configured status codes. See
[Dynamic Metering](./dynamic-metering.md) for the full API and merge rules.

## Block on a response-derived meter

`addMeters` runs in an outbound policy — it sees the response only after the
handler returns. By then it's too late to block the current request, and because
the meter never appeared in the policy's static config, the inbound quota check
never ran for it. A caller who is already over their limit still gets through.

To enforce the quota, declare the meter in the policy's `meters` option with a
value of **`0`**:

```json
// config/policies.json
{
  "name": "monetization-inbound",
  "policyType": "monetization-inbound",
  "handler": {
    "export": "MonetizationInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "meters": { "records": 0 }
    }
  }
}
```

This does two things:

1. **Enforces the quota up front.** At request time the policy checks the
   `records` entitlement and returns `403 Forbidden` when the balance has run
   out — before the request reaches your backend.
2. **Avoids double-counting.** The static `0` contributes nothing to the total.
   Runtime values add to the static base (`0 + n = n`), so `addMeters` reports
   the exact amount to bill.

:::note

The quota check runs at the start of each request and lets through any caller
who still has balance; the policy charges usage after the response. So a request
whose cost exceeds the remaining balance still completes, the balance goes
negative, and the policy blocks the next request. An increment of `1` enforces
the quota exactly — a caller overshoots only when a single request meters more
than `1` against a partial balance.

:::

## Putting it together

A complete setup for a metered, plan-gated, response-counted route:

```json
// config/policies.json
{
  "policies": [
    {
      "name": "monetization-inbound",
      "policyType": "monetization-inbound",
      "handler": {
        "export": "MonetizationInboundPolicy",
        "module": "$import(@zuplo/runtime)",
        "options": {
          "meters": { "records": 0 }
        }
      }
    },
    {
      "name": "plan-gate",
      "policyType": "custom-code-inbound",
      "handler": {
        "export": "default",
        "module": "$import(./modules/plan-gate)"
      }
    },
    {
      "name": "count-records",
      "policyType": "custom-code-outbound",
      "handler": {
        "export": "default",
        "module": "$import(./modules/count-records)"
      }
    }
  ]
}
```

```json
// On the route
{
  "x-zuplo-route": {
    "policies": {
      "inbound": ["monetization-inbound", "plan-gate"],
      "outbound": ["count-records"]
    }
  }
}
```

The request flows through the monetization policy (authenticates, checks the
`records` quota, blocks if the quota has run out), then the plan gate (allows
the operation only on qualifying plans), then your handler, then the outbound
policy (counts the records and reports them with `addMeters`).

## Next steps

- [Reading Subscription Data](./subscription-data.md) — the full subscription
  object you can read in code.
- [Dynamic Metering](./dynamic-metering.md) — the full `setMeters` / `addMeters`
  / `getMeters` API and how static and runtime values merge.
- [Monetization Policy Reference](./monetization-policy.md) — every
  configuration option.
- [Meters](./meters.mdx) — defining the meters your policies increment.
