Пошаговое руководство

Подключите форму и проверьте результат

Рабочие примеры формы с публичным ключом: сохранение заявки, понятные ошибки и повтор без дублей. HTML, React и Astro.

С чего начать

Создайте форму в кабинете, скопируйте access_key и подставьте его в код ниже. Выберите HTML, React или Astro.

Скопировать рабочий пример
Готовое подключение

Вставьте ключ.
Скопируйте код.

Все формы отправляются методом POST на один адрес. Поле access_key указывает, в какую форму попадёт заявка. Остальные поля остаются вашими.

Взять ключ в кабинете

Замените YOUR_ACCESS_KEY ключом своей формы. Для всех вариантов используется один адрес отправки.

HTML

contact.html
<form data-formhunt action="https://app.formhunt.ru/v1/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">
  <label for="email">Ваша почта</label>
  <input id="email" type="email" name="email" required>
  <label for="message">Сообщение</label>
  <textarea id="message" name="message" required></textarea>
  <p role="status" aria-live="polite" data-formhunt-status></p>
  <button type="submit">Отправить</button>
</form>
<script>
  document.querySelectorAll("form[data-formhunt]").forEach((form) => {
    if (form.dataset.formhuntReady) return;
    form.dataset.formhuntReady = "true";
    const output = form.querySelector("[data-formhunt-status]");
    const button = form.querySelector('button[type="submit"]');
    const retry = { current: { payload: "", key: "" } }, busy = { current: false };
    const setMessage = (message) => { output.textContent = message; };
    const setBusy = (value) => { button.disabled = value; form.setAttribute("aria-busy", String(value)); };
    form.addEventListener("submit", async (event) => {
      if (!globalThis.crypto?.randomUUID || !globalThis.fetch) return;
      event.preventDefault(); if (busy.current) return;
      busy.current = true; setBusy(true); setMessage("Сохраняем заявку…");
    const payload = new FormData(form);
    const fingerprint = JSON.stringify([...payload.entries()]);
    if (retry.current.payload !== fingerprint) retry.current = { payload: fingerprint, key: crypto.randomUUID() };
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 15000);
    try {
      const response = await fetch(form.action, {
        method: "POST", body: payload, credentials: "omit",
        headers: { Accept: "application/json", "Idempotency-Key": retry.current.key },
        signal: controller.signal
      });
      const result = await response.json().catch(() => null);
      const requestId = [response.headers.get("X-Request-Id"), result?.request_id].find((value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value));
      if (![200, 201].includes(response.status) || result?.success !== true || result?.status !== "accepted" || typeof result?.submission_id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result.submission_id)) {
        const reason = response.status === 429 ? "Слишком много отправок. Подождите и повторите." : response.status === 404 ? "Проверьте публичный ключ и активность формы." : response.status === 400 ? "Проверьте поля и настройки формы." : "Не удалось подтвердить сохранение. Повторите попытку.";
        throw new Error(reason + (requestId ? " Код запроса: " + requestId : ""));
      }
      const unchanged = JSON.stringify([...new FormData(form).entries()]) === fingerprint;
      const confirmed = result.test ? "Тестовая заявка сохранена. Обычные уведомления отключены." : "Заявка сохранена. Доставка уведомления проверяется отдельно.";
      setMessage(unchanged ? confirmed : confirmed + " Сохранена предыдущая версия. Новые правки остались в форме; отправьте их отдельно.");
      if (unchanged) form.reset();
      retry.current = { payload: "", key: "" };
    } catch (failure) {
      setMessage(failure instanceof Error && failure.name === "Error" ? failure.message : "Ответ не получен. Повторите отправку с теми же данными.");
    } finally {
      clearTimeout(timeout); busy.current = false; setBusy(false);
    }
    });
  });
</script>

React

ContactForm.tsx
import { useRef, useState } from "react";

