Skip to content

Callbacks & Events

Callbacks vs Webhooks

Callbacks run in-process — they fire in your application code when a gate or usage event happens. Webhooks fire out-of-process — Paygent POSTs to a URL you configure, even if your app isn't running the SDK at that moment. Use callbacks for real-time in-app reactions (upgrade prompts, model fallback). Use webhooks for external integrations (Slack alerts, billing systems, monitoring).

Paygent fires three types of callbacks:

  • on_soft_gate — user is approaching a limit
  • on_hard_gate — user is over a limit (call is blocked)
  • on_usage — every successful metered call

Plus there's the exception you catch in your request handler when a hard gate raises.

This page covers all four with working FastAPI / Flask examples.

on_soft_gate(callback)

Fires when usage hits soft_gate_at (default 80%) on any dimension. The LLM call still runs — soft gate is a warning, not a block.

The callback receives a GuardResult:

class GuardResult:
    status: Literal["ok", "soft_gate", "hard_gate"]   # "soft_gate" here
    gate_reason: str | None                            # see table below
    usage_pct: float                                   # 0.80 → 80% of limit
    current_value: float                               # current dollars or tokens
    limit_value: float                                 # the cap
    message: str | None                                # human-readable

gate_reason values:

Value Meaning
"total_spend" Period spend limit
"session_spend" Session spend limit
"model_limit:gpt-4o" Per-model token cap (model name follows the colon)

Use it for

  • Surfacing a warning banner in your UI
  • Logging quota events to your observability stack
  • Triggering a "you should upgrade" email
  • Pre-emptively switching the user to a cheaper model

Complete example

from paygent import Paygent, paygent_context

pg = Paygent.init(api_key="pg_live_...")

def warn(result):
    print(f"⚠ {result.message}")
    # gate_reason starts with "model_limit:" for model-specific gates
    if result.gate_reason and result.gate_reason.startswith("model_limit:"):
        model = result.gate_reason.split(":", 1)[1]
        print(f"   → model {model}: {result.current_value:,} / "
              f"{result.limit_value:,} tokens ({result.usage_pct:.0%})")
    else:
        print(f"   → {result.gate_reason}: "
              f"${result.current_value:.2f} / ${result.limit_value:.2f}")

pg.on_soft_gate(warn)

with paygent_context(user_id="user_123"):
    response = client.chat.completions.create(...)
import { Paygent, paygentContext } from "@paygentjs/sdk";

const pg = await Paygent.init({ apiKey: "pg_live_..." });

pg.onSoftGate((result) => {
  console.warn(`⚠ ${result.message}`);
  // gateReason starts with "model_limit:" for model-specific gates
  if (result.gateReason?.startsWith("model_limit:")) {
    const model = result.gateReason.split(":", 2)[1];
    console.warn(
      `   → model ${model}: ${result.currentValue.toLocaleString()} / ` +
        `${result.limitValue.toLocaleString()} tokens ` +
        `(${Math.round(result.usagePct * 100)}%)`,
    );
  } else {
    console.warn(
      `   → ${result.gateReason}: ` +
        `$${result.currentValue.toFixed(2)} / $${result.limitValue.toFixed(2)}`,
    );
  }
});

await paygentContext({ userId: "user_123" }, async () => {
  const response = await openai.chat.completions.create(/* ... */);
});

Output when the user is at 87%:

⚠ Approaching spend limit: 87% used
   → total_spend: $42.63 / $49.00

Soft gates are also synced as audit events

