System.JSONException: <message>
Apex couldn't parse a JSON string — unexpected character, missing comma, type mismatch in deserialize. The exception message says where in the JSON it choked. The fix depends on whether the producer or consumer is at fault.
Also seen asSystem.JSONException·Unexpected character·JSON parse error apex·Unable to deserialize
The integration looked fine yesterday. Today an inbound webhook from a partner system breaks the trigger that processes it. The log reads System.JSONException: Unexpected character ('}' (code 125)): was expecting double-quote to start field name. The Apex JSON.deserialize call refuses the payload. The partner says they didn't change anything. The fix is yours to find.
What the platform is telling you
System.JSONException is the umbrella for any failure inside the JSON.deserialize, JSON.serialize, JSON.deserializeStrict, or JSON.deserializeUntyped calls. The message after the colon names the specific failure: malformed JSON syntax, type mismatch during deserialization, unexpected token at a position, or a value that doesn't fit the target Apex type.
Three families of failure fall under JSONException:
Syntax errors. The string isn't valid JSON. A missing comma, an unescaped quote, a trailing comma at the end of an array, an unterminated string. The platform's parser refuses and reports the position.
Type mismatches. The JSON is valid, but a field's value type doesn't match what your Apex class expects. The JSON has "age": "30" (string), but your Apex class declares Integer age. The platform throws on deserialization.
Strict-mode rejections. JSON.deserializeStrict refuses any JSON field that doesn't have a matching Apex property. If the producer adds a new field and you call strict, the call fails until you add the property to your Apex class.
The message names which family it is. "Unexpected character" is a syntax error. "Cannot convert" is a type mismatch. "Unrecognized field" is strict-mode.
The broken example
A webhook receiver that deserializes into a typed Apex class:
public class CustomerSyncReceiver {
public class CustomerPayload {
public String externalId;
public String name;
public Integer age;
public Decimal balance;
}
@AuraEnabled
public static void receive(String body) {
CustomerPayload p = (CustomerPayload) JSON.deserialize(body, CustomerPayload.class);
// Process p...
}
}
The partner system sends:
{
"externalId": "abc-123",
"name": "Acme Corp",
"age": "30",
"balance": "1234.56",
"metadata": {"region": "EMEA"}
}
Three potential problems:
age: "30"is a string, not an integer.JSON.deserializemay auto-convert, butJSON.deserializeStrictrefuses.balance: "1234.56"is a string. Same auto-convert behavior, same strict refusal.metadata: {...}is a field your Apex class doesn't declare.deserializeStrictrefuses; the lenientdeserializesilently ignores it.
If the call was deserialize (lenient), the syntax issue isn't in the JSON; it's in how Apex coerces the types. The error might say "Cannot convert String to Integer."
If the call was deserializeStrict, the error names the offending field.
The fix: choose the right deserialize method, design for evolution
Apex offers three deserialization paths:
JSON.deserialize(text, Type): lenient. Tries to coerce values where possible. Ignores unknown fields. Good for "I expect this shape; ignore extras."
JSON.deserializeStrict(text, Type): strict. Refuses any unknown field. Refuses type coercion. Good for tightly-controlled internal APIs where any drift is a bug.
JSON.deserializeUntyped(text): returns Object, which you cast and walk manually. Good for unknown or polymorphic shapes where you don't have a typed class.
For a webhook from an external partner, choose deserialize (lenient) unless you have a strict contract. Partners add fields over time, and strict-mode refusal of every new field creates fragility.
The fixed handler with explicit type handling:
public class CustomerSyncReceiver {
public class CustomerPayload {
public String externalId;
public String name;
public Integer age;
public Decimal balance;
// metadata intentionally not declared; ignored by lenient deserialize.
}
@AuraEnabled
public static void receive(String body) {
if (String.isBlank(body)) {
throw new IllegalArgumentException('Request body is empty');
}
CustomerPayload p;
try {
p = (CustomerPayload) JSON.deserialize(body, CustomerPayload.class);
} catch (System.JSONException ex) {
// Wrap the platform exception with context for the caller.
throw new AuraHandledException(
'Could not parse customer payload: ' + ex.getMessage()
+ '. First 200 chars: ' + body.left(200)
);
}
if (String.isBlank(p.externalId)) {
throw new IllegalArgumentException('externalId is required');
}
// Continue processing...
}
}
The handler validates input, wraps the platform exception with a useful diagnostic message (including a prefix of the offending body), and validates required fields after the deserialize succeeds.
When the producer is at fault
A webhook that sends invalid JSON is a producer bug. The fix lives on their side, not yours. Two things to do on your side anyway:
Log the offending payload. When JSONException fires, capture the first 1000 characters of the request body in a custom log object. Build a Lightning report so admins can see what bad payloads look like.
Return a useful error to the producer. Don't return a generic 500. Return a 400 with a body that names the specific JSON issue. The producer's logs then show the actionable error, and their next deploy can fix it.
If your Apex receives webhooks via a REST @HttpPost endpoint, the response shape matters:
@RestResource(urlMapping='/customer-sync/*')
global class CustomerSyncRestApi {
@HttpPost
global static void receive() {
RestResponse res = RestContext.response;
String body = RestContext.request.requestBody.toString();
try {
CustomerSyncReceiver.receive(body);
res.statusCode = 200;
} catch (System.JSONException ex) {
res.statusCode = 400;
res.responseBody = Blob.valueOf(JSON.serialize(new Map<String, Object>{
'error' => 'malformed_json',
'message' => ex.getMessage()
}));
} catch (IllegalArgumentException ex) {
res.statusCode = 400;
res.responseBody = Blob.valueOf(JSON.serialize(new Map<String, Object>{
'error' => 'validation',
'message' => ex.getMessage()
}));
}
}
}
The producer's logs now show the specific error category and message, not just "the integration broke."
When the consumer (your code) is at fault
A more common case: the producer sends valid JSON, but your Apex class doesn't match the shape. Two patterns help.
Mirror the source schema exactly. If the producer's payload has customer_id in snake_case, your Apex field needs to be customer_id too (you can't auto-convert snake_case to camelCase in Apex). Add helper getters if you want a different name internally.
Use Apex annotations for renaming. Apex doesn't have a @JsonProperty("foo") annotation like Java's Jackson. The field name must match exactly. If you need a different Apex-side name, deserialize to an untyped Object first and walk the map yourself.
A common variant: timestamps. JSON timestamps can come in many formats:
- ISO 8601:
"2026-05-24T14:30:00Z" - Unix epoch seconds:
1716559800 - Unix epoch milliseconds:
1716559800000 - Salesforce date string:
"2026-05-24T14:30:00.000+0000"
Apex's Datetime deserialization expects a specific format and fails on others. If your producer sends ISO 8601 but your Apex class declares Datetime, you may need to deserialize to String first and parse explicitly:
String iso = (String) payloadMap.get('timestamp');
Datetime dt = (Datetime) JSON.deserialize('"' + iso + '"', Datetime.class);
The double-quoting is required because JSON.deserialize expects a JSON-formatted value (strings need quotes).
A subtle source: invisible control characters
A producer that constructs JSON by string concatenation can accidentally embed control characters: tab, newline, or null bytes inside a string field. The platform's parser refuses with cryptic errors that name the position but not the cause.
The diagnostic: in your error handler, log the request body as hex or escape-format. Look for ``, \t inside what should be a single-line string, or stray \r\n. The producer probably forgot to JSON-escape a value.
The fix lives on the producer side: use a proper JSON serializer instead of string concatenation. On your side, an interim workaround is to strip non-printable characters before deserialization:
// Strip the C0 control range. Regex pattern targets characters U+0000 to U+001F.
// (Exact regex omitted here so the pattern doesn't trip downstream serializers.)
String cleaned = StripControlChars.fromBody(body);
This is fragile (it can destroy legitimate Unicode), so prefer the upstream fix.
Schema versioning conventions
For long-running integrations, agree on a schema-versioning convention with your producer up front. Two patterns work well:
Top-level version field. Every payload includes "version": "v1". Your Apex switches on the version and uses the appropriate parser. New versions add the property; old versions stay supported.
Forward-compatible schemas. Every payload is allowed to have extra fields, and your Apex uses lenient deserialize that ignores them. The producer can ship new fields without breaking you, and you upgrade your Apex when you need the new data.
The first pattern is more rigorous; the second is more flexible. Most healthy integrations land somewhere in between.
When deserializeUntyped is the right tool
For payloads where you genuinely don't know the shape (polymorphic types, deeply nested or variable structures), use JSON.deserializeUntyped:
Object raw = JSON.deserializeUntyped(body);
Map<String, Object> root = (Map<String, Object>) raw;
String type = (String) root.get('type');
if (type == 'customer') {
handleCustomer(root);
} else if (type == 'order') {
handleOrder(root);
}
The untyped path returns nested Map<String, Object> and List<Object> structures. You cast and walk manually. The verbose code is the cost; the flexibility is the benefit. Use it when the payload genuinely varies.
For typed payloads where you know the shape, prefer the typed deserialize. The compile-time type check on the resulting class catches a lot of bugs before they hit production.
Schema-mismatch debugging steps
When you suspect a schema mismatch but can't pinpoint the field, walk this list:
- Log the full request body in your error handler.
- Pretty-print the JSON in your local editor to spot structural issues.
- Use a JSON schema validator (jsonschemavalidator.net or a CLI like
ajv) to test the payload against a written schema. - Compare the producer's documented schema with your Apex class field-by-field.
- If steps 1-4 don't reveal the issue, deserialize to
Objectand inspect the result:
Object raw = JSON.deserializeUntyped(body);
System.debug(JSON.serializePretty(raw));
The pretty-printed output shows exactly what Apex sees, including any control characters or unexpected nesting. The bug usually becomes obvious in the debug log.
A small history note
Apex's JSON handling has improved across releases. Early versions had bugs around scientific notation in numbers and Unicode escaping in strings. Recent versions are robust on both. If you encounter a JSONException that seems impossible, check that your class API version is recent (50+ is usually safe). Older API versions sometimes have parsing quirks that were fixed in later releases.
Testing the unhappy paths
A test suite for a JSON-consuming method should cover:
- The happy path. Well-formed JSON matching the expected schema. Confirms the parser works for the canonical case.
- Empty input. Pass
''and confirm the method throws a meaningful exception (not the platform's stock NullPointerException). - Malformed JSON. Pass
'{not valid'and confirmJSONExceptionis caught and rethrown with context. - Missing required field. Pass JSON that lacks
externalIdand confirm the validation path catches it. - Extra field with lenient deserialize. Pass JSON with an unknown field and confirm the method succeeds (lenient mode ignores the extra).
- Type mismatch. Pass
"age": "not-a-number"and confirm the failure is handled.
Six tests cover the full state space for most JSON parsers. The investment is twenty minutes; the saving is hours of incident triage when a partner pushes an unexpected payload.
A defensive habit: schema documentation alongside Apex
When you define an Apex class for deserialization, paste the producer's JSON schema (or a representative example) into a comment block at the top of the file. Future maintainers see the contract immediately, without hunting through the producer's docs.
If the producer ships a formal JSON Schema document, store it in your repo alongside the Apex class. CI can validate that the Apex class's field set matches the schema. The validation runs in seconds and catches drift at PR time, well before it surfaces as a JSONException in production.
Further reading from Salesforce
Related dictionary terms
Share this fix
Related Apex errors
Initial term of field expression must be a concrete SObject: <type>
ApexYou wrote `someThing.Field__c` where `someThing` isn't a specific SObject type — usually an `SObject` or `Object` reference. Apex needs the …
Method does not exist or incorrect signature
ApexThe Apex compiler can't find a method matching the call you wrote — wrong name, wrong argument types, or wrong number of arguments. The comp…
MIXED_DML_OPERATION: DML operation on setup object is not allowed after you have updated a non-setup object (or vice versa)
ApexSalesforce splits objects into "setup" (User, Group, GroupMember, PermissionSet, Profile, Queue, etc.) and "non-setup" (everything else). A …
System.AsyncException: Future method cannot be called from a future or batch method
ApexYou called an `@future` method from inside another `@future`, batch, or queueable. Salesforce blocks recursive async chains because they cou…
System.CalloutException: Read timed out
ApexThe HTTP callout exceeded its allowed read time waiting for the remote server's response. Salesforce caps a single callout at 120 seconds (d…