Hi, Devlin. Ten years of fullstack, currently in Norway, and I have just enough scars from integrating payment APIs to have opinions about the bits of OpenAPI 3.1 that nobody talks about. This post is for the "our contract is serious now " phase: the moment you stop describing endpoints with a wiki page and start asking what does my generated client actually need from this spec?
Three features carry most of the weight, and DaloyJS exposes all three as first-class building blocks: top-level webhooks for events you emit, route-level callbacks for out-of-band calls you make back to the consumer, and the discriminator + discriminatedUnion pair that turns polymorphic payloads into tagged TypeScript unions a switch statement actually understands.
The pain you're here to fix
# A representative API contract circa "we'll figure it out later":
#
# - "We POST to your /webhooks/billing endpoint when an invoice is paid.
# Body shape? Check our PDF docs. Versioning? We promise we won't break it.
# Auth? You'll see an X-Signature header. Algorithm? Email support."
#
# - "The booking event has 12 'type' values. Each type has a totally different
# payload. TypeScript? Sorry, it's all 'data: Record<string, unknown>'."
#
# - "When you create the payment, give us a callback URL. We'll POST 'something'
# back when it's done. Generated SDK? Nope. Contract test? Nope. Vibes? Yes."
#
# Three things every grown-up API has — webhooks, callbacks, and polymorphic
# payloads — and three things the average OpenAPI spec quietly skips.
# That's the gap this post fills.
three classic gaps · all fixable in 3.1TS · UTF-8 · LF
Webhooks: the events YOU send
OpenAPI 3.0 had no native concept of "the things this API posts to you". People worked around it by inventing companion specs, prose docs, or — most often — nothing at all. OpenAPI 3.1 added a top-level webhooks map, which is the spec saying here are the requests this API will send, and here is their exact shape. DaloyJS lets you declare them next to your routes:
// src/index.ts — top-level webhooks describe events YOU send to consumers.
// They never become a route on your server. They show up in the OpenAPI
// document and, more importantly, in every generated client SDK.
import { App, generateOpenAPI } from "@daloyjs/core";
import { z } from "zod";
const app = new App({
openapi: {
info: { title: "Bookstore API", version: "1.0.0" },
webhooks: {
// The webhook name is what every client SDK exposes.
"invoice.paid": {
method: "POST",
operationId: "invoicePaidWebhook",
summary: "Sent when an invoice transitions to PAID.",
tags: ["Webhooks", "Billing"],
request: {
headers: z.object({
"x-signature": z.string().describe("HMAC-SHA256 of body, hex"),
"x-event-id": z.string().uuid(),
}),
body: z.object({
type: z.literal("invoice.paid"),
data: z.object({
invoiceId: z.string().uuid(),
amountCents: z.number().int().positive(),
currency: z.enum(["EUR", "NOK", "USD"]),
paidAt: z.string().datetime(),
}),
}),
},
responses: {
200: {
description: "Consumer acknowledged the event.",
body: z.object({ received: z.literal(true) }),
},
410: {
description: "Consumer endpoint no longer exists — we stop retrying.",
},
},
},
},
},
});
OpenAPI 3.1 top-level webhooks · never become routesTS · UTF-8 · LF
The webhook is never bound to a path on yourserver — it's a contract for consumers. What changes is what falls out of pnpm genon the consumer's side:
apps/consumer/app/webhooks/billing/route.ts
// On the CONSUMER's side, the generated SDK contains a typed adapter:
//
// import { handleInvoicePaidWebhook } from "@yourapi/client";
//
// export async function POST(req: Request) {
// const { signature, event, error } = await handleInvoicePaidWebhook(req, {
// secret: process.env.INVOICE_WEBHOOK_SECRET!,
// });
// if (error) return error.toResponse(); // ← typed problem+json
// // event is fully typed: event.data.amountCents is `number`, not `unknown`.
// await db.invoices.update(event.data.invoiceId, { status: "paid" });
// return new Response(JSON.stringify({ received: true }));
// }
//
// The webhook section appears in /openapi.json under the top-level
// "webhooks" key (OpenAPI 3.1 feature; not available in 3.0). Scalar
// renders it in your /docs UI alongside paths and components.
typed receiver · signature verification · zero unknownsTS · UTF-8 · LF
3.1 onlyTop-level webhooks
Not present in 3.0. Make sure your spec consumers (Postman, Scalar, Hey API, Speakeasy, the lot) target 3.1. Scalar renders them in the /docs UI alongside paths and components automatically.
Callbacks: the events YOU make in response to a request
Webhooks are subscriptions — you set them up out-of-band and they fire whenever an event happens. Callbacks are different: they are out-of-band requests tied to a specific operation. The canonical example is payments. The consumer creates a payment with a callbackUrl; you POST to that URL when the payment settles.
The OpenAPI runtime expression {$request.body#/callbackUrl} tells the spec consumer where the callback URL comes from— which field of which message. That's the difference between a tool being able to generate a mock callback server and a sentence in a README saying "put the URL in callbackUrl".
// src/routes/payments.ts — callbacks describe out-of-band requests YOUR
// API will make back to the consumer. The canonical example: create a
// payment, get notified later when it settles.
import { z } from "zod";
import type { CallbackMap } from "@daloyjs/core";
const PaymentCreate = z.object({
amountCents: z.number().int().positive(),
currency: z.enum(["EUR", "NOK", "USD"]),
callbackUrl: z.string().url(), // ← the URL we'll POST to later
});
const paymentCallbacks: CallbackMap = {
// Callback name — appears in the generated client SDK.
paymentSettled: {
// Runtime expression — the spec value. Tells consumers WHERE we'll POST.
"{$request.body#/callbackUrl}": {
method: "POST",
operationId: "paymentSettledCallback",
summary: "We POST this when the payment leaves PENDING.",
request: {
headers: z.object({
"x-signature": z.string(),
}),
body: z.object({
paymentId: z.string().uuid(),
status: z.enum(["captured", "failed"]),
failureReason: z.string().optional(),
}),
},
responses: {
200: { description: "Consumer acknowledged." },
4: { description: "We retry 4xx up to 5 times with backoff." },
},
},
},
};
app.route({
method: "POST",
path: "/payments",
operationId: "createPayment",
request: { body: PaymentCreate },
responses: {
202: {
description: "Accepted — payment is pending. Watch for the callback.",
body: z.object({ paymentId: z.string().uuid() }),
},
},
callbacks: paymentCallbacks, // ← attaches to THIS operation only
handler: createPaymentHandler,
});
callback attached to ONE operation · runtime expression points at request bodyTS · UTF-8 · LF
// Why bother spelling this out in the spec?
//
// 1. The runtime expression "{$request.body#/callbackUrl}" tells the spec
// consumer EXACTLY which field of the request body becomes the URL.
// Generated clients can build mock servers around it. Postman renders it.
//
// 2. The request/response bodies attached to the callback are validated
// types in the generated SDK. The consumer's endpoint receives
// "PaymentSettledCallbackBody" — never "unknown" — and gets to use the
// framework's response helpers for the 200 ack.
//
// 3. Contract tests on the consumer side can pin the EXACT shape they'll
// accept: `expectContract<PaymentSettledCallbackBody>(req.body)`. If you
// bump the API minor version and forget to update the callback, CI fails.
//
// 4. Documentation. Your Scalar UI shows the callback as a sub-operation of
// POST /payments, with its own example payload and response. Onboarding
// docs basically write themselves.
four reasons the spec gives you back the time you spent writing itTS · UTF-8 · LF
Discriminators: the bare spec builder
When you have hand-rolled JSON schemas already (legacy spec migration, third-party schema files you compose with), the discriminator() helper is the small, type-checked spec object you want. It validates the property name at boot, so an empty string never silently lands in your spec:
// src/schemas/animal.ts — the bare discriminator() helper.
// Use when you already have a hand-rolled JSON schema and want to attach
// the OpenAPI discriminator object cleanly:
import { discriminator } from "@daloyjs/core";
export const Animal = {
oneOf: [
{ $ref: "#/components/schemas/Cat" },
{ $ref: "#/components/schemas/Dog" },
{ $ref: "#/components/schemas/Owl" },
],
discriminator: discriminator("kind", {
cat: "#/components/schemas/Cat",
dog: "#/components/schemas/Dog",
owl: "#/components/schemas/Owl",
}),
};
bare OpenAPI 3.1 discriminator object · throws on empty propertyNameTS · UTF-8 · LF
discriminatedUnion(): the one you'll actually use
discriminatedUnion()is the daily-driver helper. It's a Standard Schema, so it validates request and response bodies at runtime; it also exposes a .toJSONSchema() projection so the OpenAPI generator picks up the oneOf + discriminator pair without any glue. One declaration; both jobs done:
src/schemas/booking-event.ts
// src/schemas/booking-event.ts — the everyday case: runtime validator +
// OpenAPI emitter in one. Drop-in for any route's request or response body.
import { z } from "zod";
import { discriminatedUnion } from "@daloyjs/core";
const BookingCreated = z.object({
type: z.literal("booking.created"),
bookingId: z.string().uuid(),
customerId: z.string().uuid(),
totalCents: z.number().int().positive(),
});
const BookingCancelled = z.object({
type: z.literal("booking.cancelled"),
bookingId: z.string().uuid(),
reason: z.enum(["user_requested", "payment_failed", "fraud"]),
refundCents: z.number().int().nonnegative(),
});
const BookingShipped = z.object({
type: z.literal("booking.shipped"),
bookingId: z.string().uuid(),
carrier: z.enum(["posten", "bring", "dhl"]),
trackingNumber: z.string(),
});
// One union. Three variants. Runtime validation + JSON Schema in one call:
export const BookingEvent = discriminatedUnion("type", {
"booking.created": BookingCreated,
"booking.cancelled": BookingCancelled,
"booking.shipped": BookingShipped,
});
// Use it like any other schema:
app.route({
method: "POST",
path: "/events/booking",
operationId: "ingestBookingEvent",
request: { body: BookingEvent },
responses: { 202: { description: "Accepted" } },
handler: async ({ body }) => {
// `body` is a discriminated union. TypeScript narrows on `body.type`.
switch (body.type) {
case "booking.created": return create(body); // ← totalCents is in scope
case "booking.cancelled": return cancel(body); // ← refundCents in scope
case "booking.shipped": return ship(body); // ← trackingNumber in scope
}
},
});
runtime validator + OpenAPI emitter · TypeScript narrows on body.typeTS · UTF-8 · LF
What lands in the spec is the boring, standards-compliant shape every code generator understands:
generated/openapi.json · #/components/schemas/BookingEvent
// What `BookingEvent` emits into your OpenAPI spec — the part the generated
// client SDK reads to produce a real TypeScript tagged union:
{
"oneOf": [
{ "$ref": "#/components/schemas/BookingCreated" },
{ "$ref": "#/components/schemas/BookingCancelled" },
{ "$ref": "#/components/schemas/BookingShipped" }
],
"discriminator": {
"propertyName": "type",
"mapping": {
"booking.created": "#/components/schemas/BookingCreated",
"booking.cancelled": "#/components/schemas/BookingCancelled",
"booking.shipped": "#/components/schemas/BookingShipped"
}
}
}
oneOf + discriminator.mapping · the canonical 3.1 polymorphic shapeTS · UTF-8 · LF
And the client SDK — the one Hey API generates for you — picks it up as a real tagged union, with exhaustive switch protection on the consumer side. This is the bit your future self will thank you for:
generated/client/types.gen.ts
// generated/client/types.gen.ts (output, not hand-written)
export type BookingEvent =
| { type: "booking.created"; bookingId: string; customerId: string; totalCents: number }
| { type: "booking.cancelled"; bookingId: string; reason: "user_requested" | "payment_failed" | "fraud"; refundCents: number }
| { type: "booking.shipped"; bookingId: string; carrier: "posten" | "bring" | "dhl"; trackingNumber: string };
// apps/web/components/event-feed.tsx — react consumer.
function EventRow({ event }: { event: BookingEvent }) {
switch (event.type) {
case "booking.created":
// event is narrowed: totalCents exists, refundCents does NOT.
return <span>New booking {event.bookingId} · {event.totalCents / 100} EUR</span>;
case "booking.cancelled":
return <span>Cancelled {event.bookingId} · refund {event.refundCents / 100} EUR ({event.reason})</span>;
case "booking.shipped":
return <span>Shipped {event.bookingId} via {event.carrier} · {event.trackingNumber}</span>;
}
}
// Add a new variant in the SERVER schema, run `pnpm gen` on the CLIENT side,
// and the TypeScript compiler instantly flags every `switch` that doesn't
// handle it. That's the entire point.
tagged union · narrowed inside switch · new variant = type errorTS · UTF-8 · LF
The combo: webhooks + discriminatedUnion
Here is the pattern I keep recommending in design reviews: one webhook entry whose body is a discriminated union over every event type. The alternative — one webhook per event type — produces an SDK with N near-identical handlers, N opportunities to forget signature verification, and N opportunities to disagree with yourself about retry semantics. The combo collapses all of that to one typed handler with one exhaustive switch:
src/index.ts + apps/consumer/route.ts
// The pattern: ONE webhook entry whose body is a discriminated union over
// every event type. The generated client gets one typed handler with
// exhaustive switch coverage instead of N near-identical webhook adapters.
const app = new App({
openapi: {
info: { title: "Bookstore API", version: "1.0.0" },
webhooks: {
"booking.event": {
method: "POST",
operationId: "bookingEventWebhook",
summary: "Fires for every booking lifecycle transition.",
request: {
headers: z.object({ "x-signature": z.string() }),
body: BookingEvent, // ← the discriminatedUnion above
},
responses: {
200: { description: "Acknowledged" },
},
},
},
},
});
// Consumer (Next.js route handler):
export async function POST(req: Request) {
const { event, error } = await handleBookingEventWebhook(req, {
secret: process.env.BOOKING_WEBHOOK_SECRET!,
});
if (error) return error.toResponse();
switch (event.type) { // ← exhaustive, type-checked
case "booking.created": return ingestCreated(event);
case "booking.cancelled": return ingestCancelled(event);
case "booking.shipped": return ingestShipped(event);
}
}
one webhook · union body · one consumer handler · exhaustive switchTS · UTF-8 · LF
The codegen loop and the CI gate
None of this matters if the spec drifts from the live route table. The discipline is the same as for plain routes: dump the spec from buildApp() in a script, regenerate the client, and gate CI on git diff --exit-code against the committed spec. Add a webhook? The spec changes, the diff fails, the PR forces you to regenerate. No drift, no surprises.
# The end-to-end loop you actually run:
pnpm gen:openapi # dumps your spec to generated/openapi.json
pnpm gen # Hey API codegen → generated/client/
# What ends up on disk:
generated/
├─ openapi.json # the OpenAPI 3.1 doc (with paths, webhooks, components)
└─ client/
├─ types.gen.ts # tagged unions for every discriminatedUnion()
├─ sdk.gen.ts # typed functions for routes + webhooks + callbacks
└─ client.gen.ts # the fetch wrapper
# CI gate: spec drift = test failure.
pnpm gen:openapi
git diff --exit-code generated/openapi.json
# ─→ exits 1 if the committed spec doesn't match the live route registry.
pnpm gen:openapi · pnpm gen · git diff --exit-code in CITS · UTF-8 · LF
When to reach for which
# When to reach for which feature.
webhooks (top-level) ↳ Events YOU emit to consumers. They never become
a route on your server. Always pair with
x-signature headers and a discriminated union
body if you have more than one event type.
callbacks (route-level) ↳ Out-of-band requests YOUR API will make back to
the consumer that triggered an operation.
Canonical: payments, long-running jobs, OAuth.
The runtime expression points at the request
field that supplied the URL.
discriminator() ↳ Bare OpenAPI 3.1 spec builder. Use when you
already have hand-rolled JSON schemas and just
want the discriminator block.
discriminatedUnion() ↳ Runtime validator + OpenAPI emitter, in one.
Default choice for polymorphic request/response
bodies. Produces exhaustive TypeScript tagged
unions on the client side.
bookmark · re-read at design review timeTS · UTF-8 · LF
Honest caveats
- OpenAPI 3.1 only. Webhooks at the top level and JSON Schema 2020-12 keywords do not existin 3.0. If your generator targets 3.0 you'll silently lose the webhooks block.
- Discriminator mapping is required by some generators. A few SDK generators won't build proper tagged unions without an explicit mapping. The helpers in DaloyJS emit one by default — keep it.
- Webhook signing isn't the spec's job. The header lives in the spec; the algorithm and the secret rotation strategy do not. Pick one (HMAC-SHA256 of the raw body, hex), document it in the operation
description, and ship a signing helper next to the client SDK so consumers don't roll their own.
Where to go next
The post that's closest in spirit is Contract-First Without the Codegen Dance — same philosophy, different feature surface. For the recipient side of any of this, the RFC 9457 errors post explains why your callback/webhook responses should be problem+json all the way down. And if you're still assembling the route table itself, the bookstore tutorial is the route-by-route starter.
Webhooks, callbacks, and discriminators are the three places where an OpenAPI document earns its keep. If your spec doesn't describe them today, the generated client doesn't know about them, the consumer's code is full of unknown, and the "just check the PDF" messages have already started landing in support. Pick one feature this sprint. Future you will be unreasonably grateful.
— Devlin