Back to Arky

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 label is the text shown with it per language, or hidden for 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_one takes exactly one option and select_many one or more; answers store option keys, so changing a label never breaks old answers.
  • date is a day (YYYY-MM-DD); date_time is a moment.
  • text has its own length limits and an optional pattern; every text answer is at most 10,000 characters.
  • file takes files sent with the submission, at most max_files (1 to 20).
  • draft refuses answers while the form is being built; closed stops taking answers and keeps its submissions.
POST /v1/stores/{store_id}/forms
SDK: sdk.forms.create()
GET /v1/stores/{store_id}/forms
SDK: sdk.forms.find()
GET /v1/stores/{store_id}/forms/{id}
SDK: sdk.forms.get()
GET /v1/stores/{store_id}/forms/by-key/{key}
SDK: sdk.forms.getByKey()
PUT /v1/stores/{store_id}/forms/{id}
SDK: sdk.forms.update()
DELETE /v1/stores/{store_id}/forms/{id}?expected_updated_at=
SDK: 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 its stage_changed email.
  • 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

GET /v1/storefront/forms/{id_or_key}
SDK: arky.forms.get() / arky.forms.getByKey()
POST /v1/storefront/forms/{form_id}/submissions
SDK: 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 the id you 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; submit sends it. Only question or option edits change this version. When those changed, Arky refuses the submission with 409 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. submit with form and values, and buildFormAnswers, build the answers from plain values, check them against the questions, and leave empty values out; a value that doesn’t fit throws a FormAnswerError (with the question’s questionKey and a code) 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 id is 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 with 409.
  • 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

GET /v1/stores/{store_id}/forms/submissions
SDK: sdk.forms.findSubmissions()
GET /v1/stores/{store_id}/forms/{form_id}/submissions/{id}
SDK: sdk.forms.getSubmission()
POST /v1/stores/{store_id}/forms/{form_id}/submissions
SDK: sdk.forms.createSubmission()
POST /v1/stores/{store_id}/forms/{form_id}/submissions/{id}/stage
SDK: sdk.forms.changeSubmissionStage()
PUT /v1/stores/{store_id}/forms/{form_id}/submissions/{id}/assignee
SDK: sdk.forms.assignSubmission()
PUT /v1/stores/{store_id}/forms/{form_id}/submissions/{id}/company
SDK: sdk.forms.setSubmissionCompany()
GET /v1/stores/{store_id}/forms/{form_id}/submissions/{id}/files/{sha256}
SDK: sdk.forms.getSubmissionFile()
DELETE /v1/stores/{store_id}/forms/{form_id}/submissions/{id}?expected_updated_at=
SDK: 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_FULL rather than dropping earlier history.
  • A file download is a link that expires after five minutes, only for staff who may see the submission.
  • setSubmissionCompany links the company an application led to; assignSubmission names 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:

  • received goes 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.
  • alert goes 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.

Note

Webhook form_submission.created posts the submission of the forms the webhook names.