NetSuite · Debugging · 11 August 2026

When the NetSuite form submits itself

The error says your JSON is malformed. Your JSON is fine. The request you are debugging never left the page.

What it looks like

A Suitelet serving a custom HTML app. Click a tab, and instead of the panel loading you get the raw JSON error printed to the screen:

{"ok":false,"error":"Request body was not JSON:
 Unexpected token _ in JSON at position 0"}

Every action does it. Tabs, buttons, everything.

The wrong hour

The message names JSON, so that is where you look. The client sends this:

fetch(BASE, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ action: 'pending' })
});

Which is unambiguously valid JSON. So you check the header. You log the string before it is sent. You wonder whether NetSuite is re-encoding the body, whether the content type is being overridden somewhere, whether there is a proxy in the way.

All of it is wasted, because that request was never made.

Read the character

The error tells you the body starts with an underscore. Nothing in your payload starts with an underscore. So whose body is it?

NetSuite's. Internal form fields on a NetSuite page are named with a leading underscore — and a NetSuite page is exactly what your app is living inside.

When you render a custom app through serverWidget, the HTML goes into an INLINEHTML field on a form:

const form = serverWidget.createForm({ title: 'Workbench' });
form.addField({
    id:    'custpage_app',
    type:  serverWidget.FieldType.INLINEHTML,
    label: 'App'
}).defaultValue = html;
res.writePage(form);

Your markup is now inside a real <form> element that NetSuite owns. And in HTML, a <button> with no type attribute defaults to type="submit".

So this:

<button class="tab" data-t="approve">Pending Approval</button>

submits the enclosing NetSuite form. The browser POSTs NetSuite's own field set back to the Suitelet, your handler tries to parse it as JSON, and it fails on the first underscore. Your click handler may well have fired too — it just lost the race to a full page navigation.

The general shape: when you inject markup into somebody else's form, you inherit their form semantics. This applies to any INLINEHTML field — on a Suitelet page, on a record form, anywhere. The container is not neutral.

The fix

Set the type explicitly on every button:

<button type="button" class="tab" data-t="approve">Pending Approval</button>

Two practical notes. The ones that hide are the buttons built inside JavaScript template strings rather than written in the static markup — those are easy to miss and behave identically. And cancel the default in the handler as well:

btn.addEventListener('click', function (ev) {
    if (ev) ev.preventDefault();
    doTheThing();
});

That is belt and braces on purpose. One missed attribute in six months' time takes the whole page down, and the symptom will not look like a missing attribute.

Make the error explain itself

Since the underscore is a reliable signature, the parse guard can name its own cause rather than reporting an unexpected token:

const raw = String(ctx.request.body || '');
try {
    body = JSON.parse(raw || '{}');
} catch (pe) {
    const shellPost = /^_/.test(raw.trim());
    return respond({
        ok: false,
        error: shellPost
            ? 'The NetSuite form submitted itself instead of the app. ' +
              'A button is missing type="button".'
            : 'Request body was not JSON: ' + pe.message
    });
}

This is worth doing whenever a failure has a recognisable fingerprint. The generic message costs an hour; the specific one costs thirty seconds. You already know the cause at the moment you write the guard — write it down while you do.

The transferable part

The error named the last component that touched the data, not the first one that went wrong. That is the normal case rather than the exception: a JSON parser can only tell you that what arrived was not JSON, and it has no way of knowing that what arrived came from a different sender entirely.

So when a message points at a component you have already verified, stop verifying it harder. Ask what else could have produced the input it is complaining about.

Related: is the error you're chasing the cause, or just a symptom?

New England Systems Group

If there's a process everyone works around instead of through, that's where we start.

SuiteScript development and back-office automation for manufacturers and distributors. Danbury, Connecticut.

hello@nesystemsgroup.com