Skip to content

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.

ModeRuns the model?CostSame answer every time?Use it for
live (default)yesbillednoProduction.
mocknofreeyesTesting your code — bubble delivery, image attaching, error handling, retries. This is the one your CI runs.
sandboxyes, the real pipelinebilled at costnoThe 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_...", mode="mock")
typescript
const ft = new FluidTalk({ token: "ftc_...", 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_..."
json
{
  "data": {
    "character": { "name": "Ava", "id": "..." },
    "bindings": [{
      "platform": "instagram",
      "enabled": true,
      "workflow": "IG Flow",
      "comments_enabled": true,
      "engage_enabled": true,
      "max_engagements_per_post": 2,
      "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 /triggers call with an event_id that isn't in this list is a no-op that returns 200 (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 ​

handleWhat you get
test_reply3 normal text bubbles with delays
test_photoa text bubble plus a photo bubble carrying a real, downloadable image_url
test_photostwo different photo bubbles — catches media caching/dedup bugs
test_visiona reply with the vision block populated
test_ignoredignored: true, ignore_reason: "lead_claimed_by_other_account"
test_duplicateignored: true, ignore_reason: "duplicate_message"
test_sealedbubbles: [] — conversation closed, send nothing
test_402HTTP 402 payment_required
test_429HTTP 429 with a Retry-After header
test_500HTTP 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.
handleWhat you get
test_outreachOutreach node, cold — opened: true plus opener bubbles
test_outreach_warmOutreach node on a live chat — opened: false, ignored: true (the no-op)
test_trigger_coldCustom Event Trigger, cold — opened: true, and your context is echoed into the opener
test_warm_eventCustom Event Trigger on a live chat — opened: false with bubbles
test_trigger_photocold open informed by a profile screenshot — vision.seen: true
test_trigger_photo_unseenscreenshot could not be fetched — vision.seen: false, generic opener, still succeeds
test_warm_event_photovision.reason: "not_used_warm_conversation"
test_trigger_unknownignore_reason: "event_not_configured"
test_dedupeddeduped: true — the idempotency replay
test_pausedpaused: 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_..." -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_..." -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 ​

handleWhat you get
test_dedupeddeduped: 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_refWhat you get
test_anythinga normal top-level comment
test_visiona comment on a post with images — vision.seen: true
test_vision_unseenthe post's image couldn't be read — comment still returned, seen: false
test_knownthe post author is a Person she already knows from DMs — recognized_lead: true, aware.cross_surface
test_disabledthe comments_disabled branch above
test_pausedpaused: true

POST /comments/reply — every stop rule returns ok: true with reply: null:

post_refdecision / reason
test_anythingcomment_reply — a normal threaded reply
test_dmdrive_to_dm_nudge — warm enough to nudge them to DMs. A different decision, and never contains a link
test_onceskip · mode=comment_once — she comments but never replies
test_hostilebow_out · hostile/bot-accusation — post nothing, thread is BOWED_OUT
test_maxedskip · max_replies_per_thread
test_strangerskip · reply_to_strangers=off
test_disabledcomments_disabled

Branch on decision and reason, never on explain.

Every mock comment and reply with text carries the same length fields as a live one: max_chars is the max_chars you sent, lowered to the platform's ceiling (so 1000 on TikTok comes back as 150), or the default of 150 when you sent none, and truncated is always false.

POST /comments/engage ​

The third comment motion — joining a thread between other people — and the one whose normal outcome is often nothing. The fixtures make the quiet branches as reachable as the loud one, because a connector that treats an abstention as a failure will look broken in production.

post_refWhat you get
test_anythingcomment_reply — a reply to the first comment you sent, so target_comment_ref is one you can match against your own data
test_engage_skipskip · no_target_selected — the character read the thread and had no business in it. Post nothing. This is a good outcome, and it is not rare.
test_engage_hostilebow_out · hostile/bot-accusation — post nothing and stop engaging here
test_engage_deepskip · thread_too_deep — more comments than engage_max_thread_depth
test_disabledthe comments_disabled branch above

Send a real thread even in mock — the fixture reads it back, so the reply you get is threaded against your own comment_refs. GET /self-test reports engage_enabled and max_engagements_per_post per binding, so you can tell "switched off" from "abstained" before you start debugging the skips.

POST /posts ​

A post has no handle and no post_ref — the community is what it hangs off — so the fixture key is community.ref, and the same test_ rule applies to it. A mode: "mock" call naming a real subreddit is a 400, not a fixture.

This surface needs the fixtures more than the others

A refusal is the normal answer. A quiet community correctly returns no_suitable_post_found, so an integrator who has only ever seen that branch cannot tell it from a broken key, a disabled workflow, or a character in the wrong room. Every branch below is reachable on demand — including test_failed, which is the one that means we broke rather than the community being quiet.

community.refWhat you get
test_anythingpost — a full post: title, body, flair, plus the angle and the evidence it came from
test_noneno_suitable_post_found · gate community — the normal refusal. A 200, and billed: finding out cost real model work
test_personano_suitable_post_found · gate persona — the one worth reading. Ideas cleared the floors, but the character cannot write any of them honestly. Includes would_have_to_invent
test_assetno_suitable_post_found · gate asset — the image does not belong in this community; refused once it was described, before the community was read
test_asset_unseengeneration_failed with vision.seen: false — the image could not be seen, so nothing ran after it
test_failedgeneration_failed — ours, not the community's. Branch on it separately; retry is reasonable
test_disabledposts_disabled — free, nothing ran
test_pausedpaused — free, nothing ran

Send an asset with any of them and the fixture also carries the vision object a live call returns (except on test_disabled and test_paused, which return before anything runs).

Send a count from 2 to 5 and test_anything returns that many distinct posts in posts (with requested and returned), post being the first; the refusals are unchanged. A count outside 1–5 is a 400, as live. See Several posts in one call.

Send always_draft: true and the refusals also carry what a live call returns: test_none two drafts (one written under the floors, one drafted for the character and turned down on review), test_persona one honest outsider draft, and test_asset draft: null with draft_unavailable.code: "asset_rejected". See Always get a draft.

Branch on decision first, then on gate. GET /self-test reports posts_enabled per binding, so you can tell "switched off" from "quiet community" before writing any code.

The two that look alike and mean opposite things are no_suitable_post_found and generation_failed: the first says the room was read and found wanting, the second says nothing was ever evaluated. Treat the first as a signal about the community and the second as a signal about us. See the Posts guide.

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.

POST /posts/media ​

Keyed by community_ref, with the same test_ rule and the same keys as POST /posts: test_disabled returns the posts_disabled no-op and test_paused the plan_limit one, both with url: null. Any other test_ ref returns the real, published Outbound 1 sample URL and stores nothing — a URL the vision model can fetch, so it also works as asset.url on a live /posts call.

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:

ImageURLUse it for
Inbound photo/docs/samples/inbound-photo.pngPass as image_url on /chat — "the lead sent you this". Exercises vision and the vision block.
Profile card/docs/samples/profile-card.pngPass as image_url on /triggers — a profile screenshot to cold-open on.
Outbound 1/docs/samples/outbound-photo-1.pngWhat a photo bubble points at in mock. Build your download-and-attach path against it.
Outbound 2/docs/samples/outbound-photo-2.pngA second, different file — for connectors that cache or dedup media by URL.
No-hook image/docs/samples/no-hook.pngShapes 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 circle

Run 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_...", 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_...", 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 it

Measured 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" }
reasonMeaning
nullThe image was fetched and described. description is what the character "saw".
vision_failedThe 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_urlNo image was supplied.
empty_descriptionThe 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:

SituationSignal
Unconfigured / typo'd event_idignore_reason: "event_not_configured"
external_event_id already delivereddeduped: true
Outreach on a conversation that's already openopened: false, ignored: true
Character over the plan's cappaused: true
Lead claimed by another accountignore_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:

  1. Your first cold open succeeds and creates a conversation.
  2. Every later cold open for that lead is now the already in conversation no-op — outreach only ever opens.
  3. And re-sending the same external_event_id returns deduped: true regardless.

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_...", 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 pipelineIdentical to live.
BillingBilled at cost, same as any turn.
SessionMarked origin: "test" — excluded from analytics, and purgeable.
Proactive follow-upsNever queued for a test_ lead. (Each rekindle is a billed generation; abandoned test leads would quietly cost you money.)
Duplicate-message guardOff — re-send the same message as often as you like.
Trigger idempotencyOff — reuse one external_event_id.
Outreach on an existing test sessionReseals 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_...")
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_...", 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_...", 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 receive

See also ​

FluidTalk Characters API — part of the Fluidvip ecosystem.