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_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 /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 screenshotvision.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_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

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 imagesvision.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.

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:

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_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 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_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 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 cost you money for 90 days.)
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_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 receive

See also

FluidTalk Characters API — part of the Fluidvip ecosystem.