Warn your users before they hit a limit
A user who hits a limit without warning feels cut off. A user who got an email at 80% had time to slow down or buy more. Limitry sends your server a webhook when a subject reaches 80% and 100% of a limit, and your server decides what to tell the user.
This guide uses the $2 monthly AI budget from
Limit AI spend per user of your product, a
limit of 200 cents a month on each user. The warning at 80% arrives
when a user has spent 160 cents.
Limitry never emails your users. You send the message, from your own domain, in your own words.
1. Add an endpoint for warnings
Section titled “1. Add an endpoint for warnings”In the app, open Settings → Webhooks, add your endpoint’s URL, pick
“Only these” and choose limit.threshold_crossed. Copy the signing
secret. It’s shown once. Or with the API, using a live key:
import { createClient } from "@limitry/sdk";
const limitry = createClient({ token: process.env.LIMITRY_LIVE_KEY });
await limitry.workspace.webhookEndpoints.create({ url: "https://example.com/hooks/limitry", eventTypes: ["limit.threshold_crossed"],});import os
from limitry import Limitry
limitry = Limitry(token=os.environ["LIMITRY_LIVE_KEY"])
limitry.workspace.webhook_endpoints.create( url="https://example.com/hooks/limitry", event_types=["limit.threshold_crossed"],)limitry workspace webhook-endpoints create --environment live \ --url https://example.com/hooks/limitry \ --event-types '["limit.threshold_crossed"]'curl -X POST https://api.limitry.com/v1/workspace/webhook-endpoints \ -H "Authorization: Bearer $LIMITRY_LIVE_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/hooks/limitry","eventTypes":["limit.threshold_crossed"]}'2. Handle the warning
Section titled “2. Handle the warning”The handler verifies the signature, ignores repeats and test traffic, and emails the user:
import { verifyWebhook, WebhookVerificationError } from "@limitry/sdk";
// What a limit.threshold_crossed event carries.type ThresholdCrossed = { subject: { kind: string; id: string }; environment: "test" | "live"; threshold: 80 | 100; used: number; amount: number; resetsAt: string | null;};
// `rawBody` must be the request body exactly as it arrived.app.post("/hooks/limitry", async (req, res) => { let event; try { event = await verifyWebhook( req.rawBody, req.headers, process.env.LIMITRY_WEBHOOK_SECRET!, ); } catch (error) { if (error instanceof WebhookVerificationError) { return res.status(400).send("bad signature"); } throw error; } res.status(200).send("ok"); // answer first, work after
if (event.type !== "limit.threshold_crossed") return; const data = event.data as ThresholdCrossed; if (data.environment !== "live") return; if (data.subject.kind !== "user") return; // A warning resent after an outage keeps its id: send one email per id. if (!(await markProcessed(event.id))) return;
const user = await db.users.find(data.subject.id); if (!user) return;
const resets = data.resetsAt ? ` It resets ${new Date(data.resetsAt).toUTCString()}.` : ""; await sendEmail({ to: user.email, subject: data.threshold === 80 ? "You've used 80% of your allowance" : "You've used all of your allowance", text: `You've used ${data.used} of ${data.amount}.` + resets + " See your usage at https://example.com/usage.", });});import osfrom datetime import datetime
from flask import Flask, requestfrom limitry import WebhookEvent, WebhookVerificationError, verify_webhook
app = Flask(__name__)
# The body must be exactly as it arrived.@app.post("/hooks/limitry")def limitry_hook(): try: event = verify_webhook( request.get_data(), request.headers, os.environ["LIMITRY_WEBHOOK_SECRET"], ) except WebhookVerificationError: return "bad signature", 400 handle_later(event) # answer first, work after return "ok", 200
def handle_later(event: WebhookEvent) -> None: data = event["data"] if event["type"] != "limit.threshold_crossed": return if data["environment"] != "live" or data["subject"]["kind"] != "user": return # A warning resent after an outage keeps its id: send one email per id. if not mark_processed(event["id"]): return user = db.users.find(data["subject"]["id"]) if not user: return resets = ( f" It resets {datetime.fromisoformat(data['resetsAt'].replace('Z', '+00:00')):%a, %d %b %Y %H:%M} UTC." if data["resetsAt"] else "" ) send_email( to=user.email, subject="You've used 80% of your allowance" if data["threshold"] == 80 else "You've used all of your allowance", text=f"You've used {data['used']} of {data['amount']}.{resets}" " See your usage at https://example.com/usage.", )markProcessed records the event id and returns false if it was
already there, for example an insert into a table with the id as its
primary key. sendEmail is your email provider.
For prepaid credits, a limit with window total, threshold: 80 means
20% of the credits are left and threshold: 100 means none are. A
balance never resets, so resetsAt is null. Link to your page for
buying more instead of a reset time. After you grant credits, the
balance can warn again the next time it runs low.
The payload and how often each kind of limit warns are in Webhooks.
3. Try it in test
Section titled “3. Try it in test”The endpoint from step 1 belongs to live and receives only live events.
To try the warning, add a test endpoint the same way, with the app on
test or with a test key (lim_test_…). It has its own signing
secret. Point it at a test copy of your handler, or let your handler
through for test events while you try it.
Then, with the test key, make ai-call checks for one user until they
pass 80% of the test budget, for example a check with a cost of 170.
The warning reaches the test endpoint within about a minute, with
"environment": "test".
Show the numbers too
Section titled “Show the numbers too”An email says “you’re close”. A usage meter in your app says exactly how close at any time. See Show users where they stand.