export default function ContactForm() {
  const retry = useRef({ payload: "", key: "" }), busy = useRef(false);
  const [message, setMessage] = useState(""), [sending, setBusy] = useState(false);
  return (
    <form aria-busy={sending} onSubmit={async (event) => {
        if (!globalThis.crypto?.randomUUID || !globalThis.fetch) return;
        event.preventDefault(); if (busy.current) return;
        const form = event.currentTarget;
        busy.current = true; setBusy(true); setMessage("Сохраняем заявку…");
    const payload = new FormData(form);
    const fingerprint = JSON.stringify([...payload.entries()]);
    if (retry.current.payload !== fingerprint) retry.current = { payload: fingerprint, key: crypto.randomUUID() };
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 15000);
    try {
      const response = await fetch(form.action, {
        method: "POST", body: payload, credentials: "omit",
        headers: { Accept: "application/json", "Idempotency-Key": retry.current.key },
        signal: controller.signal
      });
      const result = await response.json().catch(() => null);
      const requestId = [response.headers.get("X-Request-Id"), result?.request_id].find((value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value));
      if (![200, 201].includes(response.status) || result?.success !== true || result?.status !== "accepted" || typeof result?.submission_id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result.submission_id)) {
        const reason = response.status === 429 ? "Слишком много отправок. Подождите и повторите." : response.status === 404 ? "Проверьте публичный ключ и активность формы." : response.status === 400 ? "Проверьте поля и настройки формы." : "Не удалось подтвердить сохранение. Повторите попытку.";
        throw new Error(reason + (requestId ? " Код запроса: " + requestId : ""));
      }
      const unchanged = JSON.stringify([...new FormData(form).entries()]) === fingerprint;
      const confirmed = result.test ? "Тестовая заявка сохранена. Обычные уведомления отключены." : "Заявка сохранена. Доставка уведомления проверяется отдельно.";
      setMessage(unchanged ? confirmed : confirmed + " Сохранена предыдущая версия. Новые правки остались в форме; отправьте их отдельно.");
      if (unchanged) form.reset();
      retry.current = { payload: "", key: "" };
    } catch (failure) {
      setMessage(failure instanceof Error && failure.name === "Error" ? failure.message : "Ответ не получен. Повторите отправку с теми же данными.");
    } finally {
      clearTimeout(timeout); busy.current = false; setBusy(false);
    }
      }} action="https://app.formhunt.ru/v1/submit" method="POST">
      <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
      <label htmlFor="email">Ваша почта</label>
      <input id="email" type="email" name="email" required />
      <label htmlFor="message">Сообщение</label>
      <textarea id="message" name="message" required />
      <p role="status" aria-live="polite">{message}</p>
      <button type="submit" disabled={sending}>Отправить</button>
    </form>
  );
}

Astro

src/components/ContactForm.astro
<form data-formhunt action="https://app.formhunt.ru/v1/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY">
  <label for="email">Ваша почта</label>
  <input id="email" type="email" name="email" required>
  <label for="message">Сообщение</label>
  <textarea id="message" name="message" required></textarea>
  <p role="status" aria-live="polite" data-formhunt-status></p>
  <button type="submit">Отправить</button>
</form>
<script is:inline>
  document.querySelectorAll("form[data-formhunt]").forEach((form) => {
    if (form.dataset.formhuntReady) return;
    form.dataset.formhuntReady = "true";
    const output = form.querySelector("[data-formhunt-status]");
    const button = form.querySelector('button[type="submit"]');
    const retry = { current: { payload: "", key: "" } }, busy = { current: false };
    const setMessage = (message) => { output.textContent = message; };
    const setBusy = (value) => { button.disabled = value; form.setAttribute("aria-busy", String(value)); };
    form.addEventListener("submit", async (event) => {
      if (!globalThis.crypto?.randomUUID || !globalThis.fetch) return;
      event.preventDefault(); if (busy.current) return;
      busy.current = true; setBusy(true); setMessage("Сохраняем заявку…");
    const payload = new FormData(form);
    const fingerprint = JSON.stringify([...payload.entries()]);
    if (retry.current.payload !== fingerprint) retry.current = { payload: fingerprint, key: crypto.randomUUID() };
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 15000);
    try {
      const response = await fetch(form.action, {
        method: "POST", body: payload, credentials: "omit",
        headers: { Accept: "application/json", "Idempotency-Key": retry.current.key },
        signal: controller.signal
      });
      const result = await response.json().catch(() => null);
      const requestId = [response.headers.get("X-Request-Id"), result?.request_id].find((value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value));
      if (![200, 201].includes(response.status) || result?.success !== true || result?.status !== "accepted" || typeof result?.submission_id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result.submission_id)) {
        const reason = response.status === 429 ? "Слишком много отправок. Подождите и повторите." : response.status === 404 ? "Проверьте публичный ключ и активность формы." : response.status === 400 ? "Проверьте поля и настройки формы." : "Не удалось подтвердить сохранение. Повторите попытку.";
        throw new Error(reason + (requestId ? " Код запроса: " + requestId : ""));
      }
      const unchanged = JSON.stringify([...new FormData(form).entries()]) === fingerprint;
      const confirmed = result.test ? "Тестовая заявка сохранена. Обычные уведомления отключены." : "Заявка сохранена. Доставка уведомления проверяется отдельно.";
      setMessage(unchanged ? confirmed : confirmed + " Сохранена предыдущая версия. Новые правки остались в форме; отправьте их отдельно.");
      if (unchanged) form.reset();
      retry.current = { payload: "", key: "" };
    } catch (failure) {
      setMessage(failure instanceof Error && failure.name === "Error" ? failure.message : "Ответ не получен. Повторите отправку с теми же данными.");
    } finally {
      clearTimeout(timeout); busy.current = false; setBusy(false);
    }
    });
  });
