| name | writing-outbound-jsonata |
| description | Author and iterate JSONata expressions that fix outbound EDI message validation errors for a specific customer × document type. Combines NetSuite SuiteQL lookups, the Orderful SuiteApp's JSONata engine, and tight reprocess loops. Use when the user says "write JSONata for the X transaction", "fix this 856/810/855", "the partner is rejecting on validation", "help me iterate on advanced mapping", or "the SF segment is wrong on the outbound 856". |
Writing Outbound JSONata
JSONata is the SuiteApp's escape hatch for customer-specific outbound message overrides — when the default mapper doesn't satisfy a partner's spec, you author a JSONata expression on the customer's EDI Enabled Transaction Type record (the "Advanced mapping" field) that transforms the default JSON message into a valid one. (The schema in play here is Orderful's JSON form of EDI X12 — not Mosaic, which is a separate, newer, simplified schema for common transaction types.)
This skill is the procedure for doing that authoring loop in a way that doesn't waste hours on schema rejection mysteries.
When to use this skill
Use when the user says any of:
- "write JSONata for the 856/810/855 for customer X"
- "the partner rejected this with
validationStatus: INVALID"
- "help me fix the Ship From / N1 / TD1 / LIN segment on this outbound transaction"
- "this 856 keeps failing validation, can you iterate on the mapping?"
- "I need to add custom advanced mapping for
<customer>"
- "can JSONata access
<some NetSuite field> on the IF / SO / customer?"
Do NOT load this skill for:
- Inbound transaction failures (item lookups, ITEM_LOOKUP_MISSING) — that's
item-lookup.
- Customer enablement / configuration setup — that's
enable-customer.
- Cases where the fix is a NetSuite data fix (e.g., a missing Location on the IF) rather than a mapping fix. Always raise that option first; JSONata is a band-aid for schema/spec divergence, not for missing source data.
Prerequisites
The customer must already be enabled for the document type with an EDI Enabled Customer Transaction (ECT) record (customrecord_orderful_edi_customer_trans). If the row doesn't exist yet, route to enable-customer first — without an ECT row there's no record to attach JSONata to.
The user must have run netsuite-setup for the customer (TBA credentials in ~/orderful-onboarding/<customer-slug>/.env) so SuiteQL probes work.
Inputs the skill needs
Before iterating, get clarity on:
- The failing transaction. Either the NS internal id of the
customrecord_orderful_transaction row OR the Orderful transaction id. We need both sides to compare what was sent vs. what was rejected.
- The trading partner's validation errors. Don't guess structurally. Pull them with
fetch-validations — GET /v2/organizations/{orgId}/transactions/{txId}/validations returns a structured list of dataPath / message / allowedValues triples. The endpoint accepts the public orderful-api-key (no UI JWT required, despite older notes). When you also need to know what the partner's guideline strips vs. requires (the "why" behind a validation error), pull GET /v2/guideline-sets/{id}/guidelines?convertToOrderfulPath=true — paths there match the validation dataPath shape. The Errors tab in the Orderful UI is a fallback, not the primary input.
- The customer × document-type ECT record id. Query (substitute the customer NS id and document-type display name):
SELECT id, custrecord_edi_enab_jsonata,
BUILTIN.DF(custrecord_edi_enab_trans_jsonata_ver) AS ver
FROM customrecord_orderful_edi_customer_trans
WHERE custrecord_edi_enab_trans_customer = <customer_id>
AND BUILTIN.DF(custrecord_edi_enab_trans_document_type) = '<doc-type>'
- Confirm reprocess strategy. Most outbound flows reprocess by setting
custbody_orderful_ready_to_process_ful = true on the source IF (or the equivalent flag for other doc types — see Step 6). Confirm the user is OK with us flipping that flag, since it triggers a real send to the partner sandbox.
The recipe
Step 0 — Audit the customer's outbound transformation rules first
Before writing or iterating any JSONata, run audit-outbound-rules for the customer × document type. Per-relationship rules at /v2/rules can silently strip, INVERT, or ADD EDI segments AFTER the SuiteApp sends — surfacing not only as "missing field" validation errors that NS-stored messages and /v3/validate both contradict, but also as inverted values or spurious empty segments the connector never emitted. See the "Rule shapes beyond whitelist+REMOVE" taxonomy (value-swap and unconditional const-stamp) and the "Attribute the defect: connector vs. rules" recipe. If a rule's whitelist is narrower than the partner's spec, fixing the rule is the cleaner answer than working around it in JSONata. Skipping this step is the most common source of "I'm sure I sent the right segment, why does the validator say it's missing" mysteries.
Step 1 — Capture the default-mapper output
Read the failing transaction's saved message from NetSuite (NOT from Orderful's /message endpoint — that endpoint strips fields during display normalization and is misleading). The NS-side message is the truth:
SELECT custrecord_ord_tran_message
FROM customrecord_orderful_transaction
WHERE id = <ns_tran_id>
The result is a JSON string with this shape:
{
"sender": { "isaId": "..." },
"receiver": { "isaId": "..." },
"type": { "name": "856_SHIP_NOTICE_MANIFEST" },
"stream": "TEST",
"message": { "transactionSets": [ ... ] }
}
Critical: the actual EDI segments live nested under message. The wrapper (sender/receiver/type/stream) is part of $defaultValues inside JSONata. Orderful's /v3/transactions/{id}/message REST endpoint hides this wrapper — paths from the Orderful UI's Rules Editor are not the same paths JSONata uses. Always prepend message. when writing transform paths.
Step 2 — Map each validation error to a JSON path
For each error in the partner's Errors tab:
- Identify the X12 element (e.g. LIN02, N102, BSN05, TD101)
- Find where the SuiteApp emits it in
message.transactionSets[0]… — open the saved message and locate the path
- Note whether the field is missing entirely, is the wrong value, or has the wrong field name (Orderful's JSON field names and X12 element names don't always align)
Common gotchas (real ones from production):
| Error class | Where to look |
|---|
<x> is not a valid input → only X/Y/Z allowed | The SuiteApp's default value for that element doesn't satisfy the partner's restricted code list. Override to a valid value. |
| Element is mandatory | The SuiteApp leaves it null/absent. Set it from a custom field, a SuiteQL lookup, or a hardcoded value. |
must NOT have additional properties - <field> | This is Orderful's schema rejecting the payload before sending. The field name is wrong — find the right Orderful JSON field name in the existing message structure (e.g. unitOrBasisForMeasurementCode, not unitOfMeasureCode). |
| Validation error nests under a different path than expected | The Orderful UI's Rules Editor shows the normalized path. JSONata's path is one level deeper — look at the actual NS-saved message to find the real nesting. Example: purchaseOrderTypeCode lives on HL_loop[<O>].purchaseOrderReference[0], NOT on beginningSegmentForShipNotice[0]. |
| Same segment, different schema at different HL levels | Some partners encode different rules for the same segment depending on which HL it's in. Sally's 856 TD5 is the canonical example: at the Shipment HL it expects TD502/TD503/TD505 (SCAC qualifier + code + carrier routing); at the Order HL it expects only TD506 (shipmentOrderStatusCode: "CL" or "PR" — order completion). Putting Shipment-shaped TD5 at the Order HL fires "Segment is mandatory" because the actual required field (TD506) is missing. Always check whether per-HL rule variants exist before assuming the segment shape is uniform. |
| Empty arrays trigger rejection at strict validators | The SuiteApp emits some optional segments as [] empty arrays (e.g. referenceInformation: [] at Pack HL, N1_loop: [] at 856 Order HL, SAC_loop: [] on 810 when no discount). Strict schemas (Ulta in particular) reject empty array keys as malformed. Either drop the key entirely (`~> |
| Mosaic JSON field naming for repeated UOM elements | Composite segments with multiple "Unit Or Basis for Measurement Code" elements (PAL, TD1) follow occurrence-order numbering. For PAL: unitOrBasisForMeasurementCode (no suffix) = PAL06 / unit weight UOM (often unused); unitOrBasisForMeasurementCode1 = PAL10 / UOM for L/W/H (required when length is present); unitOrBasisForMeasurementCode2 = PAL12 / UOM for grossWeightPerPack. The numbering counts occurrences, not positions — verify against the live message before authoring. |
Step 2.5 — Know what's in your input context (per document type)
The JSONata input root is not the same across document types. Confirmed shapes:
- Outbound 855 (from SO):
salesOrders[] populated with the source SO and its line items; customer populated; itemFulfillments[] and invoices[] empty.
- Outbound 856 (from IF):
itemFulfillments[] populated; customer populated; salesOrders[] is empty even though the IF was created from an SO. (This is a SuiteApp gap — see NS-942 in the product backlog.) itemFulfillments[0].salesOrderId is also not exposed despite being on the IF model — see NS-943.
- Outbound 810 (from Invoice):
invoices[] populated; customer populated; salesOrders[] and itemFulfillments[] empty.
When the document type is 856 and you need SO custbody fields (e.g. custbody_orderful_edi_po_date, custbody_orderful_latest_delivery), the workaround is $lookupSingleSuiteQL joining transactionline.createdfrom (with mainline = 'T'):
$ifId := $string(itemFulfillments[0].id);
$soRow := $lookupSingleSuiteQL(
"SELECT t.custbody_orderful_edi_po_date AS po_date "
& "FROM transactionline tl "
& "INNER JOIN transaction t ON tl.createdfrom = t.id "
& "WHERE tl.transaction = " & $ifId & " AND tl.mainline = 'T'"
);
$poDateStr := $soRow.po_date;
Important: createdfrom is a column on transactionline (with mainline = 'T' to get the header line), NOT on transaction. Trying SELECT createdfrom FROM transaction errors with "Field 'createdfrom' for record 'transaction' was not found". This wastes 30+ minutes the first time you hit it.
Step 3 — Write the JSONata transform
Skeleton — see reference/outbound-jsonata.md for the full library of patterns:
(
/* SECTION 1 — bind data via lookups */
$ifId := $string(itemFulfillments[0].id);
$someValue := itemFulfillments[0].custbody_<your_field>;
/* lookupSingleSuiteQL: max 3 JOINs, TOP 1 stays as TOP 1 */
$someRow := $lookupSingleSuiteQL(
"SELECT TOP 1 col1, col2 FROM <table> WHERE id = " & $ifId
);
/* SECTION 2 — chained transforms on $defaultValues */
$defaultValues
~> | message.transactionSets[0].HL_loop[0].<segment> |
{ "<field>": $someValue, "<otherField>": $someRow.col1 } |
)
Key behaviors:
~> | path | replacement | does a shallow merge of the replacement object into the value at path. Top-level keys you set replace those at target; keys you don't mention are preserved.
- Inside the replacement, the context is the LOCATED value, not the root. You CANNOT reference
itemFulfillments[0] inside the replacement object directly — it won't resolve. Bind root data to $vars in Section 1 first, then reference the $vars inside the transform.
- Undefined values cause keys to be omitted. If
$someValue is null, the key is dropped from the result entirely (this can be a feature OR a footgun depending on whether the partner requires the field).
- Path expressions can fan out across arrays.
message.transactionSets[0].HL_loop.itemIdentification[0] matches every HL entry that has itemIdentification (only I-level entries do) and applies the transform to each.
Step 4 — Test the JSONata locally before pushing
Install the jsonata npm package locally, build a test harness that loads the actual NS-saved message as the wrapped envelope, and apply the expression. This is dramatically faster than iterating against NetSuite — local roundtrip is milliseconds vs. ~30 seconds per NetSuite reprocess.
import jsonata from 'jsonata';
const defaultValues = {
sender: { isaId: '<sender-isa-id>' },
receiver: { isaId: '<receiver-isa-id>' },
type: { name: '856_SHIP_NOTICE_MANIFEST' },
stream: 'TEST',
message: ,
};
const input = {
itemFulfillments: [{ id: 1234567, custbody_<field>: '...' }],
customer: { id: 1234567, companyName: 'Acme Foods' },
salesOrders: [],
invoices: [],
};
const expression = jsonata(JSONATA_EXPR);
const result = await expression.evaluate(input, { defaultValues });
console.log(JSON.stringify(result.message.transactionSets[0]..., null, 2));
$lookupSingleSuiteQL won't be available in the local harness (it's runtime-only), so for transforms that depend on lookups, mock the resulting $var with a hardcoded value during local testing, then swap back to the lookup before pushing.
Step 5 — Push to NetSuite
PATCH the ECT record with the expression and version V2:
PATCH /services/rest/record/v1/customrecord_orderful_edi_customer_trans/<ect_id>
Content-Type: application/json
{
"custrecord_edi_enab_jsonata": "<the expression>",
"custrecord_edi_enab_trans_jsonata_ver": { "id": "2" }
}
V2 is required for the ~> | path | update | transform syntax. If the version field is empty or set to V1, transforms may evaluate but not apply correctly.
Step 6 — Reprocess and verify
For outbound 856, flip the run-control flag on the source IF:
PATCH /services/rest/record/v1/itemfulfillment/<if_id>
Content-Type: application/json
{ "custbody_orderful_ready_to_process_ful": true }
The SuiteApp's MapReduce picks this up within seconds and creates a new customrecord_orderful_transaction row. Poll for the new transaction (NS internal id auto-numbers are NOT strictly increasing across the table — track by MAX(id) taken before the trigger, OR by the Orderful transaction id which IS chronological):
SELECT t.id, BUILTIN.DF(t.custrecord_ord_tran_status) AS status,
t.custrecord_ord_tran_orderful_id AS orderful_id,
t.custrecord_ord_tran_error AS error
FROM customrecord_orderful_transaction t
WHERE BUILTIN.DF(t.custrecord_ord_tran_document) = '<doc-type-display>'
AND t.id > <max_id_before_trigger>
ORDER BY t.id DESC
Then check Orderful's side:
GET https://api.orderful.com/v3/transactions/<orderful_id>
Headers: orderful-api-key: ${ORDERFUL_API_KEY}
Look for validationStatus: VALID. If still INVALID, get the next round of errors from the partner UI and loop back to Step 2.
For other doc types, the run-control flag varies — see the IF/SO/Invoice custom body fields for custbody_orderful_ready_to_process_*. For 855, the trigger is typically saving the SO with the right status. For 810, it's typically saving the Invoice.
Step 7 — Hand off
Once VALID, summarize for the user:
- The final JSONata expression (with comments — see the worked example in
reference/outbound-jsonata.md)
- Which fields are sourced dynamically vs. hardcoded
- Any partner-specific assumptions worth flagging to the customer-side stakeholder for review (e.g. "we assumed all orders to this partner are drop-ship — confirm this won't ever be a warehouse-routed PO")
Behaviour rules
- Always offer the data-fix path before the JSONata path. If the source data on the IF / SO / Customer is wrong (missing Location, wrong subsidiary, missing carton weights), fixing that is structurally cleaner than masking with JSONata. Only proceed with JSONata when the data is correct AND the partner spec diverges from the SuiteApp's default mapping.
- Never push JSONata without seeing the partner's actual validation errors. Schema-level guesses based on message inspection often miss the real rule the partner is enforcing. Insist on the Errors tab screenshot or a verbatim list.
- Do not trust Orderful's
/v3/transactions/{id}/message endpoint as WYSIWYG. It strips fields during display normalization (e.g. nullable identificationCodeQualifier / identificationCode on N1 partyIdentification, weightQualifier on TD1). Always read custrecord_ord_tran_message from NetSuite directly to confirm what was actually sent.
- Always include the
message. prefix in transform paths. $defaultValues is the wrapped envelope. Forgetting this is the #1 cause of "JSONata didn't seem to do anything" — the path matches nothing, the transform is a no-op, and the message goes out unchanged.
- Bind root-context values to
$vars before transforms. Inside a ~> | path | update | block, the update's evaluation context is the located value, not the input root. itemFulfillments[0] inside the replacement object will return null. Always do $something := itemFulfillments[0].field in Section 1 first.
- Test locally with the
jsonata package before each NetSuite reprocess. A local iteration is sub-second; a NS reprocess loop is ~30 seconds plus governance impact. Don't burn the user's time and unit usage on syntax errors that locally would have surfaced immediately.
- Reject ambiguous partner specs. If the user can't produce specific allowed values for a code (e.g. "the partner requires
purchaseOrderTypeCode but I don't know what value to use"), do NOT pick a plausible-sounding code and ship it. Push back on the user to get the partner's spec doc, or escalate to the customer-side stakeholder.
- Document hardcoded values explicitly. Anything in the JSONata that isn't sourced from data should have a comment naming the assumption (e.g.
/* DS = drop ship; KN if shipping to partner warehouse */). Future maintainers need to know what to revisit when the relationship evolves.
- Don't hardcode value fallbacks for partner-specific data. It's tempting to write
$carrier := $cInfo.carrier ? $cInfo.carrier : "USPS" so JSONata always emits something. Don't. Real missing data should produce a missing field, which Orderful's validator catches loudly — that's how you discover unconfigured shipping mappings, missing warehouse codes, or unmapped ship methods. A silent fallback ships garbage to the partner and removes your only signal that something's wrong. Qualifiers (the spec-defined enum codes — CN, GM, ZZ, SK, ST, SF, etc.) are constants and can be hardcoded — they're enum values, not customer data. Qualifier values (the SCAC, service-level code, warehouse code, address country, tracking number, anything that varies per customer or per shipment) must come from a real record. If the source isn't there, fix the source — don't paper over it with a default.
- Prefer SuiteApp-managed lookup records over inline JSONata maps. If you find yourself writing
$lookup({"130": {"carrier": "USPS"}, "131": {...}}, $shipMethodId) in the JSONata, stop and check whether the SuiteApp already has a record type for that mapping. For carrier and service-level codes specifically, customrecord_orderful_shipping_service ("EDI Shipping Lookup") is the home — the SuiteApp's generateShipmentHL queries it via shippingLookupRepository.queryShipmentLookupByShipItem(shipMethod) and populates TD503 (SCAC) and TD508 (service level) into $defaultValues.message.transactionSets[0].HL_loop[…].carrierDetailsRoutingSequenceTransitTime[0] before JSONata runs. Schema: Ship Item (FK to NS shipping item), Carrier (FK), SCAC Code (text), Customer (FK, optional scoping), Service Level (text). Caveat — the lookup populates values, not shape. If the partner's segment requires non-standard qualifiers (e.g. routingSequenceCode=Z + identificationCodeQualifier=ZZ + locationQualifier/locationIdentifier instead of serviceLevelCode), the JSONata still has to rebuild the segment in the partner's shape — read the values out of $defaultValues...carrierDetailsRoutingSequenceTransitTime[0] and slot them in. The "lookup record means no JSONata" assumption breaks for any partner whose segment shape diverges from the standard SuiteApp output. Same pattern applies to other lookup-heavy mappings — check the SuiteApp's source for an existing record type before reinventing in JSONata.
- One PR per fix. If the partner has 6 validation errors, address them iteratively in the same JSONata expression — but if the user wants to also fix an unrelated mapping issue (a different doc type, a different customer), that's a separate session and a separate ECT record.
- Never set
custrecord_edi_enab_trans_jsonata_ver to V1 for new work. V1 is legacy syntax with limited transform support. V2 is the supported form for everything in this skill.
- Mock empty/missing source data in NetSuite, never inline literals into JSONata. If a field is empty, zero, or null in the source record (IF / SO / Customer / Item) during testing, fix it at the data source — patch the NS record, populate a custom record, add a dataset row, set a per-line column. Do NOT bake fallbacks like
$weight ? $weight : "12500" or hardcoded SSCCs like "00100000000000000019" into the expression. The only literals JSONata should contain are partner-spec constants (qualifiers like BM/CN/SF, type codes like SA/KN, weight UOM LB) — values that don't depend on per-record data. A hardcoded data mock either ships to prod (worst outcome — fake values flowing on real EDI) or has to be remembered and stripped later (second-worst — easy to miss in JSONata diffs). Mocking belongs in the system that holds the data, where it's visible to anyone reading the record.
- Watch for schema gaps that JSONata can't fix. If Orderful's outbound schema for the document type doesn't expose a field the partner requires (e.g. some BSN sub-elements), JSONata cannot inject it — Orderful's schema validator rejects unknown properties. Surface this to the user as a feature request to Orderful, not a JSONata problem.
- The partner GUIDELINE — not a rule's whitelist — is the source of truth for which qualifier a slot requires. When a validation error names a missing/invalid qualifier ("Segment is mandatory", "
X is not a valid input"), pull the guideline's allowed values for that exact dataPath first (GET /v2/guideline-sets/{id}/guidelines?convertToOrderfulPath=true; accepts the orderful-api-key) and emit what the guideline allows. Do NOT infer the required qualifier from an outbound rule's allow-array (/v2/rules) — those arrays are not the spec, and editing them is a no-op if the guideline strips the qualifier downstream anyway. Real example: a shipment-level REF rule listed 2I, so REF*2I was emitted and the rule edited repeatedly — all no-ops, because the guideline only allowed BM. One look at the guideline would have shown BM up front.
Common gotchas
- Reprocess fired but nothing new appears. The
customrecord_orderful_transaction table's id column does NOT auto-number monotonically across the whole table — different sessions allocate from different ranges. Filter by created timestamp or by custrecord_ord_tran_orderful_id (Orderful's id IS chronological) instead of id.
Field 'subsidiary' for record 'transaction' was not found. Reason: NOT_EXPOSED. Header-level transaction.subsidiary is not exposed for SuiteQL search. Use transactionline.subsidiary instead — it's exposed.
Search error occurred: Invalid or unsupported search. Often means a JOIN your query is making isn't traversable in SuiteQL (e.g. some location.subsidiary paths). Reduce join count or rewrite via the line table.
lookupSuiteQL error: Only up to 3 joins are supported. Hard cap. Either denormalize the query (fewer joins) or break into two $lookupSingleSuiteQL calls and combine in JSONata.
lookupSuiteQL error: Query results exceeded 1 record. The wrapper rewrites your SELECT to SELECT TOP 2 (unless you wrote TOP 1 explicitly). If 2 rows return, it errors. Add TOP 1 and an ORDER BY to make the query deterministic. Use $lookupMultiSuiteQL for legitimate multi-row needs.
- JSONata evaluated but nothing changed. Check the version field — if it's null or
V1, transform syntax may not apply. Set to V2. Also check the path includes message. — paths copied from the Orderful UI's Rules Editor will not work as-is.
- The outbound message is content-identical across multiple saved ECT JSONata versions. If you re-save the ECT with meaningfully different JSONata and the received payload doesn't budge, the ECT is not the lever — Orderful rules are post-processing the output regardless of the mapping (see
audit-outbound-rules). Stop iterating on JSONata and run the connector-vs-rules attribution diff: reconstruct the connector's true output offline and compare against the received payload. This complements the "JSONata evaluated but nothing changed" case above — there the transform no-oped locally; here the transform works but a rule overwrites it downstream.
- The transform applied but Orderful's display doesn't show the field. Compare against the NS-saved message (
custrecord_ord_tran_message) — Orderful strips display-only nulls and certain fields it deems redundant. The data was sent; Orderful is just hiding it on the way back.
- The SuiteApp errored before JSONata ran with "None of the following item fulfillments have cartons". This is a pre-JSONata check in the 856 generator. Cartons must come from either
customrecord_orderful_carton OR a configured analytics dataset. If the user changed their packing setup, verify the dataset still returns cartons for the IF in question.
- Lambda body syntax error:
Expected "}", got ";". JSONata function bodies must be a single expression. Multiple $x := ...; $y := ...; ... bindings inside a function($arg) { ... } body raise this error. Wrap the body in a parenthesized expression block: function($arg) {( $x := ...; $y := ...; result )}. Common when factoring helper functions out of larger transforms.
- Path-update operator returns an object instead of an array.
arr ~> $map(fn) and $merge over a single-element array can collapse the result to a plain object — JSONata's "single-value vs array" coercion. If your transform produces "carrierDetails": {…} where the schema expects "carrierDetails": [{…}], the validator will reject it. Two fixes: prefer the path-update operator (~> | path | obj |) which preserves the array shape via shallow merge, or wrap your $map result in […] to force array preservation.
- Customer body field is null on EDI-context creates because a UE/Client script is form-gated to UI saves. Common pattern: a body field like
custbody_*_total_weight is calculated by a User Event script that only runs on specific customforms / via UI submit, so server-side record.create from the SuiteApp leaves it empty. JSONata workaround: coalesce the IF's empty value to the parent SO via transactionline.createdfrom subquery: COALESCE(NULLIF(t.<field>, 0), so.<field>) where so.id = (SELECT MAX(tl.createdfrom) FROM transactionline tl WHERE tl.transaction = t.id). Header-level transaction.createdfrom is not exposed to SuiteQL; the line-level transactionline.createdfrom IS exposed and gives you the IF→SO link. Long-term fix is in the customer's UE script (extend the form gate) or a server-side mirror.
- NSAD formula columns can't reference other formula columns. When building dataset formulas (e.g., a Mod-10 check digit on an SSCC), you can't define a
Base17 formula column and reference {Base17} from a downstream CheckDigit formula. Inline the full expression in the single column that the SuiteApp will read. Optimization: precompute the fixed-prefix portion of the weighted sum manually (e.g., for a fixed GS1 company prefix, compute the constant contribution once and only iterate digits over the variable serial portion). For a 17-digit base with an 11-character fixed prefix and a 6-digit variable serial, this drops the SUBSTR count from 17 to 6.
Cannot parse payload. Reason: <path>: must be array. A single-match predicate flattens to a scalar — $x := $defaultValues...N1_loop[partyIdentification[0].entityIdentifierCode = "ST"] returns an object (not a 1-element array) when exactly one entry matches the filter. If you then assign $x back to a list-typed path (e.g. "N1_loop": $x), Orderful's schema parser rejects the whole payload before validation runs. Wrap with $append([], ...) to force array semantics: $x := $append([], $defaultValues...N1_loop[partyIdentification[0].entityIdentifierCode = "ST"]). Same trick works for empty results — $append([], <0-match predicate>) yields [] instead of undefined. Hits any time you keep a subset of a multi-entry default and the subset narrows to 0 or 1 entries.
- Tracking number isn't a single-table column.
transaction.trackingnumberlist looks like a tracking number field but is actually a sublist anchor (an internal id pointing into the tracking-number sublist). The real path for a SuiteQL lookup is: trackingNumberMap.transaction = <ifId> → trackingNumberMap.trackingnumber (FK) → trackingNumber.id → trackingNumber.trackingnumber (the actual string). Worked example: SELECT TOP 1 tn.trackingnumber FROM trackingNumberMap m JOIN trackingNumber tn ON tn.id = m.trackingnumber WHERE m.transaction = <ifId>.
- Not every IF custbody field is SuiteQL-queryable. Some are exposed (e.g.,
custbody_shiphawk_shipping_cost, custbody_sps_package_data_source); others (e.g., custbody_if_otherrefnum) are NOT — depends on the field's "Show in List" / searchable settings. The IF data passed into JSONata via itemFulfillments[0] mirrors what selectAll() from transaction returns, so anything not SuiteQL-queryable is also not in the JSONata input. When designing JSONata that reads from the IF, verify the field is queryable (SELECT custbody_x FROM transaction WHERE id = <ifId>) before relying on it. If not, fall back to standard NS columns (otherrefnum, tranid, shipmethod, etc.) or set the value on a known-queryable field via a UE script.
- The partner's guideline acts as an allowlist on the X12 output, not just a validator. A field present in
$defaultValues but missing from the X12 isn't a bug — the partner's guideline decides what reaches the output via two mechanisms: (a) explicit deletion — the path is in the guideline with use: "notRecommended" + notes: "Intentionally left empty"; (b) implicit deletion — the path simply isn't in the guideline at all (the converter strips anything the partner doesn't allowlist). Both render in the Orderful UI as a yellow strike-through indicator next to the value. Fetch the actual rules via GET /v2/guideline-sets/{id}/guidelines?convertToOrderfulPath=true — paths match the dataPath shape from validation errors. Practical implication: before writing JSONata to remove a SuiteApp default the partner rejects, check whether the guideline is already stripping it. Overriding fields the partner doesn't reference is harmless, but writing JSONata to delete fields the guideline already removes is wasted effort. The guideline endpoints accept the public orderful-api-key (no UI JWT required).
- Outbound runs through four layers — know which one strips your field before you debug.
(1) SuiteApp default mapper → (2) your ECT JSONata → message stored in NS custrecord_ord_tran_message (this is what you POST) → (3) Orderful per-relationship rules (/v2/rules, applied server-side after send) → (4) guideline → X12 conversion (allowlist) → validation. /v3/transactions/{id}/message shows the post-(4) state. So a field present in custrecord_ord_tran_message but absent from /message (and reported "mandatory") was stripped at layer 3 OR 4 — and they need different fixes: layer 3 is a rule edit, layer 4 is "emit the qualifier the guideline allowlists." Don't assume it's the rule: confirm by checking the guideline's allowed values for that path (layer 4) before touching /v2/rules (layer 3).
Reference material
reference/outbound-jsonata.md — full reference: input/context variables, the wrapped-envelope pattern, transform operator semantics, registered SuiteQL functions, common Orderful JSON field names, schema gotchas, and an annotated worked-example expression covering N1 / TD1 / TD5 / REF / LIN.
reference/record-types.md — schema for customrecord_orderful_edi_customer_trans (where the JSONata field lives), customrecord_orderful_transaction (the saved-message field), and related records.
which-script-ran + reference/script-execution-map.md — prove whether your mapping actually executed, and where: the generating host (UE vs consolidation MR vs the 846 MR) logs audit Outbound JSONata mapper ("Overriding JSON message by JSONata"), and its errors are readable over SuiteQL without the NS UI.