Skip to main content
This page is for the team building the connector — the Hermes, OpenClaw, or custom runtime that receives escalated Flowyte conversations and talks back and forth with the customer. It’s the technical companion to AI Harnesses, which covers the operator-side setup. The flow: Flowyte opens a durable escalation session, notifies you with a signed webhook, and you claim the session and drive it over REST — read the context, post replies (which reach the customer in their original thread), and finally resolve or return control to the AI. Flowyte owns the conversation record; your connector is a participant, never the source of truth. You can also drive that loop from the other end: §5 covers POST /escalations, which starts an SMS thread with a customer and hands you the session already owned. New to this? Connect your AI harness walks the whole setup once, end to end; this page is the reference you keep open while building.

1. Authenticate with a scoped key

Every REST call uses a scoped secret key (flowyte_sk_…), minted by the operator for the destination and shown once:
The key’s scopes gate exactly what the connector may do — request only what you need. These scopes are separate from escalation_destinations:*: a connector key works conversations but can’t edit destinations. Keys are organization- and destination-scoped: a key only ever sees its own tenant’s sessions. Cross-tenant calls fail closed as 404.
Never expose local file paths, prompts, model names, or secrets in a reply, a resolve note, or a shipped log — the customer sees message text verbatim.

2. Verify the signature first

Flowyte delivers webhooks to your endpoint_url over its signed pipeline. The body is a stable envelope — route on the channel field in every notification:

Signature headers

Verification recipe

1

Check the timestamp

Reject if |now − Flowyte-Timestamp| > 300s (replay window).
2

Recompute the signature

Compute hex(HMAC_SHA256(secret, Flowyte-Delivery + "." + Flowyte-Timestamp + "." + rawBody)) using the raw request bytes — re-serializing the JSON changes whitespace and breaks the match.
3

Compare in constant time

Accept only if it equals Flowyte-Signature, using a constant-time compare (never ==). During a rotation, also accept if any secret you hold verifies Flowyte-Signature-Prev.
A bad signature or a stale timestamp is a 401 — do no work.
Webhooks are the doorbell, not the mailbox — they tell you something happened; the REST API is the source of truth. Delivery is at-least-once, so a webhook may be redelivered — dedupe on Flowyte-Delivery. Don’t parse conversation content out of webhooks; read it from /messages.

3. Claim, reply, resolve

Claim

POST /escalations/{id}/claim takes ownership and starts an activity-based lease (chat ≈ 2 min, SMS ≈ 4 h). Claiming is idempotent for your key — re-claiming a session you hold just re-extends the lease — and guarded, so a second connector gets 409 claim_conflict.
Claim with retry on 409 / 404. A redelivered webhook or a race can transiently 409, and the session may not be visible for a beat (404) right after it’s created — back off briefly and retry a few times.
The claim response (and GET /escalations/{id} as the owner) returns the context package: the last N transcript turns (each with its sequence number), the escalation reason, any verified identifiers (SMS: the caller’s phone), and completed or failed tool actions (names and status only). It’s returned only to the claiming owner and is rebuilt at claim time, so you start current with any turns that arrived while the notification was in flight. Claim can also fail with 409 destination_unavailable (the harness was disconnected or the destination is inactive) or 403 plan_required (the organization is below Starter — the handshake test is exempt).

Read and sync

Every turn — customer and your own replies — carries the conversation’s sequence number (seq), the single ordering and dedup key. To catch up, or after a reconnect, poll GET /escalations/{id}/messages?after_seq=N and process strictly increasing seq values. Never assume a webhook told you everything; the /messages mailbox is authoritative and ordered.

Reply

  • client_message_id is your idempotency key. A replay with the same id returns the original receipt ({ messageId, seq, delivered }) instead of double-sending. Use one stable id per logical message and reuse it across retries.
  • The reply is delivered to the customer in their original thread — chat shows a “specialist has joined” divider; SMS sends a text.
  • Media (mediaUrls) is chat-only; on SMS it returns 422 media_unsupported_channel.
  • SMS compliance: a reply to an opted-out recipient returns 409 sms_suppressed. A reply is exempt from quiet hours — the customer started this conversation — unless the operator has turned on the strict posture for their organization, in which case an out-of-window reply returns 200 with delivered: false. Both are terminal — don’t retry either. (A thread your harness started is different: see §5.)
Any owner action (a message, typing, heartbeat, even a /messages read) extends your lease. Send POST /escalations/{id}/heartbeat if you go quiet but want to keep the thread; POST /escalations/{id}/typing shows a typing indicator (chat only). If the lease expires, the thread returns to the AI and you get 409 lease_expired — re-claim to continue.