</script>

Актуальная схема OpenAPI · Инструкция llms.txt для агента

1. Получите публичный ключ

Создайте форму в личном кабинете и скопируйте access_key. Это публичный ключ для отправки: он может находиться в браузере, но не даёт доступа к управлению или чтению заявок. Управляющий token, пароль и серверные секреты никогда не вставляйте в клиентский код.

2. Добавьте пример на сайт

Выберите стек в блоке выше. HTML сохраните на странице контактов. React компонент импортируйте в приложение. Astro компонент добавьте на статическую страницу: server route и hydration не требуются. Все варианты используют один адрес с путём /v1/submit и скрытый access_key. Адрес берётся из PUBLIC_API_URL при сборке этой инструкции.

Сохраняйте name у своих полей. Поддерживаются строки и повторяющиеся текстовые поля, JSON, urlencoded и multipart. Файлы и вложенные объекты отклоняются. Тело ограничено 65536 bytes, число имён 100, имя 64 символами ASCII, значение 8192 UTF-8 bytes и повтор 20 значениями. botcheck можно добавить пустым: заполненное поле отклоняется как спам.

3. Отправьте синтетическую проверку

Используйте вымышленные значения и адрес из домена .test. Для безопасной проверки добавьте в форму <input type="hidden" name="test" value="true">, а в React закройте input как />. JSON принимает test: true. Тестовая заявка сохраняется с отдельным признаком, не расходует коммерческий usage и не запускает обычные уведомления или webhooks. Технические ограничения запросов действуют и в этом режиме.

Найдите тестовую заявку в кабинете и проверьте поля. Затем уберите hidden test, чтобы принимать обычные обращения. Проверку уведомления владельцу выполняйте отдельным явным действием на подтверждённый адрес. Сообщение «сохранена» подтверждает запись, а не доставку письма или webhook.

Успех, ошибка и повтор

JavaScript примеров проверяет HTTP ответ, success: true, status: "accepted" и submission_id. При отказе ввод остаётся на странице, кнопка снова доступна. Ответ 400 означает ошибку полей, 404 требует проверить ключ и активность формы, 413 означает превышение размера, 415 неподдерживаемый формат, 429 просит подождать перед повтором, 503 означает временную недоступность приёма. Безопасный код запроса помогает найти ошибку без передачи текста заявки.

Каждая попытка неизменных данных с JavaScript использует тот же Idempotency-Key, в том числе после потери ответа. Replay возвращает прежний submission_id; другой payload с тем же ключом получает 409. Срок идемпотентности 24 часа в пределах формы. Изменённые поля и новая отправка после подтверждения получают новый ключ. После перезагрузки страницы ключ в памяти теряется, поэтому не повторяйте неопределённую отправку после reload, пока не проверите кабинет.

HTML и Astro сохраняют нативный POST без JavaScript: браузер переходит на подтверждённую страницу результата после commit. Ошибка сервера в этом режиме отображается его безопасным JSON ответом; вернитесь назад, чтобы исправить поля. Нативная отправка не добавляет Idempotency-Key автоматически. Если нужен свой redirect, заранее разрешите точный HTTPS origin в настройках формы и добавьте hidden redirect.

Контракт для вашего агента

Начните с машиночитаемой инструкции, затем используйте актуальный /openapi.json на origin API, указанном в коде. Публичный POST не требует сессии и не должен отправлять cookies. CORS позволяет внешний fetch только для публичного приёма; Origin и ключ формы не дают management прав. Подключать отдельный backend для сайта не нужно.

Обновлено

Следующий шаг

Продолжите подключение

Начните с формы

Ваш сайт уже есть.
Подключим форму.

Откройте личный кабинет, затем передайте агенту промпт подключения.

Подключить форму