Skip to content

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.

ActionEndpoint
Get a reply to an inbound DMPOST /chat
Fire a conversation trigger / openerPOST /triggers
Generate a public commentPOST /comments
Generate a threaded replyPOST /comments/reply
Queue follow-ups for dormant leadsPOST /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 429 looks 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 a 429 from a rate limit. Read this header; do not guess.
  • error.coderate_limited. The machine signal is the status code (429) plus error.code; treat error.message as 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 429 rate_limited (you're calling too fast — retry shortly) with 402 payment_required (the character owner's wallet can't cover the turn — retrying won't help; top up the wallet). A 429 is transient; a 402 is not. The API returns 402 before 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))
done

Python (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 /events and POST /triggers are idempotent on external_event_id (unique per owner + platform). A re-delivered webhook applies exactly once — repeats return deduped: true and change nothing. Retry these freely.
  • POST /followups/{id}/ack is terminal and idempotent — acking an already-delivered follow-up returns deduped: 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 pass session_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 /followups is a pull-queue — poll it at a steady cadence (every few minutes) rather than tight-looping. Use limit to 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 429 means 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 429 rate_limited is a speed limit (transient — retry shortly). A 402 payment_required is a wallet balance limit (retrying won't help — top up the wallet). See billing.md.

See also

FluidTalk Characters API — part of the Fluidvip ecosystem.