Writing Custom Alert Conditions
Limitr's notification and alerting system.
Limitr's notification system lets you define precisely which events your application cares about and what should happen when they fire. Conditions are written in Stof — a lightweight language that runs inside the policy engine.
Alert conditions can be defined in the dashboard and configured to alert your team in real time via Slack or email. The in-process patterns below work in Cloud mode too — Cloud-synced conditions and local setNotifications() conditions coexist on the same policy instance.
How notifications work
Every enforcement call that updates a meter fires one of three event types: meter-changed, meter-limit, or meter-overage. For each event, Limitr calls matches(type, event) on every notification defined in the policy. If matches() returns true, the notification's fire function is called, and your TypeScript addHandler() receives the result with the notification name as the key:
policy.addHandler('alerts', (key: string, value: unknown) => {
// key is a built-in event type OR a custom notification name
if (key === 'high-usage-warning') {
const event = JSON.parse(value as string);
slack.send(`#alerts`, `${event.customer.id} is at ${event.remaining} tokens remaining`);
}
});
The Stof notification format
Notifications are loaded via setNotifications() as a Stof string. Each notification is a named field containing a matches function and optionally a fire function:
await policy.setNotifications(`
<notification-id>: {
fn matches(type: str, event: obj) -> bool {
// return true to fire this notification
}
fn fire(name: str, event: obj) {
// optional — events always sent to addHandler() regardless
}
}
`);
setNotifications() merges into the policy's notifications block. Calling it multiple times with the same notification ID replaces the earlier definition.
The event payload
Both matches() and fire() receive the full event object:
event.type // redundant with the type param, but available
event.entitlement // entitlement name: 'chat_input', 'seats', etc.
event.plan // plan ID: 'starter', 'growth', etc.
event.remaining // remaining balance after this operation
event.customer.id // customer ID
event.customer.plan // customer's current plan
event.customer.type // customer type: 'user', 'org', etc.
event.meter.value // new meter value (post-operation)
event.meter.limit // enforced limit value
event.meter.credit // credit ID
event.credit.description // credit description string
event.overage // meter-overage only: amount over limit after grants
event.grant_value_applied // meter-overage only: how much grant covered
Stof syntax primer
matches() returns a boolean expression. A few things to know:
Equality and comparison:
type == 'meter-changed'
event.remaining < 1000
event.meter.value >= event.meter.limit
Logical operators:
type == 'meter-changed' && event.entitlement == 'chat_input'
type == 'meter-limit' || type == 'meter-overage'
Arithmetic:
event.remaining < (event.meter.limit / 2) // less than 50% remaining
event.meter.value >= (event.meter.limit * 0.8) // 80% consumed
Null safety: Field access on a missing field returns null rather than throwing. null < 1000 is false.
Return value: Use explicit return or let the last expression be the return value:
fn matches(type: str, event: obj) -> bool {
type == 'meter-changed' // implicitly returned
}
Patterns
Usage threshold alert
Fire when a customer has consumed more than 80% of their allocation:
await policy.setNotifications(`
approaching-limit: {
fn matches(type: str, event: obj) -> bool {
if (type != 'meter-changed') return false;
if (event.entitlement != 'chat_input') return false;
event.meter.value >= (event.meter.limit * 0.8)
}
}
`);
policy.addHandler('alerts', (key, value) => {
if (key === 'approaching-limit') {
const event = JSON.parse(value as string);
const pct = Math.round((event.meter.value / event.meter.limit) * 100);
notify(`${event.customer.id} has used ${pct}% of their daily token budget`);
}
});
Hard limit hit on a specific plan
Fire only when a Growth customer hits a hard limit — useful for distinguishing from expected Starter blocks:
await policy.setNotifications(`
growth-hard-limit: {
fn matches(type: str, event: obj) -> bool {
type == 'meter-limit' && event.customer.plan == 'growth'
}
}
`);
Any limit event on a specific entitlement
Catch both hard blocks and soft overages on seats — useful for org seat management:
await policy.setNotifications(`
seat-pressure: {
fn matches(type: str, event: obj) -> bool {
event.entitlement == 'seats' &&
(type == 'meter-limit' || type == 'meter-overage')
}
}
`);
policy.addHandler('seats', (key, value) => {
if (key === 'seat-pressure') {
const event = JSON.parse(value as string);
crm.flag(event.customer.id, 'seat-pressure', {
type: event.type,
used: event.meter.value,
limit: event.meter.limit,
});
}
});
First overage event only
Fire on the first overage per customer per entitlement — useful for sending a single "you've exceeded your limit" notification rather than one per request:
await policy.setNotifications(`
first-overage: {
fn matches(type: str, event: obj) -> bool {
if (type != 'meter-overage') return false;
// remaining is negative when over limit — fire only when meter first crosses
event.remaining >= -1 && event.remaining < 0
}
}
`);
Multiple entitlements, same condition
await policy.setNotifications(`
token-budget-warning: {
fn matches(type: str, event: obj) -> bool {
if (type != 'meter-changed') return false;
const watched = event.entitlement == 'chat_input' ||
event.entitlement == 'chat_output';
if (!watched) return false;
event.remaining < (event.meter.limit * 0.1)
}
}
`);
Async fire function with a registered library
When you need fire to call a TypeScript function registered from your application:
// Register a library function from TypeScript
policy.doc.lib('Alerts', 'high_usage', (customerId: string, remaining: number) => {
pagerduty.trigger(customerId, `${remaining} tokens remaining`);
});
await policy.setNotifications(`
high-usage: {
fn matches(type: str, event: obj) -> bool {
type == 'meter-changed' &&
event.entitlement == 'chat_input' &&
event.remaining < 5000
}
async fn fire(name: str, event: obj) {
// The ? prefix is Stof's optional-call operator — no-op if not registered
?Alerts.high_usage(event.customer.id, event.remaining);
}
}
`);
fire function signatures
fire can take 2 params, 1 param, or no params — Limitr calls whichever signature is defined:
fn fire(name: str, event: obj) { ... } // name = notification ID
fn fire(event: obj) { ... } // event only
fn fire() { ... } // no args
When no fire function is defined, Limitr routes the event directly to your addHandler() with the notification ID as the key — which is the most common pattern:
// No fire function — event routes directly to addHandler()
await policy.setNotifications(`
approaching-limit: {
fn matches(type: str, event: obj) -> bool {
type == 'meter-changed' && event.remaining < (event.meter.limit * 0.2)
}
}
`);
policy.addHandler('alerts', (key, value) => {
if (key === 'approaching-limit') { /* ... */ }
});
setNotifications vs addHandler directly
setNotifications | addHandler directly | |
|---|---|---|
| Condition language | Stof | TypeScript |
| Part of versioned policy | Yes | No |
| Supports Cloud routing | Yes | No |
| Dynamic at runtime | Yes | Yes |
| Best for | Threshold conditions, Cloud alerts | Simple event filtering, billing handlers |
A common production setup: setNotifications for threshold-based conditions, addHandler for billing and operational event handling that runs regardless.
Loading notifications at runtime
setNotifications() can be called at any time — before or after customers are created, before or after enforcement starts. New conditions take effect immediately on the next enforcement call.
// Load from a separate file
import { readFileSync } from 'fs';
await policy.setNotifications(readFileSync('./notifications.stof', 'utf-8'));
// Or inline, updated dynamically
await policy.setNotifications(`
new-condition: {
fn matches(type: str, event: obj) -> bool {
type == 'meter-overage' && event.plan == 'enterprise'
}
}
`);