Strict JSON Decoding as a UX Feature

Go's encoding/json package ships with a decoder option that most API developers rarely think about: DisallowUnknownFields. When enabled, the decoder rejects any JSON property that doesn't map to a field on the destination struct. At first glance this sounds like a constraint, but for web APIs it's actually a powerful tool for catching integration mistakes early — before they turn into silent failures or security problems.

Consider a common scenario: a client calls POST /access-tokens with an optional expires_in parameter that sets the token's lifetime in seconds. A developer misreads the docs and sends expires: 3600 instead. Without strict decoding, the request succeeds but the expiry is silently ignored. The result is a token that never expires — a potential security hole that the client has no way to detect.

With DisallowUnknownFields, that same request fails immediately with an error message that names the offending field. The client knows exactly what went wrong and can fix it in seconds:

$ curl -i -H "Authorization: Bearer $CRUNCHY_API_KEY" \
    -H "Content-Type: application/json"
    -X POST $CRUNCHY_API_URL/access-tokens -d '{"expires":3600}'

HTTP/2 400
{
    "message":"Invalid JSON in request body: json: unknown field \"expires\".",
    "request_id":"5d2078fe-6ea5-4f41-816e-4717cf6c22b7"
}

The feature is cheap to implement and rarely needed in daily work, but when it does fire, it saves real debugging time.

Rolling It Out Without Breaking Existing Clients

Strict decoding isn't something you can flip on globally if you already have production traffic. Clients that have been sending slightly-off JSON for years — fields that were always ignored — will suddenly start failing. The rollout needs to be gradual:

  • Apply the check only to new endpoints, leaving existing ones on permissive decoding.
  • Add logging probes on legacy endpoints that record every unknown field encountered. Search those logs periodically to see what's actually being sent and how often.
if err := decoder.Decode(v); err != nil {
    if strings.Contains(err.Error(), "unknown field") {
        plog.Logger(ctx).WithFields(logrus.Fields{
            "api_endpoint_method": r.Method,
            "api_endpoint_path":   r.URL.Path,
        }).Warnf("Unknown field error: %s.", err)

        decoderAllowingUnknown := json.NewDecoder(bytes.NewReader(rawPayload))
        err = decoderAllowingUnknown.Decode(v)
    }

    if err != nil {
        apierror.NewBadRequestError(
            r.Context(),
            fmt.Sprintf("Invalid JSON in request body: %s.", err),
        ).Write(r.Context(), w)
        return nil, false
    }
}
  • If a particular unknown field shows up frequently, don't chase down individual users. Instead, add a hidden field to the struct that matches the bad parameter, which keeps DisallowUnknownFields enabled while preserving compatibility for existing integrations.
// Request parameters for creating a new access token.
type AccessTokenCreateRequest struct {
    ...

    // When activating strict JSON parameter validation we found that Customer X
    // was accidentally sending `expires` instead of `expires_in`. We've asked
    // them to stop, but in the meantime we allow this parameter so we don't
    // break them.
    Expires int `json:"expires" openapi:"hide" validate:"-"`
}

This grandfathering approach stops being practical if you have dozens of legacy fields, but for most APIs the number of problematic fields will be small.

Deprecating Fields With Care

When a field is no longer used, the natural instinct is to delete it from the request struct. With DisallowUnknownFields in place, that's a breaking change: any client still sending the field will get an error, even if the field has been dead weight for years.

The safe approach is to keep deprecated fields in the struct but mark them clearly in documentation and generated bindings so developers know they're obsolete. Add searchable log markers around them — something like access_token_client_id_received — and track whether anyone is still sending them. Once a marker hasn't appeared for a long stretch, run a cleanup pass and remove the field for good.

// Request parameters for creating a new access token.
type AccessTokenCreateRequest struct {
    ...

    // Client ID is the unique identifier of the API key that the new access
    // token should be associated with.
    //
    // Deprecated: This field used to be required, but an associated access
    // token is now inferred automatically using the secret included as part of
    // the `Authorization` header. This parameter is now ignored.
    ClientID *eid.EID `json:"client_id" validate:"-"`
}

Know Where to Keep the Door Open

Strict decoding is the right default for most request endpoints, but you'll need escape hatches for specific situations. The clearest example is webhook receivers. A webhook is the push API of some other vendor, and those vendors routinely add new fields to their payloads without treating it as a breaking change. Your receiver might work perfectly with strict decoding for months, then suddenly every request fails overnight when the vendor adds a single new parameter.

An endpoint framework should therefore allow opt-out. That's exactly what we did internally: an AllowUnknownJSONFields option on the endpoint declaration disables the check for routes that need it.

// Webhook endpoint where Stripe broadcasts asynchronous message about customer
// payment information.
type StripeWebhookEndpoint struct{}

func (e *StripeWebhookEndpoint) Materialize() apiendpoint.APIEndpointer {
    return &apiendpoint.APIEndpoint[StripeWebhookRequest, StripeWebhookResponse]{
        Extras: apiendpoint.APIEndpointExtras{
            AllowUnknownJSONFields: true, // <-- unknown fields allowed
        },
        Method: http.MethodPost,
        Route:  "/webhook",
        ServiceHandler: func(svc any) func(ctx context.Context, req *StripeWebhookRequest) (*StripeWebhookResponse, error) {
            return svc.(StripeService).Webhook
        },
        SuccessStatusCode: http.StatusOK,
        Title:             "Stripe webhook receiver",
    }
}

Beyond Go

DisallowUnknownFields is Go-specific, but the pattern translates easily to other ecosystems. Most JSON libraries in other languages allow you to enforce strictness with a little custom code, and the payoff is the same regardless of language.

A Natural Improvement: Spelling Suggestions

Error messages that say unknown field "expires" are helpful, but there's an obvious upgrade. By computing the Levenshtein distance between the offending field name and all known parameters, the error can suggest the likely intended spelling. A client sending expires would be told they probably meant expires_in, turning a minutes-long debugging session into a seconds-long fix.

Invalid JSON in request body: unknown field "expires". Did you mean "expires_in"?"