Przejdź do głównej zawartości

Własny frontend (Astro)

Widget nie jest jedyną drogą. Publiczne API (/api/v1/public/...) jest anonimowe i otwarte na CORS, więc możesz zbudować własny formularz w Astro (albo w czymkolwiek innym) i wysyłać zgłoszenia bezpośrednio z przeglądarki gościa. Ta strona opisuje kompletne wdrożenie: co dokładnie robi widget, co musisz odtworzyć i gdzie są pułapki.

Jeśli chcesz tylko wstawić gotowy formularz, zostań przy osadzaniu widgetu, to kilka linijek zamiast kilkuset.

Własny frontend daje pełną kontrolę nad wyglądem i strukturą HTML. W zamian przejmujesz obowiązki, które widget wykonuje sam:

Co robi widget Co musisz zrobić sam
Pobiera definicję formularza i renderuje pola Piszesz markup i pilnujesz zgodności nazw pól
Ponawia pobranie definicji (2 s, 5 s) przy błędzie sieci Nic nie ponawia, chyba że dopiszesz
Obsługuje handshake antybotowy (token, honeypot, opóźnienie) Cały handshake, opisany niżej
Waliduje required w przeglądarce, resztę pokazuje z odpowiedzi serwera Walidacja wstępna i renderowanie błędów 422
Ponawia wysyłkę odrzuconą jako „za szybka“ Ponowienie, inaczej gość zobaczy błąd zamiast podziękowania
Emituje eventy octaforms:* do GTM Emitujesz sam, jeśli mierzysz konwersje
Zgłasza błędy wysyłki do monitoringu operatora Własny monitoring
Obsługuje przekierowanie na stronę podziękowania Obsługa redirectUrl z odpowiedzi
  1. (opcjonalnie) GET /api/v1/public/forms/{formId} pobiera definicję opublikowanego formularza.
  2. Gość wchodzi w interakcję z formularzem. Wtedy, nie wcześniej, pobierasz token: GET /api/v1/public/forms/{formId}/token.
  3. Gość klika „Wyślij“. Od pobrania tokenu musiały minąć co najmniej 2 sekundy, w praktyce czekaj 3.
  4. POST /api/v1/public/forms/{formId}/leads z polami, metadanymi i kopertą antybotową.
  5. Odpowiedź 201 z redirectUrl: przekierowujesz albo pokazujesz podziękowanie.

Zanim cokolwiek zadziała:

  • Formularz musi być aktywny i opublikowany. Nieopublikowany zwraca 404 na definicji i 409 na wysyłce. Każda zmiana pól w panelu wymaga ponownej publikacji, bo zgłoszenie jest zawsze walidowane opublikowaną wersją, nigdy szkicem.
  • Zapisz identyfikator formularza (widoczny w panelu, w zakładce „Osadź formularz“).
  • Jeśli używasz listy dozwolonych domen, dopisz do niej domenę strony w Astro, razem z wariantem www, jeśli oba są używane. Szczegóły w sekcji CORS i lista domen.
GET https://app.octaforms.pl/api/v1/public/forms/{formId}

Odpowiedź:

{
"id": "0195c7f1-9b3a-7c4d-a0e2-5f1b2c3d4e5f",
"name": "Formularz kontaktowy",
"version": 4,
"submitLabel": "Wyślij zapytanie",
"thankYou": { "title": "Dziękujemy!", "text": "Odezwiemy się w ciągu 24 godzin." },
"fields": [
{
"name": "email",
"type": "email",
"options": { "label": "Adres e-mail", "placeholder": "jan@example.com" },
"rules": [
{ "rule": "required", "message": "Podaj adres e-mail" },
{ "rule": "email", "message": "To nie wygląda na adres e-mail" }
]
}
]
}
Klucz Znaczenie
version Numer opublikowanej wersji. Przydatny w logach, gdy diagnozujesz rozjazd definicji.
submitLabel Napis na przycisku ustawiony w panelu, może być null.
thankYou Treść podziękowania (title, text), oba klucze opcjonalne, całość może być null.
fields[].name Klucz, którego użyjesz w fields przy wysyłce.
fields[].type text, textarea, email, phone, number, select, radio, checkbox, date, hidden.
fields[].options label, placeholder, a dla select i radio również choices. Backend nie waliduje zawartości.
fields[].rules Lista {rule, message}. Reguła bool_required jest wycinana, to szczegół implementacyjny checkboxa.

