Appearance
Rate limits
The FluidTalk Characters API enforces rate limits so a single connector cannot exhaust shared capacity. Limits are applied per connector token — and because the token is the character, that means per character. When you exceed a limit, the API returns 429 Too Many Requests with error code rate_limited and a Retry-After header telling you how many seconds to wait before retrying.
This page explains the limit model that applies to a connector token, how to read Retry-After and back off correctly, and why retrying /events and /triggers is always safe.
The limit model
Limits are tracked per connector token. There are two kinds, and they apply at the same time.
1. Overall per-token request cap
Every request you make — across all endpoints — counts toward a single budget for your token. This bounds your overall call volume regardless of which endpoints you hit.
2. Tighter caps on generation endpoints
The endpoints that generate a character's output incur real upstream model cost, so they may carry their own, tighter caps. These are counted independently of the overall cap and of each other.
| Action | Endpoint |
|---|---|
| Get a reply to an inbound DM | POST /chat |
| Fire a conversation trigger / opener | POST /triggers |
| Generate a public comment | POST /comments |
| Generate a threaded reply | POST /comments/reply |
| Queue follow-ups for dormant leads | POST /followups/sweep |
The lighter endpoints — reporting money moved (POST /events), pulling and acking follow-ups (GET /followups, POST /followups/{id}/ack) — do not run the model and are bounded only by the overall per-token cap.
A request can be rejected by either the overall cap or a relevant per-action cap — whichever you hit first. A
429looks the same in both cases.
Design for the signal, not a number
Exact ceilings are operational and may change; this page does not promise specific values. Build your client to react to a 429 and its Retry-After header rather than to a fixed number — that way you stay correct no matter how the caps are tuned.
What a 429 looks like
A rate-limited response carries the standard error envelope, a Retry-After header, and an X-Request-Id header (the same value as error.request_id, for support and log correlation).
http
HTTP/1.1 429 Too Many Requests
Retry-After: 17
X-Request-Id: req_8a2f1c9d4e
Content-Type: application/json
{
"error": {
"code": "rate_limited",
"message": "Too many requests — please slow down and try again shortly.",
"type": "client_error",
"request_id": "req_8a2f1c9d4e"
}
}Retry-After— seconds to wait before the request will be accepted again. Always present on a429from a rate limit. Read this header; do not guess.error.code—rate_limited. The machine signal is the status code (429) pluserror.code; treaterror.messageas human-readable text that may change.X-Request-Id— quote this if you need to ask support about a specific call.
See errors.md for the full error model and the complete list of status codes.
How the SDKs surface a 429
Both SDKs raise a typed RateLimitError for a 429. The retry delay from Retry-After is exposed on the error so you can back off without parsing headers yourself.
Don't confuse
429rate_limited(you're calling too fast — retry shortly) with402payment_required(the character owner's wallet can't cover the turn — retrying won't help; top up the wallet). A429is transient; a402is not. The API returns402before any model call, so you are never charged for a refused turn. See billing.md.
Backing off correctly
The right behavior is: on a 429, sleep for Retry-After seconds, then retry. Add a small jitter and cap the number of retries so a sustained limit doesn't loop forever. A single wait of Retry-After is usually enough to clear.
curl
curl won't honor Retry-After for you, but you can read it and loop in a shell wrapper:
bash
# Honor Retry-After manually
attempt=0
until [ "$attempt" -ge 5 ]; do
code=$(curl -s -o /tmp/body -w '%{http_code}' -D /tmp/headers \
-X POST https://api-talk.fluidvip.com/api/v1/characters/chat \
-H "X-Connector-Token: ftc_live_8f3c..." \
-H "Content-Type: application/json" \
-d '{"platform":"instagram","handle":"mark","message":"hey ava"}')
if [ "$code" != "429" ]; then
cat /tmp/body
break
fi
wait=$(grep -i '^retry-after:' /tmp/headers | tr -d '\r' | awk '{print $2}')
echo "rate limited, sleeping ${wait:-5}s" >&2
sleep "${wait:-5}"
attempt=$((attempt + 1))
donePython (fluidtalk)
The RateLimitError raised for a 429 carries the parsed Retry-After value:
python
import time
from fluidtalk import FluidTalk, RateLimitError
ft = FluidTalk(token="ftc_live_8f3c...")
def with_backoff(call, *, max_attempts=5):
"""Run an API call, honoring Retry-After on 429."""
for attempt in range(max_attempts):
try:
return call()
except RateLimitError as exc:
if attempt == max_attempts - 1:
raise
# retry_after is the Retry-After header in seconds; fall back to a small default
delay = getattr(exc, "retry_after", None) or 5
time.sleep(delay)
raise RuntimeError("exhausted retries")
reply = with_backoff(
lambda: ft.chat(platform="instagram", handle="mark", message="hey ava")
)
for bubble in reply["bubbles"]:
print(bubble["text"])TypeScript (fluidtalk)
typescript
import { FluidTalk, RateLimitError } from "fluidtalk";
const ft = new FluidTalk({ token: "ftc_live_8f3c..." });
const sleep = (s: number) => new Promise((r) => setTimeout(r, s * 1000));
async function withBackoff<T>(call: () => Promise<T>, maxAttempts = 5): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await call();
} catch (err) {
if (!(err instanceof RateLimitError) || attempt === maxAttempts - 1) {
throw err;
}
// retryAfter is the Retry-After header in seconds
const delay = err.retryAfter ?? 5;
await sleep(delay);
}
}
throw new Error("exhausted retries");
}
const reply = await withBackoff(() =>
ft.chat({ platform: "instagram", handle: "mark", message: "hey ava" }),
);
reply.bubbles.forEach((b) => console.log(b.text));Idempotency makes retries safe
Two endpoints carry an idempotency key, so retrying them after a 429 (or any network hiccup) can never double-apply:
POST /eventsandPOST /triggersare idempotent onexternal_event_id(unique per owner + platform). A re-delivered webhook applies exactly once — repeats returndeduped: trueand change nothing. Retry these freely.POST /followups/{id}/ackis terminal and idempotent — acking an already-delivered follow-up returnsdeduped: true.
POST /chat has no message id, so a re-sent inbound creates a new turn (and a new metered charge). After a 429 the request was rejected before any work, so retrying that same call is safe; but to avoid duplicate turns from your own retries elsewhere, ensure at-most-once delivery of inbound DMs, or pass a stable session_id to keep them in one conversation.
POST /comments and POST /comments/reply likewise have no idempotency key and generate fresh text on each successful call — only retry them when you know the previous attempt did not succeed (a 429 is exactly that case: it was rejected before any model call). See errors.md for which statuses are safe to retry.
Staying under the limits
- Resume, don't re-create. Re-using the same
(platform, handle)resumes that lead's conversation automatically — you rarely need to passsession_id, and you should not re-fire openers you've already sent. See concepts.md. - Pace your bursts. There is no bulk endpoint; spread a flood of inbound DMs, triggers, or comments over time rather than firing them all at once.
- Pull follow-ups on a schedule.
GET /followupsis a pull-queue — poll it at a steady cadence (every few minutes) rather than tight-looping. Uselimitto drain a backlog in one call. - Always honor
Retry-After. A fixed wait of the header value clears the window. Retrying immediately just burns another rejected request. - Cap your retries. A persistent
429means your steady-state call rate is too high — slow the caller down rather than looping forever.
Notes and limitations
- Limits are tracked per connector token. Because the token is the character, each character has its own independent budget — a busy character never starves another. The same token covers all of that character's platforms.
- Tokens are issued from the character's platform settings in the dashboard, not via the API. The plaintext token is shown once; treat it like a password.
- Rate limiting and billing are different systems. A
429rate_limitedis a speed limit (transient — retry shortly). A402payment_requiredis a wallet balance limit (retrying won't help — top up the wallet). See billing.md.
See also
- errors.md — full status-code and error-envelope reference
- billing.md — metered usage and handling
402 payment_required - reference/chat.md — getting a character's reply (
POST /chat) - reference/events.md · reference/triggers.md — idempotent endpoints (
external_event_id) - sdks/python.md · sdks/typescript.md — typed errors and SDK setup