Hooking your own code into a form
The three DOM events a form fires on a published page — which you can listen to, and cancel — so code you write can add rules that belong to your shop.
Some rules no switch in the editor can make for you, because they are rules about your shop rather than about the shape of an answer: no booking less than two hours ahead, none of the ten disposable email domains you are tired of, ask the warehouse before accepting the order. This page is how you write them.
The place the code goes is the Custom code element (vi: Mã tùy chỉnh) — drag it from the elements panel onto any page, or put it in a global section. The form itself is Forms; if all you need is "this box has to be digits", the Format group there already does it, and it is not worth code.
Three events
On a published page, every form fires three DOM events:
| Event | Fired |
|---|---|
wb:form:submit |
After every platform rule has passed, and before the send |
wb:form:success |
The server accepted |
wb:form:error |
The server refused |
They are ordinary CustomEvents and they bubble from the form's own block, so
listening at document is enough — no global of ours, no waiting for our
bundle, no script-order contract:
document.addEventListener('wb:form:success', (e) => {
gtag('event', 'generate_lead', { form: e.detail.formId });
});
There is deliberately no window.WB. The person writing that snippet is
somebody we have never met, so the API has to be one they already know. It is
the same convention the product filters (wb:filter) already use.
e.detail.formId says which form fired — it is the value of the block's
data-wb-form attribute, and it is worth checking on a page holding more than
one form.
Cancelling a submission
wb:form:submit is cancelable. That is the half Format cannot cover:
document.addEventListener('wb:form:submit', (e) => {
const { values, reject } = e.detail;
if (values.email.endsWith('@mailinator.com')) {
reject('email', 'Please use an address you actually read.');
}
});
values is keyed by Field ID — the first box on each field's Advanced
tab, and the same thing that becomes the column name in your export. Settle
those IDs before you write code against them: renaming one later renames the key
here.
reject(<field id>, <message>) does two things: it cancels the submission
and it puts the message beside that field, using the same mark a
platform-side error uses. That matters more than it looks. Calling
e.preventDefault() on its own also cancels, but it cancels in silence — a
visitor presses the button and nothing happens, which is the worst failure a
page can have. Bare preventDefault() still works, and then the silence is your
choice rather than an accident of the API. Passing a field id no field carries
puts the message in the form's general notice instead of swallowing it.
Upload fields are not in values — a file input's value is a fake path
(C:\fakepath\cv.pdf in every browser), and storing that would store a lie.
Instead e.detail.files is the list of field names carrying a file, in the
order they were chosen; a field with three files appears three times. Enough to
know whether files are there and how many, not enough to read one.
You can listen and refuse, but not rewrite
values is a snapshot. Writing to it changes nothing that gets sent, and that
is on purpose: a stored submission that is not what the visitor typed is a
support case nobody can reconstruct, and the visitor never agreed to it. If a
value needs to change, change it where the visitor can see it happen — fix the
field, fix the options, or handle it after the submission reaches you.
And none of this is enforcement
This code runs on a stranger's page, on their machine, in a browser whose devtools they can open. The server re-decides every rule of its own on every submission. A rule you write here is a courtesy to an honest customer — exactly like the platform's own browser-side rules. What actually stops somebody determined lives on your side, after the submission arrives.
After the send
wb:form:success fires the moment the server accepts, before anything that
navigates. A form can be set to send the visitor to a thank-you page, and an
event fired after that would lose half your conversion measurements to a page
that had already gone. Its detail carries formId and values.
wb:form:error fires for every submission that does not succeed, carrying
status and fields — the per-field reasons in the words the server used
(required, bad_format, too_many_files…).
document.addEventListener('wb:form:error', (e) => {
const { status, fields, code } = e.detail;
console.warn('form refused', status, code ?? fields);
});
Two cases name no field, and both still fire — otherwise your count would be short:
- A form-level refusal.
fieldsis empty andcodecarries the server's own code — todaycart_emptyandcart_line_unavailable, on an order form sourced from the whole cart. Nothing the visitor typed was wrong, so there is no box to mark. - A request that never reached the server — the visitor lost their
connection mid-send.
statusis 0,fieldsis empty, there is nocode. That is the only shapefetchhas when there is no reply to report.
A snippet you can paste
<script>
document.addEventListener('wb:form:submit', (e) => {
const { formId, values, reject } = e.detail;
if (formId !== 'book-a-table') return;
const when = new Date(values.arriving_at);
if (when.getTime() - Date.now() < 2 * 60 * 60 * 1000) {
reject('arriving_at', 'Please book at least 2 hours ahead.');
}
});
</script>
Paste it into a Custom code block, on the page holding the form, or in a global section if the form appears on several pages. It runs on the published page only, never on the editor canvas — so test it with Preview, rather than waiting to see something while you build.
Updated 05/09/2026