Skip to content

API Reference — Chat

Push an inbound DM from a lead to a character and get back the character's reply. This is the workhorse of the Characters API: a lead messaged one of your bot accounts on a platform, you forward that message here, and FluidTalk returns the character's reply as an ordered list of human-like chat bubbles for your connector to send.

The endpoint is under the production base URL:

https://api-talk.fluidvip.com/api/v1/characters

This endpoint generates the character's reply and advances the relationship — it does not send anything. Reading and sending DMs on Instagram (or any platform) is your connector's job; how you connect to the platform is your concern. To report that money moved, see Events; to start or steer a conversation from a platform event, see Triggers.

Authentication. Every request carries your per-character connector token in the X-Connector-Token header. The token is the character — there is no account-level key and no character_id field. You name the platform in the body, and the same token works across every platform the character runs on (the platform selects the bound workflow). See Authentication.

Response envelope. Like every endpoint, /chat wraps its result in { "data": <result>, "request_id": "req_..." } and echoes the same id in the X-Request-Id header. The shapes below are the inner data. On failure you get { "error": { "code", "message", "type", "request_id" } } instead — see Errors.


POST /chat

The lead sent the character a DM — get the character's reply.

Request body

NameTypeRequiredDescription
platformstringYesThe platform this DM arrived on, e.g. instagram. Selects the character's bound workflow for that platform.
handlestringYesThe lead's @username on that platform. Together with platform, this identifies the lead — re-using the same pair resumes that lead's conversation.
messagestringNoThe text the lead sent. Send message, image_url, or both (an image-only DM may omit message).
image_urlstringNoPublicly-fetchable URL of an image the lead sent. The character "sees" it and may react to it. Only have the bytes? Upload them first — see Media.
session_idstringNoResume a specific conversation thread. Omit to auto-resume (or create) the session for this (character, platform, handle) — you rarely need to pass it.
own_usernamestringNoThe bot account that received this DM. Pass it to enable multi-account dedup. See Multi-account dedup.

At-most-once inbound. Unlike Events and Triggers, /chat has no idempotency key — a re-sent inbound creates a new turn. Ensure at-most-once delivery of each inbound DM, or pin re-deliveries to one conversation by passing a stable session_id. As a backstop, the same message repeated on one conversation is suppressed after the second delivery — but that is a safety net, not a substitute for delivering each DM once.

Response — ChatReply

FieldTypeDescription
session_idstring | nullThe conversation thread this turn belongs to. Persist it to pin later turns to the same session; otherwise it auto-resumes by (platform, handle). null when an inbound was ignored by dedup.
bubblesarrayThe character's reply as an ordered list of Bubble objects (below). Send them in order. [] when the conversation is closed or the inbound was ignored.
billingobjectPresent when billing is enabled — the metered cost of generating this turn. See the billing object and Billing.
ignoredbooleanPresent and true only when multi-account dedup suppressed this inbound (see Ignored by dedup).
ignore_reasonstringWhy the inbound was ignored, e.g. lead_claimed_by_other_account. Only present alongside ignored.
pausedbooleanPresent and true only when the character is paused (see Paused characters). bubbles is [] — send nothing.
pause_reasonstringWhy the character is paused, e.g. plan_limit. Only present alongside paused.

The Bubble object

A reply is a list of short messages with delays, not one blob — send each bubble in order, waiting delay_ms before each, to mimic a human typing cadence.

FieldTypeDescription
textstringThe bubble's message text to send on the platform.
delay_msintegerHow long to wait before sending this bubble, in milliseconds. The first bubble is usually 0.
image_urlstring | nullA photo the character is sending with this bubble (from its photo vault), or null for a text-only bubble.

The billing object

