All articles
.NET & integrations · 8 min read ·

Tolerant JSON contracts: surviving an inconsistent API

When the provider sends a number as a string, an object where an array was documented, and an empty string for a date — how to absorb the shape without absorbing the ambiguity.

.NETSystem.Text.JsonintegrationsAPIresilience

There is a specific kind of production incident that only happens with third-party integrations. Nothing in your code changed. No deployment went out. And at 02:40 the sync stops, with a deserialization exception on a field that has worked for eight months.

The provider changed something. Possibly by accident. Possibly they consider it a non-breaking change, because to them it is.

Whose fault is it, and does that help?

Theirs, and no.

It is worth being clear-eyed about this: you have no leverage over an external API’s payload discipline, and a rigid deserializer converts every one of their small inconsistencies into your outage. The question is not who was wrong but where you absorb the variation.

The classical answer is Postel’s law — be conservative in what you send, liberal in what you accept. It has aged badly as general advice, because unbounded leniency hides bugs and lets divergent implementations calcify. But it holds in a narrow, disciplined form:

Be liberal about the shape of what you accept. Be strict about its meaning. And record every tolerance you exercise.

The third clause is what separates this from sloppiness.

What does the platform already handle?

More than most people reach for a converter to solve. Before writing custom code, exhaust the options:

// Cache this. JsonSerializerOptions is frozen on first use, and constructing
// a new one per call throws away the serializer's metadata cache — this is a
// measurable performance bug, not a style preference.
private static readonly JsonSerializerOptions Options = new()
{
    PropertyNameCaseInsensitive = true,
    NumberHandling = JsonNumberHandling.AllowReadingFromString
                   | JsonNumberHandling.AllowNamedFloatingPointLiterals,
    ReadCommentHandling = JsonCommentHandling.Skip,
    AllowTrailingCommas = true,
};

AllowReadingFromString alone kills the single most common failure — "quantity": "12" where the docs promised 12. It costs one line and no custom code.

Which cases still need a converter?

Symptom in the payload Mechanism The trap
"12" where a number was documented JsonNumberHandling.AllowReadingFromString None — use the built-in
A single object where an array was documented Single-or-array converter Do not silently drop the case where it is neither
"" for a date Lenient DateTimeOffset converter → null Only if the target is nullable; never DateTime.MinValue
An unknown enum member Enum converter with an explicit Unknown Never map the unknown onto a valid member
"1" / "Y" / "true" for a boolean Lenient bool converter Enumerate the accepted values; do not treat “anything non-empty” as true
A field that vanishes entirely Nullable property + required on the ones that truly matter Distinguish “absent” from “zero”

The single-or-array converter is the one you will write most often, because it comes from a very common server-side pattern — serializing a collection that happens to contain one element as a bare object:

public sealed class SingleOrArrayConverter<T> : JsonConverter<T[]>
{
    // Without this, System.Text.Json short-circuits a null token and never
    // calls Read at all — the Null branch below would be dead code.
    public override bool HandleNull => true;

    public override T[] Read(ref Utf8JsonReader reader, Type _, JsonSerializerOptions options)
    {
        switch (reader.TokenType)
        {
            case JsonTokenType.Null:
                return [];

            case JsonTokenType.StartObject:
                return [JsonSerializer.Deserialize<T>(ref reader, options)!];

            case JsonTokenType.StartArray:
                // Read the elements yourself. Calling Deserialize<T[]>(ref reader, options)
                // here re-enters THIS converter — an unbounded recursion that ends in a
                // StackOverflowException, which .NET will not let you catch.
                var items = new List<T>();
                while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
                    items.Add(JsonSerializer.Deserialize<T>(ref reader, options)!);
                return items.ToArray();

            default:
                // Anything else is genuinely unexpected — let it throw, and let the
                // exception name the type. Do NOT return an empty array here.
                throw new JsonException($"Unexpected token {reader.TokenType} for {typeof(T).Name}");
        }
    }
}

Two things in there are worth more than they look.

The recursion comment is not hypothetical. The obvious-looking JsonSerializer.Deserialize<T[]>(ref reader, options) for the array case reads as “the normal path, handled normally” — but options still contains this converter, so it calls itself. The failure is a stack overflow, which terminates the process without an exception you can log. It only triggers on the array input, which is the common shape, so it will get past a test that only covers the interesting single-object case.

