Forms
Forms with typed questions and stages, submissions in one language, file answers, and notes
A form is a set of questions customers fill in on the storefront, like a contact form, a cake order or a partner application. Each filled-in form is a form submission in one language, which moves through the form’s stages, such as new → accepted. Staff can also enter a submission for a customer.
Form definitions need the content permission; reading and working submissions needs support.
Forms
import type { EpochMilliseconds } from "arky-sdk";
interface Form {
id: string;
store_id: string;
key: string;
questions: FormQuestion[];
questions_updated_at: EpochMilliseconds;
stages: { id: string; key: string }[];
emails: {
received: { email_template_id: string } | null;
stage_changed: Record<string, { email_template_id: string }>;
alert: { email_template_id: string; to: AlertRecipient[] } | null;
};
status: { type: "active" } | { type: "draft" } | { type: "closed" };
created_at: EpochMilliseconds;
updated_at: EpochMilliseconds;
}
type QuestionLabel =
{ type: "shown"; text: Record<string, string> } | { type: "hidden" };
type FormQuestion = {
id: string;
key: string;
required: boolean;
label: QuestionLabel;
} & (
| {
type: "text";
min_length: number | null;
max_length: number | null;
pattern: string | null;
}
| { type: "email" }
| { type: "number"; min: number | null; max: number | null }
| { type: "boolean" }
| { type: "date" }
| { type: "date_time" }
| { type: "geo_location" }
| {
type: "select_one";
options: { key: string; label: Record<string, string> }[];
}
| {
type: "select_many";
options: { key: string; label: Record<string, string> }[];
}
| { type: "file"; max_files: number }
);
- A question’s
labelis the text shown with it per language, orhiddenfor a field the site fills itself. A label or option label in a language the form doesn’t have yet must be one of the store’s languages; texts in a language the store later dropped stay. select_onetakes exactly one option andselect_manyone or more; answers store option keys, so changing a label never breaks old answers.dateis a day (YYYY-MM-DD);date_timeis a moment.texthas its own length limits and an optional pattern; every text answer is at most 10,000 characters.filetakes files sent with the submission, at mostmax_files(1 to 20).draftrefuses answers while the form is being built;closedstops taking answers and keeps its submissions.
/v1/stores/{store_id}/forms sdk.forms.create() /v1/stores/{store_id}/forms sdk.forms.find() /v1/stores/{store_id}/forms/{id} sdk.forms.get() /v1/stores/{store_id}/forms/by-key/{key} sdk.forms.getByKey() /v1/stores/{store_id}/forms/{id} sdk.forms.update() /v1/stores/{store_id}/forms/{id}?expected_updated_at= sdk.forms.delete() const form = await sdk.forms.create({
store_id: storeId,
id: crypto.randomUUID(),
key: "cake-order",
questions: [
{
id: crypto.randomUUID(),
key: "email",
type: "email",
required: true,
label: { type: "shown", text: { en: "Email address" } },
},
{
id: crypto.randomUUID(),
key: "size",
type: "select_one",
required: true,
label: { type: "shown", text: { en: "Cake size" } },
options: [
{ key: "small", label: { en: "Small (8 slices)" } },
{ key: "large", label: { en: "Large (16 slices)" } },
],
},
{
id: crypto.randomUUID(),
key: "pickup_day",
type: "date",
required: true,
label: { type: "shown", text: { en: "Pickup day" } },
},
{
id: crypto.randomUUID(),
key: "inspiration",
type: "file",
required: false,
label: { type: "shown", text: { en: "Inspiration photos" } },
max_files: 3,
},
],
stages: [
{ id: crypto.randomUUID(), key: "new" },
{ id: crypto.randomUUID(), key: "accepted" },
{ id: crypto.randomUUID(), key: "rejected" },
],
});Parameters
| Name | Type | Description |
|---|---|---|
id required | string | The form id the app picks (UUID v4) |
key required | string | The form's name, unique in the store (409 FORM.KEY_TAKEN otherwise) |
questions required | FormQuestion[] | Up to 100 questions with unique ids and keys |
stages required | FormStage[] | Explicitly author 1 to 50 stages; the first receives new submissions |
- A new submission lands in the first stage.
- A form holds at most 100 questions; text limits stay within 10,000 characters and a pattern within 1,000; number bounds are finite and ordered; a select question has 1 to 100 options; a file question allows 1 to 20 files; and a form has at most one email question.
- An update carries
expected_updated_at;status: { type: "closed" }closes a form. A stage that holds submissions can’t be removed; removing a stage removes itsstage_changedemail. - A form with no submissions can be deleted once nothing else names it. A form with submissions is closed instead, unless staff delete its submissions first.
- Create each form explicitly; creating a store creates no forms.
Submit From The Storefront
/v1/storefront/forms/{id_or_key} arky.forms.get() / arky.forms.getByKey() /v1/storefront/forms/{form_id}/submissions arky.forms.submit() 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 cakeOrder = await arky.forms.getByKey({ key: "cake-order" });
const submission = await arky.forms.submit({
form: cakeOrder,
language: "en",
values: {
email: "ana@example.com",
size: "large",
pickup_day: "2026-11-14",
inspiration: inspirationInput.files ? [...inspirationInput.files] : [],
},
});
- The submission’s id comes from your
createId, or theidyou pass. When the answer is lost, sending the same answers again reuses that id, so the form is submitted once; changed answers are a new submission with a new id. The SDK keeps only the id and a fingerprint of the answers, and the bot-check token isn’t part of it, so a fresh token can go with the retry. - The submission names its
language, one of the store’s languages. The questions’ labels, and the labels of the options chosen, are kept in that language as the customer saw them, and the store’s form emails go in it. - The questions’ version. A submission names the loaded form’s
questions_updated_at;submitsends it. Only question or option edits change this version. When those changed, Arky refuses the submission with409 FORM_SUBMISSION.FORM_CHANGED: load the form again and let the visitor check their answers. - An answer exists only when it is given. Leave unanswered optional questions out: never send
an empty string, an empty list or only spaces.
submitwithformandvalues, andbuildFormAnswers, build the answers from plain values, check them against the questions, and leave empty values out; a value that doesn’t fit throws aFormAnswerError(with the question’squestionKeyand acode) before anything is sent. - Files go in the same request as the answers, as multipart: one part holds the answers and names each file part, and each file is its own part. Arky checks each file’s real type from its bytes (images, video or PDF), at most 10 MiB each, 20 per submission and 50 MiB per request. Files are stored privately with the submission, never in the media library.
- Retries: the
idis picked by the app once per submit. Send the same id with the same answers and files to retry; Arky returns the stored submission before checking the current questions. Another request under that id is refused with409. - The storefront gets back its own copy:
{ id, form_id, language, answers, stage_id, created_at, updated_at }. Staff details (the stage history, notes, assignee) never reach the storefront.
Submissions In Admin
/v1/stores/{store_id}/forms/submissions sdk.forms.findSubmissions() /v1/stores/{store_id}/forms/{form_id}/submissions/{id} sdk.forms.getSubmission() /v1/stores/{store_id}/forms/{form_id}/submissions sdk.forms.createSubmission() /v1/stores/{store_id}/forms/{form_id}/submissions/{id}/stage sdk.forms.changeSubmissionStage() /v1/stores/{store_id}/forms/{form_id}/submissions/{id}/assignee sdk.forms.assignSubmission() /v1/stores/{store_id}/forms/{form_id}/submissions/{id}/company sdk.forms.setSubmissionCompany() /v1/stores/{store_id}/forms/{form_id}/submissions/{id}/files/{sha256} sdk.forms.getSubmissionFile() /v1/stores/{store_id}/forms/{form_id}/submissions/{id}?expected_updated_at= sdk.forms.deleteSubmission() interface FormSubmission {
id: string;
form_id: string;
store_id: string;
customer_id: string;
company_id: string | null;
source:
| {
type: "storefront";
customer_session_id: string;
authentication: CustomerAuthenticationSnapshot;
}
| { type: "account"; actor: AccountActor };
language: string;
snapshot: {
form_key: string;
questions: { question_id: string; label: string }[];
options: { question_id: string; option_key: string; label: string }[];
};
answers: FormAnswer[];
stage: { stage_id: string; changed_at: EpochMilliseconds };
stage_history: {
from_stage_id: string;
to_stage_id: string;
actor: AccountActor;
changed_at: EpochMilliseconds;
note_id: string | null;
}[];
assignee_account_id: string | null;
created_at: EpochMilliseconds;
updated_at: EpochMilliseconds;
}
type FormAnswer =
| { type: "text"; question_id: string; key: string; value: string }
| { type: "number"; question_id: string; key: string; value: number }
| { type: "boolean"; question_id: string; key: string; value: boolean }
| { type: "date"; question_id: string; key: string; value: string }
| {
type: "date_time";
question_id: string;
key: string;
value: EpochMilliseconds;
}
| {
type: "geo_location";
question_id: string;
key: string;
value: { lat: number; lon: number };
}
| { type: "select_one"; question_id: string; key: string; option_key: string }
| {
type: "select_many";
question_id: string;
key: string;
option_keys: string[];
}
| {
type: "file";
question_id: string;
key: string;
files: {
file_name: string;
format: string;
size_bytes: number;
sha256: string;
}[];
};
const page = await sdk.forms.findSubmissions({
store_id: storeId,
form_id: cakeOrder.id,
select: [{ question_id: sizeQuestionId, option_keys: ["large"] }],
limit: 20,
});
const current = page.items[0];
await sdk.forms.changeSubmissionStage({
store_id: storeId,
form_id: current.form_id,
id: current.id,
expected_stage_id: current.stage.stage_id,
expected_changed_at: current.stage.changed_at,
to_stage_id: acceptedStageId,
note: { id: crypto.randomUUID(), body: "Confirmed by phone." },
});
const link = await sdk.forms.getSubmissionFile({
store_id: storeId,
form_id: current.form_id,
id: current.id,
sha256: fileSha256,
});
- A stage change carries the stage it expects and when that stage was entered; its optional note
explains the move. A submission keeps every move, at most 100: a further move is refused with
409 FORM_SUBMISSION.STAGE_HISTORY_FULLrather than dropping earlier history. - A file download is a link that expires after five minutes, only for staff who may see the submission.
setSubmissionCompanylinks the company an application led to;assignSubmissionnames who handles it.- Erasing a customer deletes their submissions, files and notes.
Notes
Submission notes are staff-only, through sdk.forms.notes.find, create, update and delete
on /v1/stores/{store_id}/forms/{form_id}/submissions/{form_submission_id}/notes.
Emails
A form’s own emails pick its templates, of the form type:
receivedgoes to the answer of the form’s email question after a submission is sent, at most once per form and address in 24 hours; later submissions are still stored.stage_changed, keyed by stage id, goes to that answer when a submission is moved into that stage; starting in the first stage sends none.alertgoes to its staff recipients after each new submission.
A form without an email question sends received and stage_changed to nobody; the answer is plain
data and never touches the customer. Each email goes in the submission’s language, from the store’s
sending address, and a form can select its emails only while the store’s email settings have one
(409 STORE_EMAILS.SENDER_REQUIRED otherwise). Only stage_changed emails carry stage, so wording
that shows it outside an {{#if stage}} can’t be selected for received or alert
(409 EMAIL_TEMPLATE.FIELD_NOT_SENT). See Store Email.
Webhook form_submission.created posts the submission of the forms the
webhook names.