Running a character costs LLM tokens. When billing is enabled, the response includes the metered cost of this turn (charged to the character owner's wallet).

FieldTypeDescription
tokensnumberWhat this turn cost you = real cost × your plan's rate (1 token = $1, e.g. 0.00127932).
tokens_usednumberThe raw model cost of this turn, in tokens (the real dollars spent, before your plan's rate).
balance_afternumberThe owner's wallet token balance after this charge.
collectedbooleantrue if the charge was applied to the wallet.

A normal reply, with billing, looks like this (full envelope):

json
{
  "data": {
    "session_id": "sess_3f9a2b7c1d",
    "bubbles": [
      { "text": "heyy mark 🙈", "delay_ms": 0, "image_url": null },
      { "text": "you caught me right before bed", "delay_ms": 2400, "image_url": null },
      { "text": "what are you still doing up?", "delay_ms": 1800, "image_url": null }
    ],
    "billing": {
      "tokens": 0.00127932,
      "tokens_used": 0.00021322,
      "balance_after": 48.171823,
      "collected": true
    }
  },
  "request_id": "req_7c0d2e1f8a"
}

Ignored by multi-account dedup

If the character runs several accounts on one platform with dedup enabled, the first account a lead messaged claims that lead; an inbound from a different account is ignored so only one account ever replies. When that happens, send nothing — session_id is null, bubbles is empty, and ignored is true:

json
{
  "data": {
    "session_id": null,
    "bubbles": [],
    "ignored": true,
    "ignore_reason": "lead_claimed_by_other_account"
  },
  "request_id": "req_4b1a9d7e22"
}

You opt in by passing own_username (the bot account that received the DM). Omit it and dedup is off — every inbound is answered normally. See Multi-account dedup.

Repeated messages are suppressed

If the same message text arrives on the same conversation over and over, only the first two are answered. The third and every later identical delivery is ignored — no reply is generated, and no model call is made, so the turn is not billed:

json
{
  "data": {
    "session_id": "sess_3f9a2b7c1d",
    "bubbles": [],
    "ignored": true,
    "ignore_reason": "duplicate_message"
  },
  "request_id": "req_2c8e01ffa4"
}

This exists because a connector that re-delivers a DM instead of marking it processed would otherwise turn one message into an unbounded run of billable turns — and the character, seeing what looks like a lead repeating themselves, answers every copy.

The rule is deliberately narrow:

  • It suppresses the repeated text, not the lead. Any different message resets the counter and the conversation continues as normal — a real person nudging twice is answered both times, and one new word is enough to be heard again.
  • The attached image_url is part of the comparison, so the same caption on a new photo is a new message.
  • The counter is per conversation. One looping lead never silences another.

Handle it exactly like dedup: ignored is true, so send nothing. If you are seeing it, your connector is re-delivering — check that inbound DMs are marked processed.

A closed conversation

If the conversation has already ended (for example, the lead converted and the character handed off), the turn is accepted but there is nothing to say — bubbles is []:

json
{
  "data": {
    "session_id": "sess_3f9a2b7c1d",
    "bubbles": []
  },
  "request_id": "req_91fbca0d3e"
}

A paused character

A character can be paused — for example, when the account holds more characters than its current plan allows (after a downgrade), the newest characters over the limit are paused until the plan is upgraded again. Nothing is deleted. A paused character accepts the request but replies with nothing: session_id is null, bubbles is [], and paused is true. Send nothing.

json
{
  "data": {
    "session_id": null,
    "bubbles": [],
    "paused": true,
    "pause_reason": "plan_limit"
  },
  "request_id": "req_5d2a0f19bc"
}

Treat paused exactly like a closed conversation: don't send a message, and try again later (the character resumes automatically once the plan is upgraded). The same paused / pause_reason pair appears on Triggers and Comments, and a paused character returns no Follow-ups.

Examples

bash
curl -X POST https://api-talk.fluidvip.com/api/v1/characters/chat \
  -H "X-Connector-Token: ftc_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "instagram",
    "handle": "mark",
    "message": "hey, loved your latest post 😍"
  }'
python
from fluidtalk import FluidTalk

ft = FluidTalk(token="ftc_live_...")

reply = ft.chat(
    platform="instagram",
    handle="mark",
    message="hey, loved your latest post 😍",
    # own_username="ava_official",  # enable multi-account dedup
)

if getattr(reply, "ignored", False):
    pass  # another account owns this lead — send nothing
else:
    for bubble in reply.bubbles:  # bubbles is [] for a closed conversation
        # wait bubble.delay_ms, then send bubble.text (+ bubble.image_url, if set)
        send_on_instagram("mark", bubble.text, bubble.image_url, after_ms=bubble.delay_ms)
typescript
import { FluidTalk } from "fluidtalk";

const ft = new FluidTalk({ token: "ftc_live_..." });

const reply = await ft.chat({
  platform: "instagram",
  handle: "mark",
  message: "hey, loved your latest post 😍",
  // ownUsername: "ava_official", // enable multi-account dedup
});

if (reply.ignored) {
  // another account owns this lead — send nothing
} else {
  for (const bubble of reply.bubbles) {
    // wait bubble.delayMs, then send bubble.text (+ bubble.imageUrl, if set)
    await sendOnInstagram("mark", bubble.text, bubble.imageUrl, bubble.delayMs);
  }
}

Errors

Statuserror.codeWhen
400invalid_requestThe body is malformed or missing a required field (platform / handle).
401invalid_tokenMissing, invalid, or revoked X-Connector-Token.
402payment_requiredBilling is enabled and the owner's wallet can't cover this turn. Returned before any model call, so you are never charged for a refused turn — top up the wallet in the dashboard, then retry.
403forbiddenThe token isn't allowed to run this character on this platform.
404not_foundNo workflow could be resolved for this platform on the character.
422validation_errorThe body is well-formed JSON but a field failed validation (e.g. an empty handle).
429rate_limitedToo many requests; honor the Retry-After header, then retry.
500internal_errorSomething went wrong on our side; retry with backoff.

The SDKs raise typed errors for these — PaymentRequiredError (402), AuthError (401), ValidationError (422), RateLimitError (429), and ApiError for the rest. Catch PaymentRequiredError to surface an insufficient-balance prompt to the operator. See the full model in Errors and per-language details in Python and TypeScript.


  • Media — upload a lead-sent image's bytes to get a URL for image_url.
  • Events — report purchases / refunds that flip the lead's lifecycle.
  • Triggers — open or steer a conversation from a platform event.
  • Follow-ups — pull proactive re-engagement messages for dormant leads.
  • Multi-account dedup — run several accounts on one platform with own_username.
  • Billing — metered usage and handling 402 payment_required.
  • Concepts — characters, sessions, people, and cross-surface awareness.
  • API Reference index

FluidTalk Characters API — part of the Fluidvip ecosystem.