The default branch is the discipline. It would be easy to return [] there and have the integration “never crash”. That is exactly how a silent data-loss bug is born: a payload arrives in a shape you have never seen, you record zero items, and the reconciliation report is wrong for a month before anyone notices.

Why an unknown enum needs its own member

Providers add enum values without considering it a breaking change. A new courier status, a new order state, a new document type — from their side it is additive.

JsonStringEnumConverter will throw on an unrecognised value, which takes down the whole record. The instinct is to map the unknown onto a safe default. Resist it:

public enum OrderStatus
{
    Unknown = 0,     // reserved for values we have not seen before
    New = 1,
    Confirmed = 2,
    Cancelled = 3,
}

Unknown must be a member that your business logic explicitly refuses to act on — it should route the record to a review queue, not into the happy path. If you map an unrecognised status onto New, you have not made the system resilient, you have made it confidently wrong. And note the direction of danger: mapping an unknown onto Cancelled would be worse still.

Tolerate the shape, never the meaning

This is the line that decides whether tolerant parsing is engineering or negligence.

Acceptable: a price that arrives as "14.90" instead of 14.90. Same value, different encoding — the meaning is unambiguous.

Not acceptable: a price that is missing, and you default it to 0. Zero is a real price. You have manufactured a fact.

The test I apply: could a reasonable person read the tolerated value differently from the raw one? If yes, it is not a shape fix, it is an invention. Missing money, missing quantities, missing identifiers and missing timestamps all belong in the second category — they should surface as null and force a decision, or fail loudly.

C# 11’s required members are the enforcement mechanism for the fields where absence is genuinely fatal:

public sealed class OrderDto
{
    public required string Id { get; init; }        // absent → fail, immediately
    public required decimal Total { get; init; }
    public DateTimeOffset? ShippedAt { get; init; } // legitimately absent
    public OrderStatus Status { get; init; }
}

Keep the ugly DTO away from your domain

The DTO is allowed to be permissive, nullable and unpleasant. Your domain model is not.

Two types, one explicit mapping step between them. The mapping is where you decide what an absent value means in your business, and it is a far better place for that decision than a converter, because it has context a converter never will.

This also gives you a clean seam for validation. A DTO that deserialized successfully is not the same as a payload that made sense — an order with a Total that does not match the sum of its lines parses perfectly.

How do you find out the contract actually changed?

This is the part that is usually missing, and without it the whole approach backfires. If your converters absorb everything silently, you have built a system that cannot tell you when the provider changed.

Two mechanisms, both cheap:

  • Count every tolerance. Each converter fallback increments a metric tagged with the field name. A single alert on “we started accepting string-encoded numbers on a field that never sent them” gives you days of warning instead of an incident.
  • Capture unmapped members. JsonSerializerOptions.UnmappedMemberHandling can be set to Disallow to throw on unknown properties — usually too aggressive for a live integration, but excellent in a test suite run against recorded payloads. In production, prefer an [JsonExtensionData] dictionary that you log the keys of.

Then keep golden files: real responses, saved verbatim, replayed in tests. When the provider sends a new shape, you want to find out from a failing CI run against a freshly captured sample, not from a support ticket. On integrations where the provider’s output drifted regularly, the golden-file suite caught more real problems than any other test category.

What not to do

  • Deserialize everything into JsonElement or dynamic. You have not solved the problem, you have moved it to the call site — where it is now scattered across dozens of places instead of one converter, and the compiler helps you with none of it.
  • Wrap the deserialize in try/catch and return null. This discards the entire record because one field was odd, and destroys the diagnostic — you know something failed, but not what.
  • Make everything nullable to make the errors stop. Now every consumer downstream has to handle nulls that cannot actually occur, and the ones that genuinely can are indistinguishable.
  • Fix it only in the DTO. If a field is unreliable, that fact is interesting to the business, not just to the parser. Surface it.

The underlying principle is the one that carried over from a very different domain — telephony interop, where the other end implements the same protocol you do and still gets it subtly wrong. You do not get to fix the other implementation. You get to be precise about which deviations you absorb, and to keep a record of every one you did.

If you are building an integration against an API that keeps surprising you and want a second opinion on where to put the tolerance, that is a conversation I am glad to have.

Where I worked under these requirements

  • Atos IT Solutions and Services

    Software Engineer

    Oct 2019 – Feb 2022

The project behind this article eMAG Marketplace Automation A SaaS hub for eMAG sellers — orders, AWB, automated pricing and fraud detection.

Working on something similar?

If you are building in this space, let us talk for 30 minutes.

Book a call
All articles