Every soft gate fire is recorded as a GateEvent and synced to the backend (rate-limited to one row per (user_id, gate_reason) per 5 seconds — the SDK won't flood the audit trail when a user is hammering the API near the cap). Query them via GET /users/{id}/gate-events. See Backend API → Gate events.

on_hard_gate(callback)

Fires when usage hits hard_gate_at (default 100%). The LLM call is blocked — Paygent does not call OpenAI/Anthropic. No tokens, no cost.

The callback fires before PaygentLimitExceeded is raised. So if you want to log the block, the callback is the right place — even if the developer's code handles the exception, the callback already ran.

If you set raise_on_hard_gate=False on Paygent.init(), the callback fires but no exception is raised — the call proceeds anyway. Useful during a soft launch when you want telemetry on who's exceeding limits without enforcing yet.

Use it for

  • Logging blocks to your alerting system (PagerDuty, Sentry)
  • Sending a "you've hit your limit, please upgrade" notification
  • Recording the block in your own analytics
  • Any cleanup before the exception bubbles up

Complete example

import logging
from paygent import Paygent

logger = logging.getLogger(__name__)
pg = Paygent.init(api_key="pg_live_...")

def on_block(result):
    # This runs before the exception is raised.
    logger.error(
        "User hit limit | reason=%s pct=%.2f current=%.2f limit=%.2f",
        result.gate_reason, result.usage_pct,
        result.current_value, result.limit_value,
    )

    # Maybe trigger a workflow
    if result.gate_reason == "total_spend":
        send_upgrade_email(current_user_id())
    elif result.gate_reason and result.gate_reason.startswith("model_limit:"):
        log_to_analytics("model_quota_hit", model=result.gate_reason)

pg.on_hard_gate(on_block)
const pg = await Paygent.init({ apiKey: "pg_live_..." });

pg.onHardGate((result) => {
  // This runs before PaygentLimitExceeded is thrown.
  console.error(
    "User hit limit | reason=%s pct=%s current=%s limit=%s",
    result.gateReason,
    result.usagePct.toFixed(2),
    result.currentValue.toFixed(2),
    result.limitValue.toFixed(2),
  );

  // Maybe trigger a workflow
  if (result.gateReason === "total_spend") {
    sendUpgradeEmail(currentUserId());
  } else if (result.gateReason?.startsWith("model_limit:")) {
    logToAnalytics("model_quota_hit", { model: result.gateReason });
  }
});

on_usage(callback)

Fires after every successful metered call. The callback receives a UsageEvent:

class UsageEvent:
    id: str                    # UUID, idempotency key
    user_id: str
    session_id: str | None
    timestamp: datetime
    model: str | None          # normalized (e.g. "gpt-4o-mini", not "...-2024-07-18")
    input_tokens: int
    output_tokens: int
    total_tokens: int
    tool_calls: list[str]      # tool names invoked
    cost_tokens: float         # cost from token usage
    cost_tools: float          # cost from tool calls
    cost_total: float          # sum of above
    metadata: dict             # whatever you put in paygent_context(metadata=...)
    synced: bool               # has this been pushed to backend yet

Use it for

  • Real-time dashboard updates
  • Forwarding to your own analytics (Segment, Mixpanel, custom)
  • Cost-tracking in your own DB alongside Paygent
  • Custom alerting (e.g. "this user used $5 in 5 minutes")

Complete example

pg = Paygent.init(api_key="pg_live_...")

def log_call(event):
    print(
        f"[{event.timestamp.isoformat()}] "
        f"{event.user_id} {event.model} "
        f"in={event.input_tokens} out={event.output_tokens} "
        f"${event.cost_total:.4f}"
    )

    # Forward to your own pipeline
    my_segment_client.track(
        event.user_id,
        "llm_call",
        properties={
            "model": event.model,
            "tokens": event.total_tokens,
            "cost": event.cost_total,
            **event.metadata,
        },
    )

pg.on_usage(log_call)
const pg = await Paygent.init({ apiKey: "pg_live_..." });

pg.onUsage((event) => {
  console.log(
    `[${event.timestamp.toISOString()}] ` +
      `${event.userId} ${event.model} ` +
      `in=${event.inputTokens} out=${event.outputTokens} ` +
      `$${event.costTotal.toFixed(4)}`,
  );

  // Forward to your own pipeline
  segment.track({
    userId: event.userId,
    event: "llm_call",
    properties: {
      model: event.model,
      tokens: event.totalTokens,
      cost: event.costTotal,
      ...event.metadata,
    },
  });
});

The callback fires for every metering path: auto-instrumented OpenAI calls, pg.wrap(), pg.awrap(), and the LangChain / CrewAI callbacks. So it's the single place to plug in your own analytics.

on_session_start(callback)

Fires when a session is first loaded for a user — typically on their first call. Callback receives the UserState:

def on_start(state):
    print(f"Session start: user={state.user_id} plan={state.plan}")
    if state.billing_period:
        print(f"  Period: {state.billing_period.start}{state.billing_period.end}")

pg.on_session_start(on_start)
pg.onSessionStart((state) => {
  console.log(`Session start: user=${state.userId} plan=${state.plan}`);
  if (state.billingPeriod) {
    console.log(`  Period: ${state.billingPeriod.start}${state.billingPeriod.end}`);
  }
});

Useful for debugging which plan a user landed on, or for warming up your own per-user caches.

Multiple callbacks

You can register more than one of each type. They all fire in registration order. If one of them throws, the others still fire — Paygent catches exceptions per-callback.

pg.on_soft_gate(send_warning_email)
pg.on_soft_gate(log_to_metrics)
pg.on_soft_gate(update_dashboard)

# All three fire on every soft gate, even if send_warning_email throws.
pg.onSoftGate(sendWarningEmail);
pg.onSoftGate(logToMetrics);
pg.onSoftGate(updateDashboard);

// All three fire on every soft gate, even if sendWarningEmail throws.

There's no unregister. The list is meant to be set up once at startup.

Catching PaygentLimitExceeded

When raise_on_hard_gate=True (the default) and a hard gate fires, the LLM call doesn't run and PaygentLimitExceeded is raised. Catch it in your request handler.

The exception carries the full GuardResult:

try:
    response = client.chat.completions.create(...)
except PaygentLimitExceeded as e:
    e.guard_result.gate_reason   # "total_spend", "model_limit:gpt-4o", etc.
    e.guard_result.message       # human-readable
    e.guard_result.usage_pct     # how far over (1.02 = 102%)

FastAPI

from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
from openai import OpenAI

from paygent import Paygent, PaygentLimitExceeded, paygent_context

pg = Paygent.init(api_key=os.environ["PAYGENT_API_KEY"])
client = OpenAI()
app = FastAPI()

class ChatRequest(BaseModel):
    user_id: str
    message: str

@app.post("/chat")
async def chat(req: ChatRequest):
    try:
        with paygent_context(user_id=req.user_id):
            response = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=[{"role": "user", "content": req.message}],
            )
        return {"reply": response.choices[0].message.content}

    except PaygentLimitExceeded as e:
        raise HTTPException(
            status_code=429,
            detail={
                "error": "limit_reached",
                "reason": e.guard_result.gate_reason,
                "message": e.guard_result.message,
                "usage_pct": e.guard_result.usage_pct,
            },
        )