Finish

4. Channels: chat vs SMS

The lifecycle is identical across channels — route on the channel field — but:
  • Chat replies appear live in the website widget; typing indicators and media attachments are supported. Chat is customer-initiated only — there is no visitor to push to until one arrives, so POST /escalations refuses any channel but sms with 422 channel_unsupported.
  • SMS threads can be started from either side. You reply inside a conversation the customer began, and — with the recipient’s consent and the prerequisites in §5 — you can open one yourself. SMS has no typing indicator and no media in v1, and it requires the operator’s completed A2P 10DLC (TCR) registration — the same registration their inbound number already needs. See SMS.
Harness conversations require the operator’s organization to be on Starter or higher. The handshake test works on any plan, so setup can precede the upgrade.

5. Start an outbound SMS thread

POST /escalations opens a new SMS conversation with a customer and sends its first message. It is the outbound sibling of everything above: once the session exists, the customer’s replies route to your harness through the same path as any escalation (the Flowyte AI does not answer them), so you continue on POST /escalations/{id}/messages exactly as you would on a thread you claimed.
This texts a real person, and an SMS cannot be unsent. It is not a bulk or cold-outreach channel: every initiate is gated on the recipient’s recorded consent, capped per workspace, restricted to US and Canada numbers, and held to the recipient’s legal texting window. Read the whole section before you wire it up.

Prerequisites

All of these must hold, or the call is refused before anything is sent: Outbound initiation is off by default and switched on per organization by Flowyte — ask your Flowyte contact to enable it once the rest is in place.

The request

There is no from. The sending number is derived server-side from the agent’s SMS-active number, and you can never choose it — an agent structurally cannot text without an assigned number, and accepting a caller-supplied from would route around that rule. An agent with no SMS-active number is 422 no_sms_number.
Send the message only. On the first text of a messaging relationship (and again after 30 days) the platform prepends the brand identity and appends the required Msg & data rates may apply. Reply HELP for help, STOP to opt out. block; every message is then sanitized to GSM-7 and capped at 2 segments. Do not write your own disclosure — you would ship it twice.

The response

The session is created already claimed by your key and active, so you never race the 120-second claim SLA on a thread you started. It carries an ordinary SMS lease (4 hours, extended by any owner action) and auto-resolves after 24 hours with no activity in either direction. delivered is always true on a 201 — an initiate that did not send is an error, never a 201 with delivered: false. state is active in the normal case; it reads claimed only if the first-message state transition did not land, and the thread is yours either way.
You will still receive webhooks for a thread you startedescalation.requested followed by escalation.claimed (carrying initiatedBy: "external_agent"), so a connector that reconciles purely from its event stream sees the session appear and sees that it owns it, in the same ordering as every other session. The session’s reason.type is agent_initiated. If your connector auto-claims on escalation.requested, that claim is harmless — claiming is idempotent for the key that already holds the session.
The recipient’s recorded consent chip must be conversational or express.
  • An inbound text is consent to text back. A customer’s first inbound message stamps conversational automatically, so anyone who has ever texted the business can be texted back with no extra step. This is the ordinary case and needs nothing from you.
  • express is consent captured deliberately elsewhere — on a call, in a chat, on a form.
  • A number with no consent chip, or no contact record at all, is refused 422 consent_required. A number that has never texted the business is exactly this case.
An organization that holds consent out-of-band can be granted an audited bypass. It waives only the per-recipient consent check — opt-out (STOP), 10DLC registration, the number being SMS-active and quiet hours all still apply — and every use writes a row to the append-only compliance ledger. It is off by default and is not something a connector can turn on.

Quiet hours differ from a reply

A harness reply is exempt from the recipient’s 8am–9pm window, because the customer started that conversation. A harness-initiated first text is business-initiated, so it is always held to the window: outside it, the call is refused 409 quiet_hours carrying details.nextOpen (RFC 3339). It is a refusal, not a queue — nothing is sent, and you retry after nextOpen.

Idempotency — read this before you write a retry

