Production
The outside world is a webhook — decode it
The decoders lesson makes the argument in the abstract: the outside world sends you unknown, not types, so validate at the boundary. This post is what that looks like once a real integration is live — where a mis-parsed webhook means your app tells a customer their payment failed while the provider has their money.
A handler that cannot see undecoded data
Third-party events arrive as webhooks rather than a socket, so the load balancer can spread them across API instances. Every one of them is registered through a single wrapper whose whole job is to make raw input unreachable:
// Api/src/External/Api.ts
export type Api<Route extends string, UrlParams, BodyParams> = {
route: Route
urlParamsDecoder: JD.Decoder<UrlParams>
bodyParamsDecoder: JD.Decoder<BodyParams>
}
export function externalApi(app, { contract, handler }): void {
const handlerRunner = (req, res) => {
const paramsResult = decodeParams(
req, contract.urlParamsDecoder, contract.bodyParamsDecoder)
if (paramsResult._t === "Ok") {
return runHandler(paramsResult.value, handler, res)
} else {
Logger.error(`[EXTERNAL-API]: ${req.path}`)
Logger.error(JD.formatInline(paramsResult.error))
}
// Always return 200 else the sender will keep retrying
// which is pointless for us
return ok200(res)
}
app.post(expressRoute, handlerRunner)
} The handler's type is ApiHandler<UrlParams & BodyParams> — a type that only exists on the far side of the decoder. There is no code path where handler logic touches req.body. If the vendor ships an API version that renames a field, the result is a logged decode error with the full payload, not a handler doing arithmetic on undefined.
Notice the honest comment, too. A malformed message is acknowledged with a 200 anyway — because the sender would retry with backoff for a day, and a payload that failed to decode once will fail every retry. Knowing when not to error is also boundary design.
One event, end to end
Here is the entire file for a payment status webhook — the event that decides whether a customer sees their order confirmed:
// Api/src/External/Api/PaymentStatus.ts (61 lines, complete)
export type UrlParams = { accountID: ProviderAccountID; paymentID: ProviderObjectID }
export type BodyParams = { status: PaymentStatus; amount: Cents }
export type PaymentStatus = "CAPTURED" | "FAILED"
const urlParamsDecoder: JD.Decoder<UrlParams> = JD.object({
accountID: providerAccountIDDecoder,
paymentID: providerObjectIDDecoder,
})
const bodyParamsDecoder: JD.Decoder<BodyParams> = JD.object({
status: JD.oneOf(["CAPTURED", "FAILED"]),
amount: centsDecoder,
})
export async function handler(params: UrlParams & BodyParams) {
const { accountID, paymentID, status, amount } = params
await PaymentRow.updateStatus(accountID, paymentID, status, amount)
publishToQueue(accountID, paymentID)
} Contract, decoders, handler — done. Even accountID and paymentID from the URL path are decoded into opaque types; a webhook aimed at a payment that can't exist never reaches the database call. And amount is Cents, not number, because the one thing worse than a rejected webhook is a silently hundred-fold payment.
Decoders shaped like the real sender
Textbook JSON uses null or omits absent fields. Plenty of real APIs — especially anything with an enterprise integration behind it — send empty strings instead. You could normalize that in every handler, forever — or you could say it once, as a combinator:
// Core/Data/Decoder.ts
export function optionalStringDecoder<T>(
decoder: JD.Decoder<T>,
): JD.Decoder<T | undefined> {
return JD.optional(
JD.string.transform((s) =>
s.length === 0 ? undefined : decoder.verify(s),
),
)
} Now "the field is missing, empty, or a valid value of type T" is one reusable fact about that vendor, and the nightly sync report — the endpoint where every sub-system reports whether it ran — can describe itself as typed, individually-optional telemetry:
// Api/src/External/Api/SyncReport.ts (excerpt)
type Status = "OK" | "SKIPPED" | "ERROR"
const bodyParamsDecoder = JD.object({
resources: JD.object({
customers: JD.optional(statusDecoder),
invoices: JD.optional(statusDecoder),
payments: JD.optional(statusDecoder),
refunds: JD.optional(statusDecoder),
// …every resource, individually optional
}),
quotaUsedPercentage: percentDecoder, // opaque Percent, not number
}) "Outside" includes you
The discipline gets interesting when you notice how much of "your own" system is actually the outside world:
- Your own messages, round-tripped. API instances hand work to each other through a queue. Messages they themselves published are still parsed with a
Result-returningparseJSONand a contract decoder on receipt — because between publish and receive sit serialization, a broker, and every deploy where two API versions briefly coexist. - Your environment.
Env.tsdecodes every variable — ports through a number-string decoder,NODE_ENVthroughJD.oneOf— and this is the one place the process is allowed to die: a misconfigured server should not start. - Your own database. Every Kysely row passes a
rowDecoderbefore it becomes a domain value. Migrations drift, columns get altered by hand in a staging incident, atextcolumn holds something no current code writes. The database is a boundary too.
The cost, honestly
This is not free. Every webhook needs a contract file. Every table carries decoder boilerplate. The decoder for your central aggregate — the order, the claim, the booking, whatever your system is actually about, with its line items and nested unions — will pass a thousand lines, and it will look absurd next to the three-line interface it replaced. There are also holes worth punching deliberately: binary attachment blobs read out of Postgres are checked with JD.instanceOf(Buffer) and nothing more, because base64-encoding megabytes of files through a JSON decoder is the wrong kind of purity. Write the reason next to it and move on.
What you buy is a specific guarantee: every assumption about external data is written down, in one place, and enforced at runtime. When the vendor's next API version changes a field, the failure is a decode error naming the exact path that surprised you — not a corrupted order view three components downstream.