// Express
import express from "express";
import OpenAI from "openai";
import { Paygent, PaygentLimitExceeded, paygentContext } from "@paygentjs/sdk";

const pg = await Paygent.init({ apiKey: process.env.PAYGENT_API_KEY });
const openai = pg.instrument(new OpenAI());
const app = express();
app.use(express.json());

app.post("/chat", async (req, res) => {
  const { userId, message } = req.body;
  try {
    const response = await paygentContext({ userId }, () =>
      openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [{ role: "user", content: message }],
      }),
    );
    res.json({ reply: response.choices[0].message.content });
  } catch (err) {
    if (err instanceof PaygentLimitExceeded) {
      res.status(429).json({
        error: "limit_reached",
        reason: err.guardResult.gateReason,
        message: err.guardResult.message,
        usagePct: err.guardResult.usagePct,
      });
    } else {
      throw err;
    }
  }
});

429 is the conventional status — "Too Many Requests" / "rate-limited" in spirit, even though the cap is dollar-based.

Flask

from flask import Flask, request, jsonify
from openai import OpenAI

from paygent import Paygent, PaygentLimitExceeded, paygent_context

pg = Paygent.init(api_key=os.environ["PAYGENT_API_KEY"])
client = OpenAI()
app = Flask(__name__)

@app.post("/chat")
def chat():
    body = request.get_json()
    user_id = body["user_id"]

    try:
        with paygent_context(user_id=user_id):
            response = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=[{"role": "user", "content": body["message"]}],
            )
        return jsonify({"reply": response.choices[0].message.content})

    except PaygentLimitExceeded as e:
        return jsonify({
            "error": "limit_reached",
            "reason": e.guard_result.gate_reason,
            "message": e.guard_result.message,
        }), 429