Odpowiedź jest cache’owana (max-age=60, ETag), więc możesz ją pobierać przy każdym wejściu na stronę bez obaw o obciążenie.

Honeypot to pułapka: pole niewidoczne dla człowieka, które naiwny bot wypełnia. Wartość wysyłasz jako antibot.hp. Jeżeli jest niepusta, serwer cicho odrzuca zgłoszenie i odpowiada nieodróżnialnie od sukcesu.

<div class="octa-hp" aria-hidden="true">
<label>
Nie wypełniaj tego pola
<input type="text" name="octa_url" tabindex="-1" autocomplete="off" />
</label>
</div>
/* Poza ekran, nie display:none ani hidden: część botów pomija ukryte pola,
a to pole ma być dla nich kuszące. Człowiek go nie zobaczy, nie dojdzie
do niego tabem i nie usłyszy w czytniku ekranu. */
.octa-hp {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}

Nazwa pola jest dowolna, ale niech wygląda kusząco dla bota (octa_url, website, company_url). Ważne, żeby nie kolidowała z prawdziwym polem formularza.

GET https://app.octaforms.pl/api/v1/public/forms/{formId}/token
{ "token": "1753084800.9f2c…" }

Token to podpisany serwerowo znacznik czasu. Odesłanie go przy wysyłce dowodzi, że przed POST-em było GET, co odsiewa boty strzelające prosto w endpoint.

Zasady:

  • Pobieraj leniwie, przy pierwszej interakcji gościa z formularzem, a nie przy załadowaniu strony. Wiek tokenu ma przybliżać czas spędzony nad formularzem. Pobranie na starcie sprawia, że każdy bot automatycznie ma token „dojrzały“.
  • Od pobrania do wysyłki muszą minąć co najmniej 2 sekundy (parametr instancji ANTIBOT_MIN_SECONDS, domyślnie 2). Widget czeka 3 sekundy, żeby mieć zapas na rozjazd zegarów, i to samo polecamy Tobie.
  • Token jest ważny 6 godzin i w tym oknie można go użyć wielokrotnie. Nie musisz pobierać nowego przy każdej próbie. Jeśli dostaniesz 403 inne niż too-fast, pobierz świeży token i ponów wysyłkę raz.
  • Endpoint jest no-store, nie cache’uj go po swojej stronie ani nie wstawiaj tokenu do statycznego HTML.
  • Token jest przypisany do formularza. Na stronie z dwoma formularzami potrzebujesz dwóch tokenów.
POST https://app.octaforms.pl/api/v1/public/forms/{formId}/leads
Content-Type: application/json
{
"fields": {
"name": "Jan Kowalski",
"email": "jan@example.com",
"consent": "1"
},
"meta": {
"form-url": "https://twoja-strona.pl/kontakt",
"referrer": "https://www.google.com/"
},
"antibot": {
"hp": "",
"t": "1753084800.9f2c…"
}
}

Klucze to dokładnie wartości name z definicji formularza.

Zapis wartości:

Typ pola Co wysłać
text, textarea, email, phone, date, hidden String. Pusty string znaczy „brak wartości“.
number String albo liczba, obie formy przechodzą.
select, radio Wybrana wartość jako string.
checkbox "1" albo true gdy zaznaczony, "" gdy nie. Obie formy zaznaczenia są akceptowane.

Reguła required liczy wartość jako obecną, gdy po obcięciu białych znaków ma długość większą od zera. Pozostałe reguły (email, phone, min, max, nip, postCodePL, numeric) są pomijane dla pól pustych, więc pole nieobowiązkowe i puste nigdy nie wywoła błędu formatu.

Dowolna mapa, nie jest walidowana i nie trafia do reguł. Trzy rzeczy warto tam wysyłać:

  • form-url z pełnym adresem strony. Powiadomienia Slack używają tego klucza, żeby pokazać, z jakiej strony przyszło zgłoszenie, i żeby dobrać ikonę wiadomości. Bez niego powiadomienie będzie uboższe.
  • referrer z document.referrer.
  • Parametry kampanii (utm_source, utm_campaign i podobne), jeśli ich potrzebujesz w webhooku.

Metadane nie są anonimizowane w payloadzie webhooka, więc nie wkładaj tam danych osobowych, które nie powinny wyjść na zewnątrz.

