Abuse Protection & Rate Limits
/api/widget/chat is a public, unauthenticated endpoint by necessity — anonymous website visitors have no login. That means none of the usual per-user auth checks apply, so Deskly layers rate limits and a real balance check instead.
Three rate-limit layers
All three run, in order, before any AI call happens:
| Layer | Limit | Purpose |
|---|---|---|
| Per-visitor | 20 messages / minute | Stops one person spamming a single bot's chat |
| Per-bot, hourly | 300 messages / hour | Blast-radius backstop if the domain check is bypassed — a non-browser client can forge the request entirely |
| Per-bot, daily | 1,000 messages / day | Approximates a credit budget by message count, so one bot can't silently burn a multi-bot account's entire balance in a single day |
These are in-memory sliding-window limits keyed by visitor ID or bot ID (widget visitors have no Supabase user ID to key against).
The daily cap is a message-count approximation, not an exact cost limit — the real money boundary is the owner's credit balance check below, which uses actual estimated and actual token cost.
The real boundary: owner credit balance
Every widget bill lands on the bot owner's account, never the anonymous visitor's. Before generating a response, the route checks:
ownerProfile.credits <= 0→ refuse immediately with a static polite message- After loading history and building the RAG-augmented prompt,
estimateCreditCost(...)runs before calling the model — if the estimate exceeds the owner's remaining balance, the request is refused before any tokens are spent - After streaming completes, the actual cost is calculated from real token counts and deducted, with a
credit_transactionsrow recorded for auditability
Graceful degradation
When credits run out, the widget doesn't show a broken UI or an error — it streams back a fixed, polite message:
"Sorry, this assistant is temporarily unavailable. Please try again later or contact us directly."
The visitor never sees an HTTP error state; from their side it just looks like a slow, minimal reply.
Owner notification
The first time a request is refused for insufficient credits, sendCreditsExhaustedNotification emails the bot owner (if RESEND_API_KEY is configured and the owner has an email on file) so they know to top up. This call happens inline in the request path rather than on a separate debounce timer — in high-traffic scenarios you may want to add your own debouncing before going to production with a high-volume bot.
What this does not protect against
Domain/origin checking (covered in Embeddable Widget) is a best-effort deterrent against casual snippet copying, not a security boundary — a scripted client can call /api/widget/chat directly with any valid publicKey. The rate limits and credit balance check above are what actually cap the financial exposure of that scenario; they're designed to hold even if the domain check is bypassed entirely.