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.
Zanim zaczniesz
Dział zatytułowany „Zanim zaczniesz”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 |
Jak wygląda przepływ
Dział zatytułowany „Jak wygląda przepływ”- (opcjonalnie)
GET /api/v1/public/forms/{formId}pobiera definicję opublikowanego formularza. - Gość wchodzi w interakcję z formularzem. Wtedy, nie wcześniej, pobierasz token:
GET /api/v1/public/forms/{formId}/token. - Gość klika „Wyślij“. Od pobrania tokenu musiały minąć co najmniej 2 sekundy, w praktyce czekaj 3.
POST /api/v1/public/forms/{formId}/leadsz polami, metadanymi i kopertą antybotową.- Odpowiedź
201zredirectUrl: przekierowujesz albo pokazujesz podziękowanie.
Krok 0: przygotowanie w panelu
Dział zatytułowany „Krok 0: przygotowanie w panelu”Zanim cokolwiek zadziała:
- Formularz musi być aktywny i opublikowany. Nieopublikowany zwraca
404na definicji i409na 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.
Krok 1: definicja formularza
Dział zatytułowany „Krok 1: definicja formularza”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.
Krok 2: honeypot w markupie
Dział zatytułowany „Krok 2: honeypot w markupie”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.
Krok 3: token antybotowy
Dział zatytułowany „Krok 3: token antybotowy”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
403inne 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.
Krok 4: wysyłka
Dział zatytułowany „Krok 4: wysyłka”POST https://app.octaforms.pl/api/v1/public/forms/{formId}/leadsContent-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-urlz 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.referrerzdocument.referrer.- Parametry kampanii (
utm_source,utm_campaigni 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.
antibot
Dział zatytułowany „antibot”| Klucz | Wartość |
|---|---|
hp |
Wartość pola honeypot. Dla człowieka zawsze pusty string. |
t |
Token z kroku 3. |
Limity koperty
Dział zatytułowany „Limity koperty”| Limit | Wartość |
|---|---|
| Rozmiar żądania | 64 KB |
Liczba kluczy w fields |
200 |
Liczba kluczy w meta |
100 |
Krok 5: obsługa odpowiedzi
Dział zatytułowany „Krok 5: obsługa odpowiedzi”| 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.
Kompletny przykład w Astro
Dział zatytułowany „Kompletny przykład w Astro”Trzy pliki: zmienna środowiskowa, komponent, użycie.
PUBLIC_OCTAFORMS_API=https://app.octaforms.plPUBLIC_OCTAFORMS_FORM_ID=0195c7f1-9b3a-7c4d-a0e2-5f1b2c3d4e5fPrefiks 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.
src/components/OctaForm.astro
Dział zatytułowany „src/components/OctaForm.astro”---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.
Szczegóły po stronie Astro
Dział zatytułowany „Szczegóły po stronie Astro”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.
Limity zapytań
Dział zatytułowany „Limity zapytań”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ę.
CORS i lista domen
Dział zatytułowany „CORS i lista domen”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
403z 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.plitwoja-strona.plto dwie różne domeny. Dodaj oba, jeśli oba są używane. Wpis*.twoja-strona.plobejmuje subdomeny, ale nie domenę główną. - Nie zapomnij o adresach deweloperskich (
localhost), jeśli testujesz lokalnie na produkcyjnym formularzu z listą domen.
Analityka
Dział zatytułowany „Analityka”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.
Checklista wdrożeniowa
Dział zatytułowany „Checklista wdrożeniowa”- Formularz w panelu jest aktywny i opublikowany.
- Nazwy pól w markupie zgadzają się z
fields[].namezGET /api/v1/public/forms/{formId}. - Pole honeypot jest w markupie, ukryte przez pozycjonowanie poza ekranem, z
tabindex="-1"iaria-hidden="true". - Token pobierany leniwie, przy pierwszej interakcji, nie przy załadowaniu strony.
- Przed wysyłką odczekane 3 sekundy od pobrania tokenu.
- Ponowienie przy
403ztype: ".../too-fast", ten sam token. -
403bez tegotypeczyści token i pokazujedetailz odpowiedzi. -
422renderujeviolationsprzy polach, a komunikaty pól nieobecnych w markupie trafiają do wspólnego bannera. - Do
fieldstrafiają wyłącznie nazwy pól formularza, reszta idzie dometa. -
meta['form-url']jest ustawione (powiadomienia Slack z niego korzystają). - Checkbox wysyłany jako
"1"albo"". -
redirectUrlz odpowiedzi jest obsługiwane, a brakredirectUrlpokazuje 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.
Częste problemy
Dział zatytułowany „Częste problemy”| 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 |