Klucz Wartość
hp Wartość pola honeypot. Dla człowieka zawsze pusty string.
t Token z kroku 3.
Limit Wartość
Rozmiar żądania 64 KB
Liczba kluczy w fields 200
Liczba kluczy w meta 100
Status Znaczenie Co zrobić
201 Zgłoszenie przyjęte, ciało: { "redirectUrl": string | null } Przekieruj na redirectUrl, a przy null pokaż podziękowanie
403 z type: ".../too-fast" Za szybko po pobraniu tokenu Odczekaj i wyślij ten sam token jeszcze raz
403 bez tego type Token nieważny, wygasły, podrobiony albo domena spoza listy Pobierz nowy token i pozwól ponowić. Pokaż detail z odpowiedzi
404 Formularz nie istnieje albo jest usunięty Błąd konfiguracji, zgłoś do siebie, nie do gościa
409 Formularz nieaktywny albo bez opublikowanej wersji Jak wyżej
422 Błąd walidacji, ciało zawiera violations Pokaż komunikaty przy polach
429 Przekroczony limit zapytań Poproś o ponowienie za chwilę
błąd sieci Brak połączenia albo zablokowany CORS Komunikat ogólny, patrz sekcja o CORS

Błędy mają format application/problem+json (RFC 9457):

{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Validation failed.",
"violations": [
{ "field": "email", "message": "Podaj adres e-mail", "code": "required" }
]
}

Komunikaty w violations[].message pochodzą z konfiguracji formularza w panelu, są pisane dla użytkownika końcowego i nadają się do pokazania wprost. Pole detail przy 403 i 429 też jest napisane dla gościa.

Trzy pliki: zmienna środowiskowa, komponent, użycie.

PUBLIC_OCTAFORMS_API=https://app.octaforms.pl
PUBLIC_OCTAFORMS_FORM_ID=0195c7f1-9b3a-7c4d-a0e2-5f1b2c3d4e5f

Prefiks PUBLIC_ jest konieczny, bo wartości używa kod działający w przeglądarce. Nie ma tu nic tajnego: identyfikator formularza i tak jest widoczny w kodzie strony.

