- Post History
- Subscribe to RSS Feed
- Mark as New
- Mark as Read
- Bookmark
- Subscribe
- Printer Friendly Page
- Report Inappropriate Content
56m ago - edited 53m ago
CartJS errCode 42202 "Item is unavailable for Guest" and "reqJSON is null" from Inbound Email Actions: Root Cause and Fix
Two bugs in one code path, and why the DEV test proved nothing
ServiceNow · Service Catalog + Inbound Email · July 2026
Summary. An inbound email fires a business rule that orders a catalog item through sn_sc.CartJS. Two separate bugs were sitting in that one code path, and fixing the first is what finally revealed the second.
errCode 42202, "Item is unavailable for Guest" happens because the email sender doesn't match any sys_user.email, so the whole transaction runs as Guest. Setting the item's "Available for" to any user won't fix it, and that failure turns out to be useful information rather than a dead end.
Cannot invoke "org.json.simple.JSONObject.get(Object)" because "reqJSON" is null happens because submitOrder() was called without the item argument. There's a tested comparison of addToCart, checkoutCart, submitOrder and orderNow further down.
Also in here: why our development test passed while the code was broken, snc_internal on service accounts, and what happens when a matched sender is locked out.
See also: Server-Side GlideRecord Returns Zero Rows for One User and Rows for Admin: the Before-Query Business Rule That Hides em_alert. Separate write-up, third bug from the same project, doesn't depend on this one. Worth a look if you've got an automation that works for you but not for the people who use it.
https://www.servicenow.com/community/developer-articles/server-side-gliderecord-returns-zero-rows-fo...
Why I'm writing this
I went looking for both of these errors in the docs, the KB and the community. There are fragments. Nobody has written up what happens when they turn up together, one hiding behind the other, which is how they turned up for us.
The reqJSON one returns nothing useful anywhere. It surfaces as a Java NullPointerException, so it reads like a platform defect, and I lost a chunk of an evening treating it as one. It's a one-word mistake in the calling code, and the error message does say so if you stare at it long enough.
The setup
A detection tool emails ServiceNow when a rogue wireless access point turns up on the network. Nobody's meant to touch it: the email creates a security request, a business rule creates an alert, and CartJS orders a network catalog item so the network team gets a task to go and deal with it.
Detection tool spots a rogue AP
-> email to ServiceNow
-> Inbound Email Action creates the request record
-> Business Rule fires
-> creates the alert [worked]
-> CartJS orders the catalog item [FAILED]
In production:
errCode 42202, "Item is unavailable for Guest"
The theory I inherited, which was wrong
When I picked this up the working theory was two-step checkout. Production had it enabled, DEV didn't, and the extra checkout step was supposedly enforcing a session-user validation that one-step skipped. It explained the error. It explained why DEV passed. I built a debugging plan on top of it and got a fair way in before I bothered to check.
Both instances had glide.sc.checkout.twostep = false. It had never been true. Someone had said it early on, it sounded right, and it got repeated until it hardened into a fact.
The premise you inherit with a ticket is the one nobody re-checks, because it arrives sounding like it's already been established. And if a theory explains all your symptoms neatly, that's worth being suspicious about rather than pleased about. One property query would have killed this in four seconds.
So why did it work in DEV?
Once two-step fell over, the obvious question was what the DEV test had actually demonstrated. This turned out to be the most useful thing I learned all week.
The DEV test wasn't the automation. It was a background script, run by hand, by a developer with an admin account, containing an earlier draft of the code. Three things were different from production, all at once:
| The DEV "test" | What actually runs in production | |
|---|---|---|
| Trigger | Background script, run by hand | Inbound email action |
| Session user | A developer, logged in, admin | Guest, because no user matched the sender |
| Code | An earlier draft | The deployed business rule |
So it proved the catalog item could be ordered by an admin, from a background script, using code that wasn't the code being deployed. It never went near the thing that was broken. Deploy the same logic, let a real email fire it, and it falls over on the guest session immediately.
A simulation isn't the automation. Order the item from a background script and it works, and you've proved the item is orderable by you. You've learned nothing about whether the business rule works, as whatever user the trigger produces, running the code that's actually deployed. Change the trigger, the user or the script and you're testing something else.
It's also why both bugs survived this long. The deployed code had never once run as guest, through the email path, anywhere. Production was its first honest attempt.
Bug 1: guest sessions
The 42202 was real and the cause was boring. Inbound email actions impersonate the sender: ServiceNow takes the From address, looks for a matching sys_user.email, and if it finds one then everything downstream in that transaction runs as that user. Inbound action, business rule, CartJS, all of it. No match and you get Guest.
Nobody had ever created a user with the detection tool's sender address, so every one of these ran as Guest. CartJS orders as whoever the session is, and gives you no way to override it.
Why "Available for: any user" doesn't help
Someone had tried this before I got involved and it hadn't worked, and I nearly wrote that off as a dead end. It isn't. It's a clue.
"Any user" means any authenticated user. Guest isn't in that set because Guest isn't authenticated, it's a separate class. If you genuinely want an item orderable by unauthenticated users, you need user criteria granting the public role, Create permission on the variables for the public role, and a public portal page. That's a lot of exposure to take on and almost certainly not what you want for an internal provisioning item.
The failed fix was telling us the problem wasn't catalog configuration at all. We just didn't read it that way at the time.
fix
Create a service account with its Email field set to the sender's exact From address. That's the whole thing. The platform's own matching does the rest, and the transaction runs as that account instead of Guest. No code change.
- Get the From address off a received
sys_emailrecord, not out of the sending tool's config. Relays rewrite addresses, and you want the string ServiceNow actually sees. - Check nobody else already has that email, or the matching gets unpredictable.
- Assign
snc_internalexplicitly, and make suresnc_externalisn't on there. - Make sure the account passes the item's user criteria at item, category and catalog level. We also gave it the
catalogrole, with a caveat below. - Send a test email and look at
sys_email.user_id. Empty or guest means the match didn't happen and nothing else you do matters.
Catalog item availability is user criteria, not ACLs. It's "Available for" and "Not available for" on the item, its category and its catalog, and no ACL will change that, so don't go hunting for one.
On roles: we gave the account catalog alongside snc_internal and it orders fine. I tried to work out whether catalog was actually required and couldn't, because bug 2 below was failing the order regardless of what roles the account had. So take that as a configuration that works rather than a minimum that's been proven. What it definitely doesn't have is itil or admin, and it doesn't need them. Don't grant itil as a shortcut either. You'd be handing write access on task tables platform-wide to an account whose entire job is receiving unauthenticated email, which is precisely the thing security review exists to catch.
There's something worth noticing in that failed role test, by the way. Two bugs in one code path don't queue up politely, they interfere with diagnosing each other. I couldn't isolate a variable in bug 1 because bug 2 was failing everything anyway.
Two things that bite service accounts specifically. snc_internal gets assigned automatically the first time a user attempts to log in, and an account that only ever receives email never logs in, so it may never pick the role up. Assign it yourself.
And if the matched user is locked out you don't fall back to Guest. The inbound action just fails and the email doesn't process at all, silently, which is a worse failure mode than the one everyone worries about. The property is glide.pop3.process_locked_out.
Bug 2: reqJSON is null
Service account in place, session correct, still failing. Differently, though:
Cannot invoke "org.json.simple.JSONObject.get(Object)" because "reqJSON" is null
A Java NPE coming out of a scripting API looks like a platform bug, and I spent a while treating it as one.
submitOrder() wants the item JSON. The code called it empty:
var cart = new sn_sc.CartJS();
cart.addToCart(item);
var result = cart.submitOrder(); // no argument, so reqJSON arrives null
reqJSON is the name of the parameter that didn't arrive. The error was describing exactly what was wrong. It just didn't look like a caller mistake, because the message is written from the point of view of the Java that fell over rather than the JavaScript that caused it.
This is why the DEV/PROD story never had one clean answer. Two independent bugs in the same few lines: the guest session failing at catalog authorization, and the missing argument failing a step later at submitOrder(). Fix the first and the second shows up looking like a brand new problem. If something's been broken "for one reason" for a while, budget for the possibility that fixing it just reveals the next one down.
Testing it instead of arguing about it
The community has people using all of these patterns and contradicting each other about which is right, so rather than pick a side I ran all four in one background script and watched which ones produced a request.
| # | Pattern | Result |
|---|---|---|
| A | addToCart(item) then submitOrder(item) |
REQ created |
| B | addToCart(item) then checkoutCart() |
REQ created |
| C | addToCart(item), checkoutCart(), then submitOrder() |
Same NPE |
| D | orderNow(item) |
REQ created, most useful return |
C is the one that settles it: same argument-less call, same exception, in isolation, nothing else going on. At that point it stops being a theory.
What we went with
var item = {
'sysparm_id': '<catalog item sys_id>',
'sysparm_quantity': '1',
'variables': { /* ... */ }
};
var cart = new sn_sc.CartJS();
cart.setRequestedFor(gs.getUserID());
var orderResult = cart.orderNow(item); // pass the item
var requestId = orderResult.request_id;
We picked orderNow because it's a single-item order that doesn't need a preceding addToCart. Fewer moving parts, less to go wrong when several alerts land at once. It also came back with everything we needed:
{"sys_id":"<sys_id>","number":"REQ0012345","request_number":"REQ0012345",
"request_id":"<request sys_id>","table":"sc_request"}
Worth mentioning because there's a claim doing the rounds that orderNow() only gives you back the cart id, and people have been calling both orderNow() and submitOrder() to work around a problem that, in our instance at least, isn't there. One call was enough. Versions differ, so try it rather than taking my word or theirs.
If you'd rather use the cart, addToCart(item) then submitOrder(item) works too. The only thing that doesn't work is submitOrder() with nothing in the brackets.
What actually found this
Logging before each step, not after
Here's what kept the NPE invisible. An uncaught exception in an after business rule kills the script and tells nobody: no error on screen, no work note, no entry anywhere. The record quietly doesn't have its RITM and everyone assumes the automation didn't fire.
Step-level logging plus a try/catch turned that into an exact line in about ten minutes, after I'd spent considerably longer than that theorising:
gs.info(L + 'STEP 4 start (CartJS) as user=' + gs.getUserName());
try {
var cartDetails = cart.addToCart(item);
gs.info(L + 'STEP 4a addToCart=' + JSON.stringify(cartDetails));
var orderResult = cart.orderNow(item);
gs.info(L + 'STEP 4b orderNow=' + JSON.stringify(orderResult));
} catch (e) {
gs.error(L + 'STEP 4 THREW: ' + e.message);
}
Log before the step, not after it. The last line you see is the line that killed you. Logging after a step only ever tells you about steps that worked, which you already knew.
Logging who you're running as
gs.info(L + 'user=' + gs.getUserName() + ' | scope=' + gs.getCurrentScopeName());
Both of these bugs were about identity, or looked like it. That one line answers the question before you've thought to ask it, and if it had been there from the start this article would be a paragraph.
Testing through the real trigger
For email automation that means System Mailboxes, Received, find an actual message, reprocess it. Real sender matching, real session, real deployed code. A background script gives you none of those and a false sense of having tested something.
What I'd do differently
- Check the premise that came with the ticket. "Production has two-step enabled" arrived sounding authoritative, explained everything, and was never true.
- Never mistake a simulation for the automation. Different trigger, different user or different code means you tested something else.
- Assume there might be a second bug. Layered failures are normal in email automation, because each layer only gets reached once the one above it passes.
- Read the failed fixes. "Available for: any user" didn't work, and that was telling us the problem wasn't catalog config.
- Wrap anything that can throw. An after business rule dying silently is the worst failure mode there is, because it looks like nothing happened.
- Log first, theorise later. I had four theories and a trace. The trace was right.
Quick reference
| Symptom | Cause | Fix |
|---|---|---|
errCode 42202, Item is unavailable for Guest |
Sender doesn't match any sys_user.email, so the session is Guest. Catalog ordering won't accept an unauthenticated session, and "Available for: any user" means any authenticated user. |
Service account with the sender's exact email. No code change. |
Cannot invoke JSONObject.get because "reqJSON" is null |
submitOrder() called with no argument. |
orderNow(item), or addToCart(item) then submitOrder(item). |
| Inbound email silently doesn't process at all | The matched user is locked out. No fallback to Guest happens in this case. | Check locked_out on the account. See glide.pop3.process_locked_out. |
| Script stops halfway, no error anywhere | Uncaught exception in an after business rule. | try/catch, plus logging before each step. |
| "It worked in DEV" | The DEV test was a background script, run by an admin, on an earlier draft. It never touched the real trigger or the real session user. | Reprocess a real sys_email record. Check sys_email.user_id. |
References
- ServiceNow docs: inbound email actions impersonate the matched sender, or Guest if there's no match
- ServiceNow docs: catalog item availability is user criteria, not ACLs
sn_sc.CartJSscoped API reference
Related article: Server-Side GlideRecord Returns Zero Rows for One User and Rows for Admin: the Before-Query Business Rule That Hides em_alert. Different bug, different mechanism, same project. That one breaks the rule everybody knows about GlideRecord and ACLs.
Every error string, script excerpt and test result here came out of a live instance. Versions and configuration differ, so check yours rather than trusting mine.
Thank you,
Selva(Meena) Arun