Grants & spending controls

What a grant is

A grant is “an actor that may spend from this wallet.” Three kinds today:

  • account_default — the implicit grant for the account owner, used whenever the user is signed in directly (dashboard session, dashboard JWT).
  • api_key — one per API key. Created automatically when the key is created; deleted (revoked) when the key is.
  • oauth — one per OAuth grant issued to an agent via /oauth/authorize. Revoking the grant kills every token tied to it.

Each grant has its own row in spending_controls. That’s what makes “Claude can spend 500 KES/day, but my server cron can spend 50,000 KES/day” expressible: they’re different grants, each with its own caps.

What controls do

Five checks run before any wallet hold is placed, in this order:

  1. daily_limit — sum of today’s confirmed + pending debits + this call’s price must be ≤ limit
  2. per_transaction_limit — this call’s price must be ≤ limit
  3. blocked_capability_slugs — if the slug is on the list, reject
  4. allowed_capability_slugs — if a non-empty allow-list is set and the slug isn’t on it, reject
  5. require_escalation_above — if the price exceeds the threshold, return escalation_required (the agent must request a human approval before this call goes through)

Any rejection produces a 402 with X-LAP-Rejection-Reason: <code> and a Lipafy error code matching the failed check.

Setting controls

$curl -X PUT https://api.lipafy.xyz/v1/controls \
> -H "authorization: Bearer <dashboard-jwt>" \
> -H "content-type: application/json" \
> -d '{
> "daily_limit": "1000",
> "per_transaction_limit": "200",
> "blocked_capability_slugs": ["betting.*"],
> "require_escalation_above": "500"
> }'

That sets account-level (legacy) controls. To set controls per grant (per agent), pass grant_id:

$curl -X PUT https://api.lipafy.xyz/v1/controls \
> -d '{
> "grant_id": "<oauth-grant-uuid>",
> "daily_limit": "500",
> "per_transaction_limit": "100"
> }'

Resolution order

When the gateway checks controls for a request, it tries:

  1. The specific grant_id of the credential the caller authenticated with
  2. The account-level (grant_id IS NULL) row, as a fallback “house rules”
  3. If neither exists, no controls — the call goes through

This means grant-specific controls fully override the account default. Set a grant row with active: false and a request authenticating via that grant runs with no controls at all (an explicit choice, not a fallback) — so use that toggle deliberately.

Why this beats “limit per account”

The naïve model — one cap per account — means giving an agent any access risks your full daily budget. With grants:

  • Different agents get different caps without separate accounts
  • Revoking one agent doesn’t disrupt others
  • Audit shows exactly which credential drove which spend
  • Approvals attach to a specific grant, not the whole account

The same primitive scales from “I give Claude 500 KES/day” to “this company gives each of its 200 employees a Lipafy grant with a different cap, and they all share one wallet.”

What controls don’t do

  • They don’t replace balance checks. If your wallet has 50 KES and the call costs 100, you get insufficient_balance regardless of controls.
  • They don’t apply at scheduled-intent fire time differently than at live-call time — the controls active when the call fires are what matter. A user who tightens controls between scheduling and firing will see the scheduled call fail.
  • They don’t override mandates. An intent mandate’s own conditions (slug patterns, per-fulfillment cap, total budget) are checked independently and in addition.