Appearance
Testing your integration
Building a connector means writing code for cases a real character almost never hands you on demand: a photo bubble, a 402, a sealed conversation, an ignored duplicate, an outreach that opens versus one that quietly doesn't. Waiting for a live persona to produce each of those — on billed turns, against real leads — is not a test loop.
So every endpoint takes a mode.
| Mode | Runs the model? | Cost | Same answer every time? | Use it for |
|---|---|---|---|---|
live (default) | yes | billed | no | Production. |
mock | no | free | yes | Testing your code — bubble delivery, image attaching, error handling, retries. This is the one your CI runs. |
sandbox | yes, the real pipeline | billed at cost | no | The pre-flight before go-live — does she actually open well, was my photo really seen, does the funnel move. |
Set it once on the client and nothing else in your code changes:
python
ft = FluidTalk(token="ftc_live_...", mode="mock")typescript
const ft = new FluidTalk({ token: "ftc_live_...", mode: "mock" });bash
curl ... -H "X-FluidTalk-Mode: mock"Or per call, as a mode field in the request body — the body field wins over the header.
Both test modes require a test_ handle
mock and sandbox only accept a handle starting with test_. Anything else is a 400.
That is deliberate. It means a mode accidentally left switched on in production cannot answer a real lead with a fixture — the call fails loudly instead. test_ is reserved for this; don't use it for a real lead.
A typo'd mode (sandbx) is a 422, never a silent fall back to live. Guessing live for a typo would bill a turn you thought was a test.
mock still authenticates for real
It is not an offline stub. Your token is verified, the platform binding is resolved, and rate limits apply — so a bad token still 401s and an unbound platform still 404s in mock. A fixture returned on a broken token would let you "verify" auth you haven't actually got working, which is exactly the kind of false green this surface exists to remove.
What mock skips is the model call, the database writes and the charge. Nothing else.
Start here: GET /self-test
One free call, no model, no charge. Run it before you write anything else.
bash
curl https://api-talk.fluidvip.com/api/v1/characters/self-test \
-H "X-Connector-Token: ftc_live_..."json
{
"data": {
"character": { "name": "Ava", "id": "..." },
"bindings": [{
"platform": "instagram",
"enabled": true,
"workflow": "IG Flow",
"has_outreach_node": true,
"fireable_event_ids": ["story_reaction", "new_follower", "outreach"],
"entry_points": [ ... ]
}],
"photos": { "total": 12, "organized": 12, "by_tier": { "tier_1": 6, "tier_2": 4, "tier_3": 2 } },
"settings": { "ignore_duplicate_leads": false, "separate_sessions_per_account": false },
"limits": { "requests_per_min": 300, "generations_per_min_per_action": 60, "enforced": true },
"checks": [
{ "check": "token_valid", "ok": true, "detail": "Authenticated as character 'Ava'." },
{ "check": "platform_bound", "ok": true, "detail": "1 platform binding(s): instagram" },
{ "check": "triggers_fireable", "ok": true, "detail": "Fireable event_ids: story_reaction, ..." },
{ "check": "photo_vault_sendable", "ok": true, "detail": "12 photo(s) in the vault ..." }
],
"ok": true
}
}It answers the two questions that are invisible on /chat and cause most "the API doesn't work" reports:
fireable_event_ids— the only place these are discoverable. A/triggerscall with anevent_idthat isn't in this list is a no-op that returns200(see below).photo_vault_sendable— if the vault is empty or unorganized, the character will never emit a photo bubble no matter what the lead asks. Without this check that is indistinguishable from a broken integration.
Test handles pick the branch
In mock, the lead handle decides what comes back — like Stripe's test card numbers. No setup, no state, no waiting.
POST /chat
handle | What you get |
|---|---|
test_reply | 3 normal text bubbles with delays |
test_photo | a text bubble plus a photo bubble carrying a real, downloadable image_url |
test_photos | two different photo bubbles — catches media caching/dedup bugs |
test_vision | a reply with the vision block populated |
test_ignored | ignored: true, ignore_reason: "lead_claimed_by_other_account" |
test_duplicate | ignored: true, ignore_reason: "duplicate_message" |
test_sealed | bubbles: [] — conversation closed, send nothing |
test_402 | HTTP 402 payment_required |
test_429 | HTTP 429 with a Retry-After header |
test_500 | HTTP 500 internal_error |
anything else test_* | the default reply fixture |
POST /triggers — outreach and custom event triggers
Two different node types fire this endpoint, and they behave differently:
- the built-in Outreach node (
event_id: "outreach", a reserved key) — a cold open you decided to make. On a lead who already has a live conversation it is a no-op, because there is nothing to open. - a custom Event Trigger (your own
event_id) — something happened. It cold-opens when there is no chat, and folds into the chat as the next turn when there is.
handle | What you get |
|---|---|
test_outreach | Outreach node, cold — opened: true plus opener bubbles |
test_outreach_warm | Outreach node on a live chat — opened: false, ignored: true (the no-op) |
test_trigger_cold | Custom Event Trigger, cold — opened: true, and your context is echoed into the opener |
test_warm_event | Custom Event Trigger on a live chat — opened: false with bubbles |
test_trigger_photo | cold open informed by a profile screenshot — vision.seen: true |
test_trigger_photo_unseen | screenshot could not be fetched — vision.seen: false, generic opener, still succeeds |
test_warm_event_photo | vision.reason: "not_used_warm_conversation" |
test_trigger_unknown | ignore_reason: "event_not_configured" |
test_deduped | deduped: true — the idempotency replay |
test_paused | paused: true, pause_reason: "plan_limit" |
Opening on a profile screenshot
/triggers takes an image_url — a screenshot of the lead's profile, so the cold open can reference something specific instead of "hey 🙂". It works for the Outreach node and for custom Event Triggers alike, because both cold-open through the same path.
bash
# 1. no public URL for your screenshot? upload the bytes, get a FluidCloud link back
URL=$(curl -s -X POST https://api-talk.fluidvip.com/api/v1/characters/inbound-media \
-H "X-Connector-Token: ftc_live_..." -H "Content-Type: application/json" \
-d "{\"platform\":\"instagram\",\"data_b64\":\"$(base64 -w0 profile.png)\",\"filename\":\"profile.png\"}" \
| python -c "import sys,json; print(json.load(sys.stdin)['data']['url'])")
# 2. open on it
curl -X POST https://api-talk.fluidvip.com/api/v1/characters/triggers \
-H "X-Connector-Token: ftc_live_..." -H "Content-Type: application/json" \
-d "{\"platform\":\"instagram\",\"handle\":\"mark\",\"event_id\":\"outreach\",
\"external_event_id\":\"lead-42\",\"image_url\":\"$URL\"}"json
{ "data": {
"opened": true,
"bubbles": [{ "text": "okay the dog in your third pic has my whole heart", "delay_ms": 900, "image_url": null }],
"vision": { "supplied": true, "seen": true,
"description": "vet tech in Austin, three photos with a golden retriever", "reason": null }
} }Always check vision.seen
A screenshot we cannot fetch does not fail the call — you still get an opener, just a generic one. seen: false with reason: "vision_failed" means the model provider could not fetch your URL. See the Wikimedia example below.
The screenshot is used on a cold open only. If a conversation is already live you get vision.reason: "not_used_warm_conversation" — reported rather than silently dropped, because she already knows this lead.
POST /events
handle | What you get |
|---|---|
test_deduped | deduped: true — the idempotency replay |
anything else test_* | ok: true with the lifecycle flip reported but not applied |
Mock this one especially
/events is the only endpoint whose live effect you cannot undo from the API — it flips a Person's lifecycle stage. Test your purchase-webhook wiring in mock, where nothing is written.
POST /followups/sweep and GET /followups
In mock, a sweep always queues one follow-up and it is immediately pullable — no dormancy wait. Acking a mock id always succeeds. This is the whole pull → deliver → ack loop in three calls, instead of waiting days for a real lead to go quiet.
POST /comments and POST /comments/reply
Comments have no handle, so the fixture key is the post_ref — the post is what a comment conversation hangs off, the way a lead is for a DM. The same test_ rule applies to it.
Check this before you debug anything else
comments.enabled defaults to true, so a character with a bound workflow comments without you configuring anything. If someone has switched Comments off for that workflow, the endpoint fail-closes with 200 and every call returns:
json
{ "ok": true, "ignored": "comments not configured",
"ignore_reason": "comments_disabled", "comment": null,
"explain": "Comments are switched OFF for workflow 'IG Flow'… — comments are on by default, so this workflow has been set that way deliberately. Turn Comments back on in the workflow's settings; nothing you send to this endpoint will generate anything until you do." }GET /self-test reports comments_enabled per binding so you can check it before writing any code.
POST /comments — keyed by post_ref:
post_ref | What you get |
|---|---|
test_anything | a normal top-level comment |
test_vision | a comment on a post with images — vision.seen: true |
test_vision_unseen | the post's image couldn't be read — comment still returned, seen: false |
test_known | the post author is a Person she already knows from DMs — recognized_lead: true, aware.cross_surface |
test_disabled | the comments_disabled branch above |
test_paused | paused: true |
POST /comments/reply — every stop rule returns ok: true with reply: null:
post_ref | decision / reason |
|---|---|
test_anything | comment_reply — a normal threaded reply |
test_dm | drive_to_dm_nudge — warm enough to nudge them to DMs. A different decision, and never contains a link |
test_once | skip · mode=comment_once — she comments but never replies |
test_hostile | bow_out · hostile/bot-accusation — post nothing, thread is BOWED_OUT |
test_maxed | skip · max_replies_per_thread |
test_stranger | skip · reply_to_strangers=off |
test_disabled | comments_disabled |
Branch on decision and reason, never on explain.
Did she see the post's image?
POST /comments carries the same vision block as /chat, when you send post.image_urls:
json
"vision": { "supplied": 2, "seen": true,
"description": "a beach at sunset with two people silhouetted", "reason": null }supplied is how many URLs you sent (up to 4 reach the model), so you can tell "you sent none" from "we couldn't read the ones you sent". A post image we can't fetch does not block the comment — she riffs on the caption alone and you get a blander comment with no error. seen: false is the only signal.
POST /inbound-media
In mock it returns a sample URL and stores nothing. Use it to check your upload wiring without filling the character owner's storage with test files.
Testing the photo path
This is the one you cannot reach on demand in live mode: whether she sends a photo depends on the strategist choosing a photo move and a vault photo surviving the tier, daypart, set and already-sent gates.
python
reply = ft.chat(platform="instagram", handle="test_photo", message="hey")
for b in reply.bubbles:
if b.image_url:
send_photo("test_photo", b.image_url) # ← the path you actually need to test
else:
send_dm("test_photo", b.text)A photo bubble's text is empty
{"text": "", "delay_ms": 1600, "image_url": "https://..."} — send it as a photo, not as a caption on the previous message. The test_photo fixture returns exactly this shape so you find out now rather than in production.
Test images you can use right now
You need media to build photo handling, in both directions, before you have any of your own. These are published with the docs — real files, publicly downloadable, stable URLs:
| Image | URL | Use it for |
|---|---|---|
| Inbound photo | /docs/samples/inbound-photo.png | Pass as image_url on /chat — "the lead sent you this". Exercises vision and the vision block. |
| Profile card | /docs/samples/profile-card.png | Pass as image_url on /triggers — a profile screenshot to cold-open on. |
| Outbound 1 | /docs/samples/outbound-photo-1.png | What a photo bubble points at in mock. Build your download-and-attach path against it. |
| Outbound 2 | /docs/samples/outbound-photo-2.png | A second, different file — for connectors that cache or dedup media by URL. |
| No-hook image | /docs/samples/no-hook.png | Shapes only. On /triggers it returns vision.reason: "no_usable_hook" — the fallback branch. |
GET /self-test returns these URLs too, under testing.sample_images, so you never have to look them up.
Each image says its own name inside the picture
vision.description for the inbound sample reliably mentions an orange circle and the words "inbound photo" — so you can assert on it in a test instead of eyeballing it, and a wrong-image bug is obvious at a glance.
Receiving a photo (lead → character)
python
r = ft.chat(platform="instagram", handle="test_vision",
message="what do you think?",
image_url="https://talk.fluidvip.com/docs/samples/inbound-photo.png")
assert r.vision.seen is True
print(r.vision.description) # -> mentions an orange circleRun it in sandbox for a real vision call, or in mock for a canned vision block.
Sending a photo (character → lead)
python
mock = FluidTalk(token="ftc_live_...", mode="mock")
reply = mock.chat(platform="instagram", handle="test_photo", message="hey")
for b in reply.bubbles:
if b.image_url:
data = requests.get(b.image_url).content # a real, downloadable file
send_photo("test_photo", data) # ← the path you need to test
else:
send_dm("test_photo", b.text)Use handle="test_photos" to get two different images in one reply — one sample will not catch a connector that caches or dedups by URL.
Cold-opening on the sample profile
python
sandbox = FluidTalk(token="ftc_live_...", mode="sandbox")
r = sandbox.trigger(platform="instagram", handle="test_lead", event_id="outreach",
external_event_id="lead-42",
image_url="https://talk.fluidvip.com/docs/samples/profile-card.png")
print(r.vision.description) # the hook she found
print([b.text for b in r.bubbles]) # the opener built on itMeasured against the sample card, which reads "vet tech in Austin / mum to two golden retrievers / I run marathons badly":
vision : "a vet tech in Austin with two golden retrievers and marathon running"
opener : "hey, those two golden retrievers look like a handful lol"The images are generated by
docs/api/scripts/make_samples.py, committed alongside the docs. They are fixtures, not stock photography — deliberately synthetic so their descriptions stay stable.
Did she actually see my photo?
When you pass an image_url, the reply carries a vision block. This is returned in live mode too — it is not a testing-only field.
python
reply = ft.chat(platform="instagram", handle="mark",
message="what do you think? 😏",
image_url="https://your-cdn.example.com/inbound/mark-42.jpg")
reply.vision
# { "supplied": true, "seen": true, "description": "a grey cat on a windowsill", "reason": null }
# { "supplied": true, "seen": false, "description": null, "reason": "vision_failed" }reason | Meaning |
|---|---|
null | The image was fetched and described. description is what the character "saw". |
vision_failed | The image could not be fetched or described. Almost always an image_url that isn't publicly reachable — a signed or expired platform CDN link, or something behind auth. |
no_url | No image was supplied. |
empty_description | The image was fetched but nothing usable came back. |
Check seen, don't assume it
A photo that could not be fetched does not fail the turn. The character still replies — just blandly, reacting to a photo she never saw. Without this block that is invisible: you get slightly worse conversations and no error anywhere.
If you see vision_failed, your URL isn't publicly fetchable. Upload the bytes to POST /inbound-media and pass the URL it returns.
Remember that we do not fetch the image — the model provider does. A link that works from your server, or that you can open in your own browser, may still fail here.
That is not a hypothetical. A public Wikimedia image URL — one that renders fine in any browser — comes back seen: false, because Wikimedia rejects the provider's fetcher:
json
{ "supplied": true, "seen": false, "description": null, "reason": "vision_failed" }and the character still replied, with a perfectly plausible "oh wow... that's a choice" that gives no hint she saw nothing. The same image uploaded through POST /inbound-media first came back:
json
{ "supplied": true, "seen": true,
"description": "A red circle on a white background, resembling the Japanese flag, with a neutral vibe.",
"reason": null }If in doubt, upload the bytes. A URL you control is the only kind you can be confident about — and vision.seen is how you find out either way.
Why my trigger did nothing
/triggers is fail-closed: an event_id that isn't configured fires nothing and returns 200. Six different outcomes return 200 with no bubbles, so every one of them now carries an explain string saying what happened and what to do.
json
{
"data": {
"ok": true,
"ignored": "event not configured",
"ignore_reason": "event_not_configured",
"bubbles": [],
"explain": "No entry point with event_id 'story_reactionn' exists on workflow 'IG Flow' (character 'Ava', platform 'instagram'). Fireable event ids: story_reaction, new_follower, outreach."
}
}The 200-with-nothing outcomes, all of which carry explain:
| Situation | Signal |
|---|---|
Unconfigured / typo'd event_id | ignore_reason: "event_not_configured" |
external_event_id already delivered | deduped: true |
| Outreach on a conversation that's already open | opened: false, ignored: true |
| Character over the plan's cap | paused: true |
| Lead claimed by another account | ignore_reason: "lead_claimed_by_other_account" |
Sealed conversation (/chat) | bubbles: [] |
explain is prose for a human reading logs. Branch your code on ignore_reason / deduped / opened, never on the explain text — its wording will change.
Testing outreach for real
Outreach is the hardest path in this API to exercise live, and it's worth knowing exactly why:
- Your first cold open succeeds and creates a conversation.
- Every later cold open for that lead is now the
already in conversationno-op — outreach only ever opens. - And re-sending the same
external_event_idreturnsdeduped: trueregardless.
So live, testing outreach works exactly once. Both behaviours are correct — they're what stops a retry from cold-opening the same person twice — but they make iteration impossible.
sandbox relaxes both, for its own test sessions only:
python
ft = FluidTalk(token="ftc_live_...", mode="sandbox")
for i in range(3):
r = ft.trigger(platform="instagram", handle="test_out",
event_id="outreach", external_event_id="same-key-every-time")
assert r.opened # a real, fresh cold open every time
print(r.bubbles)Each call runs the real opener with the real model against the real workflow, and bills at cost. Nothing about a live conversation is affected: the reseal only ever touches a session sandbox mode opened itself.
What sandbox mode does and doesn't change
sandbox | |
|---|---|
| The model, the workflow, the pipeline | Identical to live. |
| Billing | Billed at cost, same as any turn. |
| Session | Marked origin: "test" — excluded from analytics, and purgeable. |
| Proactive follow-ups | Never queued for a test_ lead. (Each rekindle is a billed generation; abandoned test leads would cost you money for 90 days.) |
| Duplicate-message guard | Off — re-send the same message as often as you like. |
| Trigger idempotency | Off — reuse one external_event_id. |
| Outreach on an existing test session | Reseals it first, so the cold open happens again. |
Everything else — rate limits, plan caps, dedup, the 402 when the wallet can't cover a turn — behaves exactly as it does live.
A worked example
python
from fluidtalk import FluidTalk
# 1. Wiring check — free, no model.
ft = FluidTalk(token="ftc_live_...")
st = ft.self_test()
assert st.ok, [c for c in st.checks if not c.ok]
# 2. Your code, against fixtures. Free, instant, deterministic — this is your CI.
mock = FluidTalk(token="ftc_live_...", mode="mock")
reply = mock.chat(platform="instagram", handle="test_photo", message="hey")
deliver(reply.bubbles) # exercises the photo-attach path (real file)
two = mock.chat(platform="instagram", handle="test_photos", message="hey")
deliver(two.bubbles) # two DIFFERENT images -> catches media dedup bugs
seen = mock.chat(platform="instagram", handle="test_vision", message="look",
image_url="https://talk.fluidvip.com/docs/samples/inbound-photo.png")
assert seen.vision.seen is True # exercises your inbound-photo path
shot = mock.trigger(platform="instagram", handle="test_trigger_photo", event_id="outreach",
external_event_id="e1",
image_url="https://talk.fluidvip.com/docs/samples/profile-card.png")
assert shot.opened and shot.vision.seen # exercises outreach-with-a-screenshot
sealed = mock.chat(platform="instagram", handle="test_sealed", message="hey")
assert sealed.bubbles == [] # exercises "send nothing"
try:
mock.chat(platform="instagram", handle="test_402", message="hey")
except PaymentRequiredError:
pass # exercises your top-up handling
# 3. One real end-to-end run before go-live. Billed at cost.
sandbox = FluidTalk(token="ftc_live_...", mode="sandbox")
r = sandbox.trigger(platform="instagram", handle="test_me",
event_id="outreach", external_event_id="preflight-1")
print(r.bubbles) # what a real lead would actually receiveSee also
- Sending & receiving DMs — the inbound loop and
image_url. - Triggers & openers — entry points and the reserved
outreachkey. - Media — uploading bytes when you have no public URL.
- Errors — the full error model.
- Billing — what a turn costs and what a
402means.