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:

LayerLimitPurpose
Per-visitor20 messages / minuteStops one person spamming a single bot's chat
Per-bot, hourly300 messages / hourBlast-radius backstop if the domain check is bypassed — a non-browser client can forge the request entirely
Per-bot, daily1,000 messages / dayApproximates 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:

  1. ownerProfile.credits <= 0 → refuse immediately with a static polite message
  2. 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
  3. After streaming completes, the actual cost is calculated from real token counts and deducted, with a credit_transactions row 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.