Skip to content
Matthew Adams By Matthew Adams Co-Founder · 5 min read
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.

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.

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.

FAQs

What is the difference between OpenAPI webhooks and callbacks? Webhooks are top-level, spec-wide notification endpoints that any subscriber can register for. Callbacks are per-operation notifications triggered by runtime expressions - they are defined inline on a specific path operation and their URL is derived from the originating request or response context.
How does Corvus handle runtime expressions like $request.body#/callbackUrl? The code generator resolves runtime expressions automatically. The generated response struct captures the request and response context, and callback/link methods extract values using typed property navigation. You don't wire up the expression resolution yourself.
Can I generate both sides of a webhook interaction from the same spec? Yes. Use openapi-callback-server to generate receiver stubs (for your application to receive webhooks), and openapi-callback-client to generate sender code (for your server to dispatch webhooks to subscribers). Both commands work from the same OpenAPI spec.

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.