client_message_id is required, and it is a genuine idempotency key: the claim is keyed on (organization, client_message_id), is durable, and is committed before the text is sent. A replay therefore never produces a second text — not after the original escalation was resolved, not after a conversation-episode rollover, and not against a concurrent duplicate of itself. A completed replay returns the original 201 byte for byte. Reusing one id for a different message is 409 client_message_id_reused — an id names one message, and replaying the first message’s receipt for a second one would report a delivery that never happened.
409 send_state_unknown is the one refusal that does not promise the phone stayed silent. It means the carrier request failed in a way that does not prove the message stayed home — a timeout, a transport failure, a 5xx. The message may already have been delivered.Do not blindly retry. Either re-read the thread (the response carries details.escalationId and details.conversationId), or replay the same client_message_id — a replay resolves to this same answer, or to the receipt once the state settles, and never sends a second text. Sending under a fresh id is what texts somebody twice.Contrast 502 upstream_error: the carrier answered and rejected the request outright, so nothing was sent and the client_message_id has been released. That one is safe to retry once corrected.

Limits

  • 200 initiates per organization per rolling 24 hours (the deployment can configure this). Past the ceiling: 429 daily_initiate_cap, carrying details.nextOpen and a Retry-After header. The window is rolling rather than a calendar day, so a caller cannot spend the cap twice across midnight. Refusals create nothing and an idempotent replay is answered before the cap is consulted, so neither consumes budget.
  • US and Canada only. to must be a +1 NANP number; anything else is 422 unsupported_country.
  • sms only. 422 channel_unsupported for any other channel.
  • text is capped well above a real message and is refused 400 beyond it; an id longer than 200 characters is also a 400.

Errors

Every code above except send_state_unknown is a promise that the recipient’s phone stayed silent.

End to end

1

Start the thread

POST /escalations with agentId, destinationId, to, text and a fresh client_message_id. You get 201 with escalationId, conversationId, the message’s spine seq and delivered: true. The customer receives your text with the brand prefix and the opt-out disclosure appended.
2

Record the ids, then stop worrying about the claim

Store escalationId and the last seq you have seen. The session is already yours — no claim call, no SLA race. escalation.requested and escalation.claimed will also arrive on your webhook endpoint for this session; dedupe on Flowyte-Delivery as always.
3

The customer replies

Flowyte persists the reply to the conversation spine and delivers escalation.message.created to your endpoint with data.message (seq, role: "visitor", text) and data.latestSeq. The Flowyte AI stays silent for as long as you own the thread.
4

Sync the mailbox

Treat the webhook as the doorbell: GET /escalations/{id}/messages?after_seq=<last seq you saw> and process strictly increasing seq values. This closes any gap from a dropped or reordered delivery.
5

Reply

POST /escalations/{id}/messages with a new client_message_id and your text — the ordinary reply path. It is KindReply, so no further disclosure block is appended and quiet hours do not hold it. Every owner action extends your 4-hour lease.
6

Finish

POST /escalations/{id}/resolve when you are done, /return to hand the thread back to the Flowyte AI, or /request-human to route it to the operator’s human fallback. Left alone, the thread auto-resolves after 24 idle hours.
The same operation is available over the MCP gateway as initiate_escalation (scope escalations:initiate), annotated destructive and open-world because it texts a real person.

6. The handshake test

Before a destination can route real conversations, the operator runs a handshake test (POST /escalation-destinations/{id}/test) that proves your connector can talk back and forth:
  1. Flowyte opens a sandbox escalation on a synthetic conversation and sends you a signed escalation.test webhook — a normal escalation.requested envelope plus "test": true.
  2. Your connector must, within the window: verify the signature → claim → post one message → resolve.
  3. Flowyte reports a step ledger ({ webhook_delivered, claimed, message_posted, resolved }) with an actionable hint on failure. Only a green run flips the destination to active.
Treat escalation.test exactly like escalation.requested — same code path. Test messages reach no customer and are never billed, but your connector doesn’t need to know that; just claim, reply, resolve.

Hard rules

  • Verify Flowyte-Signature (constant-time; 300s replay window; accept Flowyte-Signature-Prev during rotation) before doing any work.
  • Claim with retry on 409 / 404; claim is idempotent for your key.
  • Idempotent client_message_id on every reply; a replay returns the original receipt.
  • Dedupe by seq; gap-sync via GET /escalations/{id}/messages?after_seq=N. Webhooks are the doorbell; the REST API is the mailbox.
  • Route on the channel field in every notification.
  • Sessions are tenant- and destination-scoped — you only ever see your own.
  • Never expose local paths, prompts, model internals, or secrets in replies, notes, or logs.
  • SMS requires completed TCR registration; harness conversations require Starter or higher.
  • Starting an SMS thread needs recorded consent and a separate escalations:initiate scope; chat can never be started outbound.
  • Never blindly retry 409 send_state_unknown — replay the same client_message_id or re-read the thread. A fresh id there is how you text somebody twice.
Back to AI Harnesses for the operator setup, or drive the same surface from an agent over the MCP gateway.