Production

The outside world is a webhook — decode it

TypeFirst · AgileLabs Engineering · 10 min read

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:

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.

Learn the technique. The decoders lesson builds this pattern from zero, and ts-bedrock ships the same boundary machinery.

← All posts