OpenAPI Code Generation with Corvus: Callbacks, Webhooks and Links
At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at authentication patterns.
So far in this series, we've focused on the classic HTTP request/response pattern: a client sends a request, a server returns a response. But modern APIs increasingly need to push notifications back to their clients. They need to tell you when an order ships, when a payment succeeds, or when a long-running operation completes. OpenAPI models this with webhooks, callbacks, and links.
The notification symmetry problem
When your API server sends a webhook to a subscriber, something interesting happens to the client/server roles. Your API server becomes a client (it sends an HTTP request to the subscriber's URL). The subscriber's application becomes a server (it exposes an endpoint to receive the notification).
This role reversal creates two code generation needs from the same spec:
| You are building… | You need… | Command |
|---|---|---|
| The subscriber app | A server to receive the webhooks | openapi-callback-server |
| The API server | A client to send the webhooks | openapi-callback-client |
Both sides benefit from the same typed validation and schema enforcement that the regular openapi-client and openapi-server commands provide. The subscriber gets schema-validated payloads with typed handler interfaces. The API server gets a typed client that validates notifications before sending them.
Generating a callback server (receiving webhooks)
When your application subscribes to a service's events, you need endpoints to receive the callbacks. The openapi-callback-server command generates them:
corvusjson openapi-callback-server petstore.json \
--rootNamespace MyApp.WebhookReceiver \
--outputPath ./Generated/WebhookReceiver
This produces the same structure you've seen from openapi-server - handler interfaces, endpoint registration, params and result types - but for the callback and webhook operations rather than the main API paths.
Wire it up exactly as you would a regular generated server:
using Corvus.Text.Json;
using MyApp.WebhookReceiver;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
WebhookHandler handler = new();
app.MapApiEndpoints(handler);
app.Run();
Your handler implements the generated interface. Each webhook or callback operation gets its own method with typed parameters:
internal sealed class WebhookHandler : IApiCallbacksHandler, IApiWebhooksHandler
{
public ValueTask<OnEventCallbackResult> HandleOnEventCallbackAsync(
OnEventCallbackParams parameters,
JsonWorkspace workspace,
CancellationToken cancellationToken = default)
{
// parameters.Body is the typed, schema-validated notification payload
Console.WriteLine($"Received event: {parameters.Body.EventType}");
return new(OnEventCallbackResult.Ok());
}
public ValueTask<StatusChangeWebhookResult> HandleStatusChangeWebhookAsync(
StatusChangeWebhookParams parameters,
JsonWorkspace workspace,
CancellationToken cancellationToken = default)
{
Console.WriteLine($"Status changed to: {parameters.Body.NewStatus}");
return new(StatusChangeWebhookResult.Ok());
}
}
The generator separates callbacks (defined inline on path operations) from webhooks (defined at the top level of the spec) into distinct handler interfaces. If your spec defines both, your handler class implements both. If it only uses one, you only implement that interface.
Generating a callback client (sending webhooks)
On the other side, when your API server needs to dispatch notifications to subscribed clients, you generate a typed client:
corvusjson openapi-callback-client petstore.json \
--rootNamespace MyApp.WebhookSender \
--outputPath ./Generated/WebhookSender
This produces a client class with methods for each callback/webhook operation. You use it in your server's business logic when you need to notify subscribers:
using Corvus.Text.Json.OpenApi.HttpTransport;
using MyApp.WebhookSender;
// When a pet is adopted, notify the subscriber at their registered URL
using HttpClient httpClient = new() { BaseAddress = new Uri(subscriberCallbackUrl) };
await using HttpClientTransport transport = new(httpClient);
await using ApiWebhooksClient client = new(transport);
await using PetAdoptedWebhookResponse response = await client.PetAdoptedWebhookAsync(
body: new PetAdoptedEvent.Source((ref PetAdoptedEvent.Builder b) =>
{
b.Create(petId: "pet-123"u8, adopterId: "user-456"u8);
}));
The payload is validated against the schema before sending, just as with regular client requests. If your notification payload doesn't match the spec, you find out at publish time rather than from a confused subscriber reporting malformed data.
Runtime expressions
OpenAPI callbacks use runtime expressions to define where the callback URL comes from. A callback defined on a /subscriptions POST operation might use {$request.body#/callbackUrl} as its URL. That means the callback URL is extracted from the request body's callbackUrl field.
The code generator resolves these automatically. The generated response struct captures the context needed:
| Expression | Resolves from | Generated accessor |
|---|---|---|
$request.body#/callbackUrl |
JSON Pointer into the request body | sourceRequest.CallbackUrl |
$request.query.eventType |
Query parameter from the original request | sourceRequest.EventType |
$request.header.X-Correlation-Id |
Header from the original request | sourceRequest.XCorrelationId |
$response.body#/id |
JSON Pointer into the response body | response.CreatedBody.Id |
$response.header.Location |
Response header | response.LocationHeader |
You don't write the expression resolution code yourself. The generator emits it based on what your spec declares, and the generated response types carry the captured context through so that callback invocations can resolve their bindings.
Links: hypermedia-style navigation
OpenAPI Links define follow-on operations that can be invoked from a response. They share the same runtime expression infrastructure as callbacks, but serve a different purpose: they model the "what can I do next?" question for API consumers.
When a response declares links, the generated response struct provides typed navigation methods:
// Create a pet - the 201 response links to showPetById
await using CreatePetResponse response = await client.CreatePetAsync(
body: new NewPet.Source((ref NewPet.Builder b) =>
{
b.Create(name: "Luna"u8, tag: "cat"u8);
}));
// Follow the link - petId is automatically populated from $response.body#/id
response.MatchResult(
matchCreated: async pet =>
{
await using ShowPetByIdResponse petResponse =
await response.CreatedLinks.ShowPetByIdAsync();
// petId was extracted from the create response body automatically
return 0;
},
matchDefault: error =>
{
Console.WriteLine($"Error: {error.Message}");
return 0;
});
The CreatedLinks property on the response gives you typed access to all linked operations defined for that response status. Parameters bound by runtime expressions ($response.body#/id in this case) are resolved from the captured context. You call the linked operation without manually extracting and passing the ID.
This is particularly powerful for APIs that follow HATEOAS principles, where the response tells you what actions are available next. The generated code makes link-following type-safe and parameter-free.
When callbacks and links intersect
Callbacks and links share the runtime expression mechanism, but they serve different roles:
Callbacks are about the server notifying the client asynchronously. The client registers a URL, and the server calls it later when something happens. The code generation produces both sides: a receiver (callback server) for the client, and a sender (callback client) for the server.
Links are about the client navigating to related operations synchronously. After receiving a response, the client follows a link to a related resource. The code generation adds navigation methods to the response type.
Webhooks are a special case of callbacks defined at the top level of the spec rather than inline on a specific operation. They represent spec-wide notification contracts that aren't tied to a particular request/response cycle.
In practice, many APIs use all three. A payment API might define links for navigating from a charge to its refunds (synchronous navigation), callbacks for notifying when a charge succeeds (asynchronous server-to-client), and webhooks for general platform events like account updates (top-level notifications).
Spec structure
For reference, here's how these features appear in an OpenAPI 3.2 spec:
Callbacks are defined inline on a path operation:
paths:
/subscriptions:
post:
operationId: createSubscription
callbacks:
onEvent:
"{$request.body#/callbackUrl}":
post:
operationId: onEventCallback
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/Event"
responses:
"200":
description: Received
Webhooks are defined at the top level:
webhooks:
statusChange:
post:
operationId: statusChangeWebhook
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/StatusChange"
responses:
"200":
description: Acknowledged
Links are defined on response objects:
paths:
/pets:
post:
operationId: createPet
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
links:
showPetById:
operationId: showPetById
parameters:
petId: "$response.body#/id"
The generator processes all three forms and produces the corresponding typed code. For the complete webhook and callback reference, including the example recipes that demonstrate both sides of a webhook interaction, see the OpenAPI documentation on corvus-oss.org.
In the [ref slug=openapi-code-generation-with-corvus-filtering text=next post], we'll look at filtering - generating code for just the endpoints you need, which becomes essential when working with large API surfaces.