Support
Fixed-step support flows, conversations by chat and email, replies, assignment, and resolution
Support has two records you configure or read:
- a support flow is a fixed set of steps: messages, choices with buttons, questions, collecting an email address, sending an email, handing the conversation to the team, and resolving it;
- a conversation is one customer’s chat, email thread, or both, with its messages. Chats and emails share one inbox in Admin.
Customers reach the store through the website chat, or by email: an email address that receives gets the store’s forwarded mail. Every reply comes from a person or from a flow’s fixed steps.
For the end-to-end setup, see the Support guide.
Support Flows
import type { EpochMilliseconds } from "arky-sdk";
interface SupportFlow {
id: string;
store_id: string;
key: string;
start_step_key: string;
steps: Record<string, SupportStep>;
created_at: EpochMilliseconds;
updated_at: EpochMilliseconds;
}
type SupportStep =
| { type: "chat_message"; text: LocalizedText; next_step_key: string }
| { type: "chat_choice"; text: LocalizedText; buttons: { label: LocalizedText; next_step_key: string }[] }
| { type: "chat_question"; text: LocalizedText; input_type: "text" | "email" | "phone"; next_step_key: string }
| {
type: "collect_email_contact";
text: LocalizedText;
subject: LocalizedText;
initial_sending_address_id: string | null;
next_step_key: string;
}
| { type: "send_email"; email_template_id: string; next_step_key: string }
| { type: "hand_to_team" }
| { type: "resolve" };
| Step | What happens |
|---|---|
chat_message | shows its text in the chat and goes straight on |
chat_choice | waits for one of its buttons |
chat_question | waits for an answer of its input_type, saved under the step’s key |
collect_email_contact | asks for the customer’s email and gives the chat an email contact, with its subject and the sending address replies come from |
send_email | emails a support reply template in the conversation’s language |
hand_to_team | gives the conversation to the team |
resolve | resolves the conversation |
/v1/stores/{store_id}/support-flows sdk.support.flow.create() /v1/stores/{store_id}/support-flows sdk.support.flow.find() /v1/stores/{store_id}/support-flows/{id} sdk.support.flow.get() /v1/stores/{store_id}/support-flows/{id} sdk.support.flow.update() /v1/stores/{store_id}/support-flows/{id}?expected_updated_at= sdk.support.flow.delete() const flow = await sdk.support.flow.create({
store_id: storeId,
id: crypto.randomUUID(),
key: "default",
start_step_key: "greeting",
steps: {
greeting: {
type: "chat_choice",
text: { en: "Hi. Ask us anything, or leave your email." },
buttons: [
{ label: { en: "Ask a question" }, next_step_key: "question" },
{ label: { en: "Talk to us" }, next_step_key: "email" },
],
},
question: {
type: "chat_message",
text: { en: "Type your question below. The team answers here." },
next_step_key: "team",
},
email: {
type: "collect_email_contact",
text: { en: "What's your email?" },
subject: { en: "Your question to Northwind" },
initial_sending_address_id: supportAddress.id,
next_step_key: "thanks",
},
thanks: {
type: "chat_message",
text: { en: "Thanks, {{email}}. A team member will reply here or by email." },
next_step_key: "team",
},
team: { type: "hand_to_team" },
},
});
Rules checked when a flow is saved:
start_step_keyand everynext_step_keyname a step of this flow, and every step can be reached from the start;- steps that don’t wait (
chat_message,send_email) can’t loop without a waiting step between; - each
{{key}}in a text names achat_questionorcollect_email_contactstep asked on every path before it; - every chat text has the same languages, and a language the flow doesn’t have yet must be one of the store’s; button labels are unique within a choice, at most 500 characters;
- every
send_emailnames one of the store’s support reply templates, and everycollect_email_contactsending address is an active sending address of the store; - a chat reaches a
send_emailonly after acollect_email_contactwith a sending address; - at most 100 steps, 20 buttons on a choice, and 64 KiB per text.
A flow that starts with chat steps runs in the website chat. A flow with only send_email,
hand_to_team and resolve can also answer new email: an email address names it as its
initial_flow_id, and such a flow can’t gain chat steps. An update replaces all steps in one write
and carries the expected_updated_at it read; a key another flow uses is 409 SUPPORT_FLOW.KEY_TAKEN.
A conversation keeps the copy of the steps it started with; only new conversations run the edit. A
flow can’t be deleted while an email address starts with it, and a support reply template can’t be
deleted while a flow’s send_email or a conversation still running its flow names it.
Storefront Chat
/v1/storefront/conversations arky.support.conversation.start() /v1/storefront/conversations/{conversation_id} arky.support.conversation.get() /v1/storefront/conversations/{conversation_id}/messages arky.support.conversation.findMessages() /v1/storefront/conversations/{conversation_id}/messages arky.support.conversation.sendMessage() /v1/storefront/conversations/{conversation_id}/messages/{message_id} arky.support.conversation.getMessage() import { initialize } from "arky-sdk/storefront";
import { createOrderToken } from "arky-sdk/utils";
const arky = initialize("arky_pk_...", {
locale: "en",
createVisitorIdentity: () => ({
customer_id: crypto.randomUUID(),
session_id: crypto.randomUUID(),
}),
createSessionId: () => crypto.randomUUID(),
createId: () => crypto.randomUUID(),
createOrderToken,
});
const started = await arky.support.conversation.start({
language: "en",
flow_key: "default",
});
localStorage.setItem("support-chat", started.conversation.id);
const choice = started.messages.findLast((message) => message.type.type === "flow_choice");
const reply = await arky.support.conversation.sendMessage({
conversation_id: started.conversation.id,
input: { type: "button", label: "Talk to us" },
prompt_message_id: choice?.id,
});Parameters
| Name | Type | Description |
|---|---|---|
id optional | string | The conversation id (UUID v4). Left out, the SDK takes it from the app's createId and keeps it for a retry. |
language required | string | One of the store's languages, and with a flow one its chat texts have. |
flow_key optional | string | The flow the chat starts with; without it the chat goes straight to the team. |
- The start returns the conversation and its first messages. A chat belongs to the customer of the session that started it: the SDK starts or resumes the browser’s session first, and every later call needs only the conversation id under that session. There is no separate chat token. A retried start with the same id returns the same conversation; without an id, the same start sent again after a lost answer reuses the kept one.
- A message input is
{ type: "button", label }or{ type: "text", text }. At a choice only a shown button counts; at a question the text is the answer, and anemailanswer must be an email address. Once the team has the chat, text goes to the team. - Answers name the prompt they answer. While the chat waits at a choice, a question or an email
prompt,
prompt_message_idnames the prompt message shown. If the chat moved on, the answer is refused with409 CONVERSATION.STALE_PROMPT: read the conversation again and show the current prompt. - Each message carries an
id, from the app’screateIdunless you pass one. The SDK keeps it with a fingerprint of the message until the answer, so the same input for the same prompt sent again after a lost answer reuses it, and the repeat finds the message instead of adding a second one. A different message gets a new id. - A
resolvedchat takes no more messages (409 CONVERSATION.ENDED): start a new one. A new email from the customer, or a reply from the team, reopens a resolved conversation. findMessagespages from the newest messages back, each page sorted bysequence. Poll the conversation every few seconds to pick up the team’s replies.
The storefront gets the messages a chat may show; staff details and email headers stay in Admin:
type StorefrontConversationMessageType =
| { type: "customer_chat"; text: string }
| { type: "received_email"; text: string }
| { type: "flow_message"; text: string }
| { type: "flow_choice"; text: string; buttons: string[] }
| { type: "flow_question"; text: string; input_type: "text" | "email" | "phone" }
| { type: "reply"; text: string };
A flow_choice tells the chat to show only its buttons; a flow_question tells it which field to
show. A received_email appears only when staff showed it in the chat. The conversation’s status
says who has it: flow (waiting at step_key), team or resolved, and its language is the one it
runs in.
Email Conversations
The store forwards its support address to the forwarding address of an email address that receives. Arky routes each forwarded email by the address Resend received it at, never by the message’s own headers, and files it in the receiving store:
- An email that answers one of the conversation’s messages, by its
In-Reply-ToorReferencesheaders, joins that conversation; a matching subject alone never does. Anything else starts a new conversation with its sender as the customer. - A new email conversation runs in the receiving address’s
language. It starts with the address’s flow when the address is active and has one; otherwise the team has it. Replies come from the address’sinitial_sending_address_id. - A flow answers a new email automatically at most once a day per address, and never answers
automatic mail (
Auto-Submitted, bulk or list mail, or a mailer daemon); the team gets those. - A customer’s email on a resolved conversation, or while its chat waits at a prompt, gives the conversation to the team.
- Attachments, up to 10 and 20 MiB in all, are stored privately with their message. Staff open them
through a link that expires after five minutes. An attachment over the limit, or one Resend still
can’t hand over after a day, is left out, and the message lists it by file name and reason
(
left_out_attachments) so staff can ask for it again.
Admin Conversations
/v1/stores/{store_id}/conversations sdk.support.conversation.startEmail() /v1/stores/{store_id}/conversations sdk.support.conversation.find() /v1/stores/{store_id}/conversations/{conversation_id} sdk.support.conversation.get() /v1/stores/{store_id}/conversations/{conversation_id}/messages sdk.support.conversation.findMessages() /v1/stores/{store_id}/conversations/{conversation_id}/messages sdk.support.conversation.reply() /v1/stores/{store_id}/conversations/{conversation_id}/messages/{message_id}/chat-visibility sdk.support.conversation.changeChatVisibility() /v1/stores/{store_id}/conversations/{conversation_id}/select-sending-address sdk.support.conversation.selectSendingAddress() /v1/stores/{store_id}/conversations/{conversation_id}/assign sdk.support.conversation.assign() /v1/stores/{store_id}/conversations/{conversation_id}/resolve sdk.support.conversation.resolve() const inbox = await sdk.support.conversation.find({
store_id: storeId,
statuses: ["team"],
limit: 25,
});
const conversation = inbox.items[0];
await sdk.support.conversation.reply({
store_id: storeId,
conversation_id: conversation.id,
id: crypto.randomUUID(),
expected_updated_at: conversation.updated_at,
text: "I can help with that.",
delivery: conversation.type.type === "chat" ? "chat" : "email",
resolve: false,
});
interface Conversation {
id: string;
store_id: string;
customer_id: string;
assigned_account_id: string | null;
status: { type: "flow"; step_key: string } | { type: "team" } | { type: "resolved" };
type:
| { type: "chat" }
| { type: "email"; email: ConversationEmail }
| { type: "chat_and_email"; email: ConversationEmail };
language: string;
flow: { flow_id: string; steps: Record<string, SupportStep>; answers: Record<string, string>; failure: { step_key: string; reason: string; at: EpochMilliseconds } | null } | null;
next_message_sequence: number;
created_at: EpochMilliseconds;
updated_at: EpochMilliseconds;
}
interface ConversationEmail {
customer_email: string;
subject: string;
sending_address_id: string | null;
}
findfilters bystatuses,contact(chatoremail),customer_id,assigned_account_idand text.- A person’s reply is delivered by
chat,emailorchat_and_email, and gives the conversation to the team, or resolves it withresolve: true. An email reply goes tocustomer_emailfrom the conversation’s sending address, as asupport_replyoutgoing email; the message shows its send status and delivery report. Pick the sending address first withselectSendingAddresswhen the conversation has none (409 CONVERSATION.SENDER_REQUIRED); an address that can’t send yet is409 CONVERSATION.SENDER_NOT_READY, and an email reply on a conversation without email is409 CONVERSATION.NO_EMAIL. - A reply may name a support reply template in
email_template_idinstead oftext: it is delivered by email, and both the email and the message text come from the template in the conversation’s language (409 EMAIL_TEMPLATE.LANGUAGE_MISSINGwhen it has none). A template of another type, or another store’s, is404. startEmailstarts an email thread with a customer:{ id, message_id, customer_id, email, subject, language, sending_address_id }plustextoremail_template_id. The conversation, its first staff reply and the outgoing email are saved together, it starts with the team, and a retry with the same ids sends nothing twice.changeChatVisibilityshows a received email in the conversation’s chat, or hides it again, with{ expected_updated_at, visible_in_chat }of that message; a conversation without a chat is409 CONVERSATION.NO_CHAT, and only a received email can be shown.- When a flow step can’t run, such as a missing template text or sending address, the team gets the
conversation and
flow.failuresays why. - Assigning names an account with the
supportpermission, ornull(409 CONVERSATION.ASSIGNEE_NO_SUPPORTotherwise). Taking a conversation over or resolving it stops nothing already waiting to be sent. - Conversations have no delete of their own; they go with their customer or their store.
Notes on a conversation are staff-only:
await sdk.support.conversation.notes.create({
store_id: storeId,
conversation_id: conversation.id,
id: crypto.randomUUID(),
body: "Customer prefers email.",
});Every write here carries the id the app picked or the expected_updated_at it read. A retry
with the same id returns the existing record; a stale expected_updated_at is refused with
409. See Core Concepts.