Rate limits
Token-bucket limits protect public intake, booking, referral, chat and AI surfaces, while campaigns have configurable delivery pacing.
Rate-limited surfaces use a token bucket: you get a burst capacity that refills continuously. When the bucket is empty the API returns 429 with a Retry-After header (seconds).
Abuse screen for public endpoints
Every unauthenticated endpoint — capture, website tracking, booking, referral, chat widget, unsubscribe — first passes a coarse screen of 300 requests per minute, counted separately per client IP address and per surface. A request that arrives without a client IP (an internal or same-zone call) is allowed through this screen rather than being pooled into a shared bucket, so one integration can never exhaust a global allowance for everyone else. Public SEO media requests have their own screen of 600 requests per minute per client IP.
Per-surface buckets
Past the abuse screen, each resolved surface has its own exact bucket:
| Surface | Scope | Burst | Refill |
|---|---|---|---|
POST /capture/{token} | per form endpoint | 200 requests | 5 / second |
Website tracking — form discovery (POST /site/{siteKey}/forms) | per site | 30 requests | 0.5 / second |
| Booking — page config | per practice | 120 requests | 4 / second |
| Booking — availability | per practice | 120 requests | 2 / second |
| Booking — create hold | per practice | 60 requests | 1 / second |
| Booking — complete | per practice (its own bucket) | 60 requests | 1 / second |
| Referral — page config | per practice | 120 requests | 4 / second |
| Referral — submit | per practice | 60 requests | 1 / second |
| Referral — status | per practice | 120 requests | 2 / second |
| Chat widget — config | per widget connection | 120 requests | 4 / second |
| Chat widget — thread reads | per widget connection | 600 requests | 10 / second |
| Chat widget — send message | per widget connection | 300 requests | 5 / second |
| Chat widget — WebSocket | per widget connection | 600 requests | 10 / second |
AI assist (…/ai/suggest, …/ai/summarize) | per organization | 60 requests | 6 / minute |
| Campaign test sends | per sender, per campaign | 20 requests | 20 / hour |
| Support chat messages | per user | 30 messages | 1 / second |
OAuth connections (Authorization: Bearer sl_oat_…) | per connection | 300 requests | 5 / second |
Booking holds and completes draw from separate buckets, so a burst of new visitors can never starve patients finishing an existing booking.
OAuth endpoints
The unauthenticated OAuth endpoints — POST /auth/oauth2/token, /auth/oauth2/revoke and /auth/oauth2/introspect — authenticate a registered client rather than a session, so they carry their own coarse screen of 300 requests per minute, counted per client address block (an IPv4 /24 or an IPv6 /64) rather than per literal address. A request with no usable client address is refused outright on these surfaces: they are where a stolen client secret or refresh token would be ground against, so "no address to count" must not mean "no limit". Rejections answer 429 with Retry-After: 60.
A connection's own bucket is keyed on the connection, not on the individual access token, so refreshing a token does not reset it — the ceiling is what it says it is across the whole connection's life.
A bearer we have not seen recently also passes the 300-per-minute address-block screen before the token is looked up at all; one that has already resolved at that edge location within the hour skips it. That is deliberate: integration platforms send from a small fixed set of addresses, so charging every request to an address block would let one practice's busy Zaps throttle another's. Token scanning still pays the screen, because a token that has never resolved is never recognised. See OAuth connections.
Inbound email is not an HTTP surface but has its own protection: a platform-wide brake of 2,000 messages per minute, plus the per-address fair-use quota described in Channels.
Campaign hourly and daily pacing is selected when the campaign is launched. A pacing delay is background delivery behavior rather than an HTTP 429 response.
AI assist responses use code: "RATE_LIMITED" in the error envelope.
Handling 429s
Respect Retry-After and back off:
const res = await fetch(url, options);
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? "5");
await new Promise((r) => setTimeout(r, wait * 1000));
// retry once
}Other authenticated endpoints may gain limits over time. Do not design an integration that hammers them; handle the standard 429 error envelope and back off.