Store Builder

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. fields is empty and code carries the server's own code — today cart_empty and cart_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. status is 0, fields is empty, there is no code. That is the only shape fetch has 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