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:
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.
2. Verify the signature first
Flowyte delivers webhooks to yourendpoint_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.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.
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_idis 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 returns422 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 returns200withdelivered: false. Both are terminal — don’t retry either. (A thread your harness started is different: see §5.)
/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 thechannel 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 /escalationsrefuses any channel butsmswith422 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.
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.
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.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
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 started —
escalation.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.Consent — the rule that decides most refusals
The recipient’s recorded consent chip must beconversational or express.
- An inbound text is consent to text back. A customer’s first inbound message stamps
conversationalautomatically, 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. expressis 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.
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 refused409 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.
Limits
- 200 initiates per organization per rolling 24 hours (the deployment can configure this). Past
the ceiling:
429 daily_initiate_cap, carryingdetails.nextOpenand aRetry-Afterheader. 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.
tomust be a+1NANP number; anything else is422 unsupported_country. smsonly.422 channel_unsupportedfor any other channel.textis capped well above a real message and is refused400beyond it; an id longer than 200 characters is also a400.
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.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:
- Flowyte opens a sandbox escalation on a synthetic conversation and sends you a signed
escalation.testwebhook — a normalescalation.requestedenvelope plus"test": true. - Your connector must, within the window: verify the signature → claim → post one message → resolve.
- Flowyte reports a step ledger (
{ webhook_delivered, claimed, message_posted, resolved }) with an actionable hint on failure. Only a green run flips the destination toactive.
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.