// Next.js App Router — app/api/chat/route.ts
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
import { Paygent, PaygentLimitExceeded, paygentContext } from "@paygentjs/sdk";

const pg = await Paygent.init({ apiKey: process.env.PAYGENT_API_KEY });
const openai = pg.instrument(new OpenAI());

export async function POST(req: NextRequest) {
  const { userId, message } = await req.json();
  try {
    const response = await paygentContext({ userId }, () =>
      openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [{ role: "user", content: message }],
      }),
    );
    return NextResponse.json({ reply: response.choices[0].message.content });
  } catch (err) {
    if (err instanceof PaygentLimitExceeded) {
      return NextResponse.json(
        {
          error: "limit_reached",
          reason: err.guardResult.gateReason,
          message: err.guardResult.message,
        },
        { status: 429 },
      );
    }
    throw err;
  }
}

Don't want to handle exceptions?

Pass raise_on_hard_gate=False to Paygent.init(). Then:

  • on_hard_gate callbacks still fire
  • The LLM call executes anyway
  • Your code looks identical to a successful call
  • The user's period_cost ticks up past the cap

This mode is useful during a rollout: get telemetry on who would've been blocked without actually blocking yet. Once you're confident in your limits, flip raise_on_hard_gate=True.

Pre-flight checks (no exception, just ask)

If you'd rather check the gate before making the call:

if pg.is_within_limit("user_123", model="gpt-4o"):
    response = client.chat.completions.create(model="gpt-4o", ...)
else:
    return "You've hit your GPT-4o quota. Upgrade or use gpt-4o-mini."
// precheck() runs the same guard the call would, without making it.
// It fires soft/hard callbacks + the audit event but never throws.
const guard = await pg.precheck("user_123", "gpt-4o");
if (guard.status !== "hard_gate") {
  const response = await openai.chat.completions.create({ model: "gpt-4o", /* ... */ });
} else {
  return "You've hit your GPT-4o quota. Upgrade or use gpt-4o-mini.";
}

Or for the full picture:

budget = pg.get_remaining_budget("user_123")
print(f"Period:     ${budget.period_spend_remaining:.2f} left")
print(f"Session:    ${budget.session_spend_remaining:.2f} left")
print(f"By model:   {budget.model_tokens_remaining}")
print(f"Tightest:   {budget.most_constrained}")
// The JS SDK surfaces remaining budget through getMaxTokens(): the advice
// object carries the per-dimension headroom plus which one is tightest.
const advice = await pg.getMaxTokens("user_123", "gpt-4o");
console.log(`Period:   $${advice.periodSpendRemaining.toFixed(2)} left`);
console.log(`Session:  $${advice.sessionSpendRemaining.toFixed(2)} left`);
console.log(`By model: ${advice.modelTokensRemaining ?? "unlimited"} tokens`);
console.log(`Tightest: ${advice.bindingLimit}`);

// Lifetime totals consumed so far come from getUsage():
const usage = pg.getUsage("user_123");
console.log(`Spent so far: $${usage.totalCost.toFixed(2)}`, usage.tokensByModel);

For computing a safe max_tokens value to pass to OpenAI:

advice = pg.get_max_tokens(
    "user_123",
    model="gpt-4o",
    messages=[{"role": "user", "content": "..."}],   # paygent estimates input tokens
)
if advice.max_tokens == 0:
    return "Budget exhausted"

response = client.chat.completions.create(
    model="gpt-4o",
    messages=...,
    max_tokens=advice.max_tokens,   # guaranteed not to overshoot any limit
)
const messages = [{ role: "user", content: "..." }];
const advice = await pg.getMaxTokens("user_123", "gpt-4o", { messages });
// ↑ paygent estimates input tokens from `messages`
if (advice.maxTokens === 0) {
  return "Budget exhausted";
}

const response = await openai.chat.completions.create({
  model: "gpt-4o",
  messages,
  max_tokens: advice.maxTokens, // guaranteed not to overshoot any limit
});

The binding_limit / bindingLimit field tells you which dimension is tightest (period_spend, session_spend, model_tokens, unbounded, or blocked).

Next steps

  • Frameworks — LangChain / CrewAI integration with the same callback model
  • Streaming — when token capture happens for streamed responses
  • SDK Reference — every callback signature