Back to Arky

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" };
StepWhat happens
chat_messageshows its text in the chat and goes straight on
chat_choicewaits for one of its buttons
chat_questionwaits for an answer of its input_type, saved under the step’s key
collect_email_contactasks for the customer’s email and gives the chat an email contact, with its subject and the sending address replies come from
send_emailemails a support reply template in the conversation’s language
hand_to_teamgives the conversation to the team
resolveresolves the conversation
POST /v1/stores/{store_id}/support-flows
SDK: sdk.support.flow.create()
GET /v1/stores/{store_id}/support-flows
SDK: sdk.support.flow.find()
GET /v1/stores/{store_id}/support-flows/{id}
SDK: sdk.support.flow.get()
PUT /v1/stores/{store_id}/support-flows/{id}
SDK: sdk.support.flow.update()
DELETE /v1/stores/{store_id}/support-flows/{id}?expected_updated_at=
SDK: 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_key and every next_step_key name 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 a chat_question or collect_email_contact step 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_email names one of the store’s support reply templates, and every collect_email_contact sending address is an active sending address of the store;
  • a chat reaches a send_email only after a collect_email_contact with 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

POST /v1/storefront/conversations
SDK: arky.support.conversation.start()
GET /v1/storefront/conversations/{conversation_id}
SDK: arky.support.conversation.get()
GET /v1/storefront/conversations/{conversation_id}/messages
SDK: arky.support.conversation.findMessages()
POST /v1/storefront/conversations/{conversation_id}/messages
SDK: arky.support.conversation.sendMessage()
GET /v1/storefront/conversations/{conversation_id}/messages/{message_id}
SDK: 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 an email answer 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_id names the prompt message shown. If the chat moved on, the answer is refused with 409 CONVERSATION.STALE_PROMPT: read the conversation again and show the current prompt.
  • Each message carries an id, from the app’s createId unless 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 resolved chat 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.
  • findMessages pages from the newest messages back, each page sorted by sequence. 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-To or References headers, 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’s initial_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

POST /v1/stores/{store_id}/conversations
SDK: sdk.support.conversation.startEmail()
GET /v1/stores/{store_id}/conversations
SDK: sdk.support.conversation.find()
GET /v1/stores/{store_id}/conversations/{conversation_id}
SDK: sdk.support.conversation.get()
GET /v1/stores/{store_id}/conversations/{conversation_id}/messages
SDK: sdk.support.conversation.findMessages()
POST /v1/stores/{store_id}/conversations/{conversation_id}/messages
SDK: sdk.support.conversation.reply()
POST /v1/stores/{store_id}/conversations/{conversation_id}/messages/{message_id}/chat-visibility
SDK: sdk.support.conversation.changeChatVisibility()
POST /v1/stores/{store_id}/conversations/{conversation_id}/select-sending-address
SDK: sdk.support.conversation.selectSendingAddress()
POST /v1/stores/{store_id}/conversations/{conversation_id}/assign
SDK: sdk.support.conversation.assign()
POST /v1/stores/{store_id}/conversations/{conversation_id}/resolve
SDK: 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;
}
  • find filters by statuses, contact (chat or email), customer_id, assigned_account_id and text.
  • A person’s reply is delivered by chat, email or chat_and_email, and gives the conversation to the team, or resolves it with resolve: true. An email reply goes to customer_email from the conversation’s sending address, as a support_reply outgoing email; the message shows its send status and delivery report. Pick the sending address first with selectSendingAddress when the conversation has none (409 CONVERSATION.SENDER_REQUIRED); an address that can’t send yet is 409 CONVERSATION.SENDER_NOT_READY, and an email reply on a conversation without email is 409 CONVERSATION.NO_EMAIL.
  • A reply may name a support reply template in email_template_id instead of text: 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_MISSING when it has none). A template of another type, or another store’s, is 404.
  • startEmail starts an email thread with a customer: { id, message_id, customer_id, email, subject, language, sending_address_id } plus text or email_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.
  • changeChatVisibility shows 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 is 409 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.failure says why.
  • Assigning names an account with the support permission, or null (409 CONVERSATION.ASSIGNEE_NO_SUPPORT otherwise). 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.",
});
Note

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.