---
interface Props {
formId?: string;
apiBase?: string;
}
const {
formId = import.meta.env.PUBLIC_OCTAFORMS_FORM_ID,
apiBase = import.meta.env.PUBLIC_OCTAFORMS_API,
} = Astro.props;
---
<form class="octa" data-octa-form={formId} data-octa-api={apiBase} novalidate>
<p class="octa__banner" data-octa-banner hidden role="alert"></p>
{/* Honeypot: niewidoczny dla człowieka, kuszący dla bota. */}
<div class="octa-hp" aria-hidden="true">
<label>
Nie wypełniaj tego pola
<input type="text" name="octa_url" tabindex="-1" autocomplete="off" />
</label>
</div>
<div class="octa__row">
<label for="octa-name">Imię i nazwisko</label>
<input id="octa-name" name="name" type="text" autocomplete="name" required />
<p class="octa__error" data-octa-error="name" hidden></p>
</div>
<div class="octa__row">
<label for="octa-email">Adres e-mail</label>
<input id="octa-email" name="email" type="email" autocomplete="email" required />
<p class="octa__error" data-octa-error="email" hidden></p>
</div>
<div class="octa__row">
<label for="octa-message">Wiadomość</label>
<textarea id="octa-message" name="message" rows="5"></textarea>
<p class="octa__error" data-octa-error="message" hidden></p>
</div>
<div class="octa__row octa__row--check">
<label>
<input name="consent" type="checkbox" value="1" required />
Zgadzam się na kontakt w sprawie zapytania
</label>
<p class="octa__error" data-octa-error="consent" hidden></p>
</div>
<button type="submit">Wyślij</button>
<div class="octa__thanks" data-octa-thanks hidden>
<h3>Dziękujemy!</h3>
<p>Odezwiemy się w ciągu 24 godzin.</p>
</div>
</form>
<style>
.octa-hp {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
<script>
// Zapas nad serwerowym minimum (ANTIBOT_MIN_SECONDS, domyślnie 2 s):
// pokrywa rozjazd zegarów między przeglądarką a serwerem.
const MIN_TOKEN_AGE_MS = 3000;
const TOO_FAST = 'https://octaforms.pl/problems/too-fast';
const HONEYPOT = 'octa_url';
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function initForm(form: HTMLFormElement) {
if (form.dataset.octaReady === '1') return;
form.dataset.octaReady = '1';
const api = form.dataset.octaApi!;
const formId = form.dataset.octaForm!;
const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
const banner = form.querySelector<HTMLElement>('[data-octa-banner]')!;
const thanks = form.querySelector<HTMLElement>('[data-octa-thanks]')!;
let token: string | null = null;
let tokenAt = 0;
let minting: Promise<void> | null = null;
function mintToken(): Promise<void> {
if (token) return Promise.resolve();
if (!minting) {
minting = fetch(`${api}/api/v1/public/forms/${formId}/token`)
.then((res) => {
if (!res.ok) throw new Error(`token ${res.status}`);
return res.json() as Promise<{ token: string }>;
})
.then((body) => {
token = body.token;
tokenAt = Date.now();
})
.finally(() => {
minting = null;
});
}
return minting;
}
// Leniwe pobranie tokenu: dopiero gdy gość dotknie formularza, żeby wiek
// tokenu odpowiadał czasowi spędzonemu nad wypełnianiem.
form.addEventListener(
'focusin',
(event) => {
if ((event.target as HTMLElement).closest('.octa-hp')) return;
void mintToken().catch(() => {});
},
{ once: true },
);
function setBanner(message: string | null) {
banner.textContent = message ?? '';
banner.hidden = message === null;
}
function clearErrors() {
form.querySelectorAll<HTMLElement>('[data-octa-error]').forEach((el) => {
el.textContent = '';
el.hidden = true;
});
}
function showViolations(violations: Array<{ field: string; message: string }>) {
const orphans: string[] = [];
for (const violation of violations) {
const target = form.querySelector<HTMLElement>(`[data-octa-error="${violation.field}"]`);
// Błąd pola, którego nie ma w markupie (np. dodanego w panelu), musi
// trafić do bannera, inaczej wysyłka kończy się w ciszy.
if (!target) {
orphans.push(violation.message);
continue;
}
if (target.textContent) continue; // pierwszy komunikat na pole wygrywa
target.textContent = violation.message;
target.hidden = false;
}
if (orphans.length > 0) setBanner(orphans.join(' '));
}
function collectFields(): Record<string, string> {
const fields: Record<string, string> = {};
for (const element of Array.from(form.elements)) {
const input = element as HTMLInputElement;
if (!input.name || input.name === HONEYPOT) continue;
if (input.type === 'checkbox') {
fields[input.name] = input.checked ? '1' : '';
} else if (input.type === 'radio') {
if (!(input.name in fields)) fields[input.name] = '';
if (input.checked) fields[input.name] = input.value;
} else {
fields[input.name] = input.value;
}
}
return fields;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (button.disabled) return;
clearErrors();
setBanner(null);
button.disabled = true;
const honeypot = String(new FormData(form).get(HONEYPOT) ?? '');
const payload = {
fields: collectFields(),
meta: { 'form-url': location.href, referrer: document.referrer },
antibot: { hp: honeypot, t: '' as string | null },
};
try {
await mintToken();
payload.antibot.t = token;
const age = Date.now() - tokenAt;
if (age < MIN_TOKEN_AGE_MS) await sleep(MIN_TOKEN_AGE_MS - age);
const send = async () => {
const res = await fetch(`${api}/api/v1/public/forms/${formId}/leads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return { res, body: await res.json().catch(() => null) };
};
// Jedno ponowienie dla „za szybko": ten sam token, tylko starszy.
let out = await send();
if (out.res.status === 403 && out.body?.type === TOO_FAST) {
await sleep(MIN_TOKEN_AGE_MS);
out = await send();
}
const { res, body } = out;
if (res.ok) {
if (body?.redirectUrl) {
location.href = body.redirectUrl;
return;
}
form.querySelectorAll<HTMLElement>('.octa__row, button[type="submit"]').forEach((el) => {
el.hidden = true;
});
thanks.hidden = false;
return;
}
if (res.status === 422) {
showViolations(body?.violations ?? []);
return;
}
if (res.status === 403) {
// Token nieważny albo domena spoza listy. Nowa próba wymaga nowego tokenu.
token = null;
setBanner(body?.detail ?? 'Nie udało się zweryfikować formularza. Odśwież stronę i spróbuj ponownie.');
return;
}
if (res.status === 429) {
setBanner('Zbyt wiele prób. Spróbuj ponownie za chwilę.');
return;
}
setBanner(body?.detail ?? 'Nie udało się wysłać formularza. Spróbuj ponownie za chwilę.');
} catch {
setBanner('Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie.');
} finally {
button.disabled = false;
}
});
}
function initAll() {
document.querySelectorAll<HTMLFormElement>('form[data-octa-form]').forEach(initForm);
}
initAll();
// Przy włączonym ClientRouter skrypt wykonuje się raz, a nawigacja podmienia
// DOM. Bez tego formularz na kolejnej podstronie byłby martwy.
document.addEventListener('astro:page-load', initAll);
</script>
---
import OctaForm from '../components/OctaForm.astro';
---
<OctaForm />

Komponentu możesz użyć wiele razy na stronie, także z różnymi identyfikatorami formularzy (<OctaForm formId="..." />). Skrypt jest bundlowany raz, a każda instancja jest inicjalizowana osobno, bo konfiguracja siedzi w atrybutach data-*, nie w define:vars.

Strona może być statyczna. Cała komunikacja dzieje się w przeglądarce gościa, więc output: 'static' w zupełności wystarczy. SSR nie jest do niczego potrzebny.

View Transitions. Jeśli używasz <ClientRouter />, skrypty modułowe wykonują się raz na sesję, a nawigacja podmienia DOM. Dlatego przykład powyżej nasłuchuje astro:page-load i pilnuje data-octa-ready, żeby nie podpiąć obsługi dwa razy do tego samego formularza.

Konfiguracja przez data-*, nie przez define:vars. Użycie define:vars w <script> wyłącza przetwarzanie i bundlowanie skryptu przez Astro (staje się is:inline), więc przy wielu instancjach komponentu skrypt zostałby powielony. Atrybuty data-octa-form i data-octa-api na elemencie <form> załatwiają to samo bez efektów ubocznych.

Framework zamiast vanilla. Ta sama logika przenosi się 1:1 do wyspy React, Vue czy Svelte, z client:load albo client:visible. Wtedy zamiast data-* przekazujesz zwykłe propsy. Trzymaj się jednak zasady o leniwym pobieraniu tokenu: useEffect na mount to dokładnie to, czego robić nie należy.

Wysyłka jest limitowana na trzech poziomach. Każde żądanie liczy się do limitu, także odrzucone (w tym Twoje testy).

Limit Zakres Wartość
Przed uruchomieniem aplikacji IP 25 na minutę
Aplikacja IP plus formularz 20 na minutę
Aplikacja formularz, niezależnie od IP 100 na minutę

Przekroczenie zwraca 429. Limit per formularz (100 na minutę) chroni przed zalaniem skrzynki właściciela i kolejki, przy zwykłym ruchu jest nieosiągalny.

Podczas rozwoju łatwo wyczerpać limit 20 na minutę, klikając „Wyślij“ w kółko. Jeśli w trakcie testów zaczniesz dostawać 429, po prostu odczekaj minutę.

Publiczne endpointy odpowiadają z Access-Control-Allow-Origin: *, więc domyślnie zadziałają z każdej domeny, także z http://localhost:4321.

Preflight dopuszcza wyłącznie nagłówek Content-Type. Nie dodawaj do fetch własnych nagłówków (X-Requested-With, Authorization i podobnych), bo żądanie OPTIONS je odrzuci, a POST nigdy nie wyjdzie z przeglądarki.

Jeżeli formularz ma w panelu ustawioną listę dozwolonych domen, wysyłka respektuje ją per żądanie:

  • Domena z listy: wszystko działa normalnie.
  • Domena spoza listy: serwer nie odsyła nagłówka CORS, więc przeglądarka zablokuje odpowiedź. W konsoli zobaczysz błąd sieci, a nie 403 z czytelnym komunikatem. Jest to celowe, ale przy diagnozie łatwo pomylić to z awarią. Jeśli wysyłka „nie działa i nic nie widać“, najpierw sprawdź listę domen.
  • Wpisy www.twoja-strona.pl i twoja-strona.pl to dwie różne domeny. Dodaj oba, jeśli oba są używane. Wpis *.twoja-strona.pl obejmuje subdomeny, ale nie domenę główną.
  • Nie zapomnij o adresach deweloperskich (localhost), jeśli testujesz lokalnie na produkcyjnym formularzu z listą domen.

Widget emituje zdarzenia DOM w przestrzeni octaforms:, a dołączony mostek przepisuje je do dataLayer. Własny frontend nie emituje niczego. Jeśli masz w GTM skonfigurowane wyzwalacze opisane w Analityce, możesz emitować zgodne zdarzenia sam:

function track(form: HTMLFormElement, name: string, detail: Record<string, unknown> = {}) {
form.dispatchEvent(
new CustomEvent(name, { bubbles: true, detail: { slug: formId, location: null, ...detail } }),
);
}
// Pierwsza interakcja z formularzem, raz na instancję:
track(form, 'octaforms:form-start');
// Po walidacji w przeglądarce, przed wysyłką, raz na gest wysłania:
track(form, 'octaforms:form-submit');
// Po odpowiedzi 2xx, ZANIM wykonasz przekierowanie:
track(form, 'octaforms:form-success');
// Przy ostatecznym błędzie (nie przy ponowieniu „za szybko"):
track(form, 'octaforms:form-error', { status: 422 });
track(form, 'octaforms:form-error', { status: 0, reason: 'network' });

Nazwy zdarzeń są kontraktem wspólnym dla wszystkich produktów OctaForms, nie zmieniaj ich, jeśli chcesz korzystać z gotowej konfiguracji GTM. octaforms:form-success wyślij synchronicznie przed location.href = ..., inaczej przekierowanie zdąży przerwać wysyłkę zdarzenia.

  • Formularz w panelu jest aktywny i opublikowany.
  • Nazwy pól w markupie zgadzają się z fields[].name z GET /api/v1/public/forms/{formId}.
  • Pole honeypot jest w markupie, ukryte przez pozycjonowanie poza ekranem, z tabindex="-1" i aria-hidden="true".
  • Token pobierany leniwie, przy pierwszej interakcji, nie przy załadowaniu strony.
  • Przed wysyłką odczekane 3 sekundy od pobrania tokenu.
  • Ponowienie przy 403 z type: ".../too-fast", ten sam token.
  • 403 bez tego type czyści token i pokazuje detail z odpowiedzi.
  • 422 renderuje violations przy polach, a komunikaty pól nieobecnych w markupie trafiają do wspólnego bannera.
  • Do fields trafiają wyłącznie nazwy pól formularza, reszta idzie do meta.
  • meta['form-url'] jest ustawione (powiadomienia Slack z niego korzystają).
  • Checkbox wysyłany jako "1" albo "".
  • redirectUrl z odpowiedzi jest obsługiwane, a brak redirectUrl pokazuje podziękowanie.
  • Przycisk „Wyślij“ jest blokowany na czas wysyłki (chroni przed podwójnym zgłoszeniem).
  • Błąd sieci ma własny komunikat, inny niż błąd walidacji.
  • Domena strony jest na liście dozwolonych domen, razem z wariantem www, jeśli używany.
  • Wysyłka idzie z przeglądarki, nie przez własny serwer.
Objaw Przyczyna
Wysyłka kończy się błędem sieci, w konsoli błąd CORS Domena spoza listy dozwolonych domen albo własny nagłówek w fetch blokujący preflight
Stale 403, ponowienie nie pomaga Token starszy niż 6 godzin, pobrany dla innego formularza albo pobrany z cache. Sprawdź, czy nie wstawiasz tokenu do statycznego HTML
403 przy pierwszym kliknięciu, drugie przechodzi Brak odczekania po pobraniu tokenu, dopisz ponowienie na type: ".../too-fast"
422 na polu, którego nie ma w Twoim formularzu Ktoś dodał pole wymagane w panelu i opublikował. Porównaj markup z definicją
Zgłoszenia „wysyłają się“, ale nie ma ich w panelu Wypełniony honeypot (skrypt wysyła jakąś wartość w hp) albo formularz wysyłany przez rozszerzenie autouzupełniające. Sprawdź, co dokładnie leci w antibot.hp
409 Formularz nieaktywny albo bez opublikowanej wersji
429 przy testach Wyczerpany limit 20 na minutę na parę IP i formularz, odczekaj minutę
W zgłoszeniu widać utm_source i inne śmieci Wysyłasz je w fields zamiast w meta
422 na polu wielokrotnego wyboru Wartości pól są skalarne. Tablica przy regule min albo max kończy się błędem walidacji, złącz wybory w jeden string
Pole liczbowe z regułą min:5 odrzuca wartość 10 Reguły min i max porównują długość tekstu, chyba że pole ma też regułę numeric. To ustawienie formularza w panelu, nie błąd frontendu