@apvee/azure-functions-openapi
v2.0.0
Published
An extension for Azure Functions V4 that provides support for exporting OpenAPI spec files from annotated Azure Functions.
Maintainers
Readme
@apvee/azure-functions-openapi
Version 2.0 - A complete rewrite with improved TypeScript support, automatic type inference, and enhanced Azure integration.

📖 Overview
@apvee/azure-functions-openapi is a powerful extension for Azure Functions V4 that automatically generates and serves OpenAPI documentation for your serverless APIs. Built on top of @asteasolutions/zod-to-openapi and leveraging Zod schemas for validation, it ensures your API is always type-safe, well-documented, and easy to explore.
What Makes v2.0 Special?
This major release introduces a modern, ergonomic API through TypeScript module augmentation, directly extending the @azure/functions app object with intuitive methods. Write less boilerplate, get full type inference, and enjoy seamless integration with Azure Functions runtime.
import "@apvee/azure-functions-openapi";
import { app } from "@azure/functions";
import { z } from "zod";
// Setup OpenAPI documentation
app.openAPISetup({
info: { title: "My API", version: "1.0.0" },
});
// Register a fully typed endpoint
app.openAPIPath("GetUser", "Get user by ID", {
typedHandler: async ({ params, context }) => {
// params.id is automatically typed as string!
const user = await getUser(params.id);
return { jsonBody: user };
},
methods: ["GET"],
route: "users/{id}",
params: z.object({ id: z.string().uuid() }),
response: UserSchema,
});Key Capabilities
- 🎯 Automatic Type Inference - TypeScript infers all parameter types from your Zod schemas
- 📝 Multi-Version OpenAPI - OpenAPI 3.1.0 by default, with opt-in OpenAPI 3.0.3 and Swagger 2.0 output
- 🎨 Integrated Swagger UI - Beautiful, interactive API documentation out of the box
- 🔒 Azure Security Built-in - Native support for Function Keys, EasyAuth, and Azure AD
- ✅ Runtime Validation - Automatic request/response validation with detailed error messages
- 🌐 Webhook Support - Document callback endpoints with OpenAPI 3.1.0 webhooks
- 🚀 Zero Config - Sensible defaults with full customization when needed
✨ What's New in v2.0
Version 2.0 is a complete rewrite that brings significant improvements in developer experience, performance, and Azure integration. Here's what changed:
🎨 Modern API with Module Augmentation
Before (v1.x):
import {
registerFunction,
registerOpenAPIHandler,
} from "@apvee/azure-functions-openapi";
registerOpenAPIHandler("anonymous", config, "3.1.0", "json");
registerFunction("GetUser", "Get user", {
/* ... */
});Now (v2.x):
import "@apvee/azure-functions-openapi";
import { app } from "@azure/functions";
app.openAPISetup({ info: { title: 'My API', version: '1.0.0' } });
app.openAPIPath('GetUser', 'Get user', { /* ... */ });The new API extends Azure Functions natively, providing better IDE support and a more intuitive developer experience.
🎯 Automatic Type Inference with Typed Handlers
The biggest feature in v2.0 is automatic type inference. No more manual type assertions!
app.openAPIPath('UpdateUser', 'Update user information', {
typedHandler: async ({ params, body, query, context }) => {
// All parameters are automatically typed from your schemas!
// params.id: string (from UUID schema)
// body.name: string
// body.email: string
// query.notify: boolean | undefined
await updateUser(params.id, body);
return { jsonBody: { success: true } };
},
methods: ["PUT"],
route: "users/{id}",
params: z.object({ id: z.string().uuid() }),
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
query: z.object({
notify: z.boolean().optional(),
}),
});🔒 Enhanced Azure Security Support
v2.0 introduces native support for multiple Azure authentication methods:
- Azure Function Keys - Built-in support for function, admin, and anonymous auth levels
- Azure EasyAuth - Integration with App Service Authentication (AAD, Google, Facebook, GitHub, etc.)
- Azure AD Bearer Token - Manual JWT validation for Microsoft Entra ID
- Azure AD Client Credentials - Service-to-service authentication with app roles
- Custom API Keys - Flexible header/query/cookie-based authentication
- Safe host resolution -
serversis never derived from the requestHostheader unless you explicitly opt in withtrustHostHeader
// Azure EasyAuth example
const easyAuth = app.openAPIEasyAuth('aad');
app.openAPIPath('GetProfile', 'Get user profile', {
handler: getProfileHandler,
methods: ["GET"],
route: "profile",
authLevel: "anonymous", // Required for EasyAuth
security: [easyAuth],
response: ProfileSchema,
});📦 Local Swagger UI Assets
v2.0 serves Swagger UI assets locally from node_modules instead of loading from CDN:
- ✅ Better Performance - No external dependencies, faster load times
- ✅ Offline Support - Works without internet connection
- ✅ Security - No third-party CDN risks
- ✅ Monorepo Support - Automatically detects local or root node_modules
- ✅ Caching - Built-in ETag support for optimal caching
🔄 Zod 4 Compatibility
Fully aligned with Zod 4.x, ensuring compatibility with the latest validation features and improvements:
{
"peerDependencies": {
"@azure/functions": "^4.5.2",
"zod": "^4.0.0"
}
}🌐 OpenAPI 3.1.0 Webhook Support
Document callback endpoints that your API calls using the new webhooks feature:
app.openAPIWebhook('OrderCreated', 'Notify when order is created', {
typedHandler: async ({ body, context }) => {
context.log(`Webhook received: Order ${body.orderId}`);
return { jsonBody: { received: true } };
},
methods: ["POST"],
body: OrderEventSchema,
responses: [{ httpCode: 200, description: "Success" }],
});⚡ Simplified Configuration
Setup is now much simpler with a single openAPISetup() call:
// Only OpenAPI 3.1.0 in JSON/YAML is emitted by default.
// Add 3.0.3 or 2.0 only when your tooling needs them.
app.openAPISetup({
info: { title: 'My API', version: '1.0.0' },
routePrefix: 'api',
versions: ['3.1.0', '3.0.3', '2.0'], // Optional, defaults to ['3.1.0']
formats: ['json', 'yaml'], // Optional, defaults to ['json', 'yaml']
servers: [{ url: 'https://api.example.com' }],
trustHostHeader: false, // Optional, defaults to false
swaggerUI: {
enabled: true, // Optional, defaults to true
route: 'docs' // Optional, defaults to 'swagger-ui'
}
});🛠️ New Utility Functions
Export powerful utilities for manual validation and parsing:
parseRouteParams()- Validate route parametersparseQueryParams()- Validate query stringsparseBody()- Validate request bodyparseHeaders()- Validate headersparseEasyAuthPrincipal()- Decode Azure EasyAuth user infoextractFunctionKey()- Extract function keys from requestscreateTypedHandler()- Create typed handlers programmatically
📋 Breaking Changes Summary
While v2.0 brings many improvements, some APIs have changed. See the Migration Guide below for detailed migration steps.
Removed APIs:
registerOpenAPIHandler()→ Useapp.openAPISetup()registerSwaggerUIHandler()→ Useapp.openAPISetup()registerFunction()→ Useapp.openAPIPath()orapp.openAPIWebhook()registerApiKeySecuritySchema()→ Useapp.openAPIKeySecurity()registerTypeSchema()→ Useapp.openAPISchema()
Private APIs:
convertHttpRequestParamsToObject()- Now internal, useparseRouteParams()insteadconvertURLSearchParamsToObject()- Now internal, useparseQueryParams()instead
🎯 Why Document APIs with OpenAPI?
OpenAPI documentation is more than just nice-to-have—it's a fundamental part of modern API development that delivers tangible benefits to both API producers and consumers.
Always Up-to-Date Documentation
By integrating OpenAPI documentation directly into your codebase, you ensure that your API documentation is always in sync with the implementation. No more outdated docs, no more discrepancies between what's documented and what's actually deployed.
// Documentation is generated from the same schemas used for validation
app.openAPIPath('CreateUser', 'Create a new user', {
typedHandler: async ({ body, context }) => {
// This schema validates the request AND generates the documentation
return { status: 201, jsonBody: await createUser(body) };
},
methods: ["POST"],
route: "users",
body: z.object({
name: z.string().min(1).describe("User full name"),
email: z.string().email().describe("Valid email address"),
age: z.number().int().positive().optional().describe("User age"),
}),
response: UserSchema,
});Auto-Generated Client Libraries
OpenAPI specifications can be used to automatically generate type-safe client libraries for various programming languages:
- Kiota - Microsoft's OpenAPI-based API client generator
- OpenAPI Generator - Supports 50+ languages
- oazapfts - TypeScript-first OpenAPI client generator
This saves development time and ensures API consumers have reliable, type-safe SDKs without manual coding.
Enhanced Developer Experience
Comprehensive and accurate API documentation makes it easier for developers to understand and use your API:
- Interactive Testing - Swagger UI allows developers to test endpoints directly from documentation
- Type Definitions - Clear request/response schemas with examples
- Authentication Details - Security requirements clearly documented
- Validation Rules - Understand constraints before making requests
Improved API Testing
OpenAPI documentation enables:
- Contract Testing - Validate that implementation matches the spec
- Mock Servers - Generate mock APIs for frontend development
- Test Case Generation - Automatically create test scenarios from schemas
- Response Validation - Ensure API responses match documented structure
Standardization and Interoperability
OpenAPI is a widely adopted industry standard supported by thousands of tools and platforms:
- API Gateways (Azure API Management, Kong, AWS API Gateway)
- Monitoring Tools (Postman, Insomnia, Paw)
- Testing Frameworks (Dredd, Schemathesis, REST Assured)
- Documentation Platforms (Redoc, Stoplight, Readme.io)
By documenting with OpenAPI, your API becomes easily integrable with the entire ecosystem.
🚀 Key Features
Module Augmentation API
Extends @azure/functions natively with intuitive OpenAPI methods. No separate imports needed—just extend the app object you already use.
import "@apvee/azure-functions-openapi";
import { app } from "@azure/functions";
// All app.openAPIXxx() methods are now available
app.openAPISetup({ /* config */ });
app.openAPIPath('MyEndpoint', 'Description', { /* config */ });Automatic Type Inference
TypeScript automatically infers parameter types from Zod schemas. No manual type assertions, no type mismatches.
app.openAPIPath('UpdateTodo', 'Update a todo item', {
typedHandler: async ({ params, body }) => {
// params.id is automatically string (from UUID schema)
// body.title is automatically string
// body.completed is automatically boolean
const updated = { id: params.id, ...body };
return { jsonBody: updated };
},
methods: ["PUT"],
route: "todos/{id}",
params: z.object({ id: z.string().uuid() }),
body: z.object({
title: z.string(),
completed: z.boolean(),
}),
});Multi-Version OpenAPI Support
Generate OpenAPI specifications in multiple versions and formats when your tooling needs them. By default, the library emits OpenAPI 3.1.0 in JSON and YAML only.
- OpenAPI 3.1.0 - Latest spec with full JSON Schema support and webhooks
- OpenAPI 3.0.3 - Widely supported by most tools
- OpenAPI 2.0 (Swagger) - Legacy support for older tools
Add versions: ["3.1.0", "3.0.3", "2.0"] to generate all supported versions simultaneously. Export in JSON or YAML format to suit your needs.
Integrated Swagger UI
Beautiful, interactive API documentation served automatically:
app.openAPISetup({
info: { title: 'My API', version: '1.0.0' },
swaggerUI: {
enabled: true,
route: "docs", // Access at: http://localhost:7071/docs
},
});Swagger UI assets are served locally for better performance, security, and offline support.
Type-Safe Request Validation
Leverage Zod schemas to validate all aspects of HTTP requests:
- Route Parameters -
/users/{id}with UUID validation - Query Strings - Pagination, filtering, sorting with type coercion
- Request Body - JSON payloads with nested object validation
- Headers - Custom headers like API keys or tokens
Validation errors automatically return 400 Bad Request with detailed error messages.
Comprehensive Azure Security Support
Native integration with Azure authentication mechanisms:
| Security Method | Use Case | Example | | ---------------------- | --------------------------- | ----------------------------- | | Function Keys | Azure native key-based auth | API keys managed by Azure | | EasyAuth | App Service Authentication | AAD, Google, Facebook, GitHub | | Azure AD Bearer | Manual JWT validation | Entra ID token validation | | Client Credentials | Service-to-service auth | Daemon apps, background jobs | | Custom API Keys | User-implemented auth | Header/query/cookie keys |
// Example: Azure AD Bearer Token
const adAuth = app.openAPIAzureADBearer({
name: 'AzureAD',
scopes: ['User.Read', 'Mail.Send']
});
app.openAPIPath('SendEmail', 'Send email on behalf of user', {
handler: sendEmailHandler,
methods: ["POST"],
route: "send-email",
security: [adAuth],
body: EmailSchema,
});OpenAPI 3.1.0 Webhooks
Document callback endpoints that your API calls using the webhooks feature:
app.openAPIWebhook('PaymentCompleted', 'Notify when payment is completed', {
typedHandler: async ({ body, context }) => {
context.log(`Payment ${body.paymentId} completed`);
return { jsonBody: { acknowledged: true } };
},
methods: ["POST"],
body: PaymentEventSchema,
});Advanced Response Configuration
Support for complex response scenarios:
- Multiple Status Codes - Document 200, 201, 400, 404, 500, etc.
- Multiple Content Types - JSON, XML, PDF, CSV in the same endpoint
- Response Headers - Rate limits, pagination info, custom headers
- Detailed Descriptions - Clear documentation for each response
app.openAPIPath('GetReport', 'Get report in multiple formats', {
handler: getReportHandler,
methods: ["GET"],
route: "reports/{id}",
params: z.object({ id: z.string() }),
responses: [
{
httpCode: 200,
description: "Report retrieved successfully",
content: [
{ mediaType: "application/json", schema: JsonReportSchema },
{ mediaType: "application/pdf", schema: z.instanceof(Buffer) },
{ mediaType: "text/csv", schema: z.string() },
],
},
{
httpCode: 404,
description: "Report not found",
schema: ErrorSchema,
},
],
});Rich Utility Functions
Export powerful utilities for advanced use cases:
import {
parseRouteParams,
parseQueryParams,
parseBody,
parseHeaders,
parseEasyAuthPrincipal,
extractFunctionKey,
createTypedHandler,
ValidationError,
} from "@apvee/azure-functions-openapi";Zero Configuration Defaults
Get started quickly with sensible defaults, customize when needed:
// Minimal setup - generates OpenAPI 3.1.0 in JSON/YAML + Swagger UI
app.openAPISetup({
info: { title: 'My API', version: '1.0.0' }
});
// Or fully customize everything
app.openAPISetup({
info: { /* ... */ },
routePrefix: 'api',
versions: ['3.1.0', '3.0.3', '2.0'],
formats: ['json', 'yaml'],
authLevel: 'anonymous',
security: [globalSecurityScheme],
servers: [{ url: "https://api.example.com" }],
tags: [{ name: "Users", description: "User management" }],
swaggerUI: {
enabled: true,
route: "docs",
authLevel: "anonymous",
},
});📦 Installation
Install the package using npm, yarn, or pnpm:
npm install @apvee/azure-functions-openapiPeer Dependencies
This library requires the following peer dependencies:
npm install @azure/functions zodVersion Compatibility
| Package | Version | Notes |
| -------------------------------- | ---------- | ------------------------ |
| @apvee/azure-functions-openapi | ^2.0.0 | This library |
| @azure/functions | ^4.5.2 | Azure Functions runtime |
| zod | ^4.0.0 | Schema validation |
| Node.js | >=18.0.0 | Recommended: Node 20 LTS |
Complete Installation
For a new Azure Functions project with TypeScript:
# Create a new Azure Functions project
func init my-api --typescript
# Navigate to project
cd my-api
# Install dependencies
npm install @azure/functions zod
npm install @apvee/azure-functions-openapi
# Install dev dependencies
npm install -D typescript @types/nodeVerification
Verify the installation by importing the package:
import "@apvee/azure-functions-openapi";
import { app } from "@azure/functions";
import { z } from "zod";
console.log("✅ Installation successful!");🚀 Quick Start
Get your first OpenAPI-documented Azure Function running in minutes.
Step 1: Setup OpenAPI
Create or edit your src/index.ts file:
import "@apvee/azure-functions-openapi";
import { app } from "@azure/functions";
// Configure OpenAPI documentation
app.openAPISetup({
info: {
title: "My First API",
version: "1.0.0",
description: "A simple API with OpenAPI documentation",
},
});Step 2: Create Your First Endpoint
Add a simple GET endpoint:
import { z } from "zod";
// Define response schema
const GreetingSchema = z.object({
message: z.string(),
timestamp: z.string(),
});
// Register endpoint with OpenAPI documentation
app.openAPIPath('GetGreeting', 'Get a greeting message', {
typedHandler: async ({ query, context }) => {
const name = query.name || "World";
context.log(`Greeting requested for: ${name}`);
return {
jsonBody: {
message: `Hello, ${name}!`,
timestamp: new Date().toISOString(),
},
};
},
methods: ["GET"],
route: "greet",
query: z.object({
name: z.string().optional().describe("Name to greet"),
}),
response: GreetingSchema,
});Step 3: Run Your Function
Start the Azure Functions runtime:
npm start
# or
func startStep 4: Access Your API Documentation
Open your browser and navigate to:
- Swagger UI: http://localhost:7071/swagger-ui
- OpenAPI JSON: http://localhost:7071/api/openapi/3.1.0.json
- OpenAPI YAML: http://localhost:7071/api/openapi/3.1.0.yaml
Step 5: Test Your Endpoint
Try your new endpoint:
# Without name parameter
curl http://localhost:7071/api/greet
# With name parameter
curl "http://localhost:7071/api/greet?name=Azure"Response:
{
"message": "Hello, Azure!",
"timestamp": "2025-11-06T12:34:56.789Z"
}Complete Quick Start Example
Here's the full src/index.ts file:
import "@apvee/azure-functions-openapi";
import { app } from "@azure/functions";
import { z } from "zod";
// Setup OpenAPI
app.openAPISetup({
info: {
title: "My First API",
version: "1.0.0",
description: "A simple API with OpenAPI documentation",
contact: {
name: "API Support",
email: "[email protected]",
},
},
tags: [{ name: "Greetings", description: "Greeting endpoints" }],
});
// Define schemas
const GreetingSchema = z.object({
message: z.string(),
timestamp: z.string(),
});
// Register endpoint
app.openAPIPath('GetGreeting', 'Get a personalized greeting', {
typedHandler: async ({ query, context }) => {
const name = query.name || "World";
context.log(`Greeting requested for: ${name}`);
return {
jsonBody: {
message: `Hello, ${name}!`,
timestamp: new Date().toISOString(),
},
};
},
methods: ["GET"],
route: "greet",
tags: ["Greetings"],
query: z.object({
name: z.string().optional().describe("Name to greet (default: World)"),
}),
response: GreetingSchema,
});🎉 Congratulations! You now have a fully documented API with automatic type inference, request validation, and interactive Swagger UI.
📘 Core Concepts
Setup OpenAPI Documentation
The app.openAPISetup() method is the entry point for configuring OpenAPI documentation. Call it once in your src/index.ts file before registering any endpoints.
Basic Setup
Minimal configuration with defaults:
import "@apvee/azure-functions-openapi";
import { app } from "@azure/functions";
app.openAPISetup({
info: {
title: "My API",
version: "1.0.0",
},
});This generates:
- OpenAPI 3.1.0 in JSON and YAML formats
- Swagger UI at
/swagger-ui - Documents accessible at
/api/openapi/3.1.0.jsonand/api/openapi/3.1.0.yaml
Complete Configuration
Full example with all available options:
app.openAPISetup({
// Required: API metadata
info: {
title: "Todo API",
version: "2.0.0",
description: "A comprehensive todo management API",
termsOfService: "https://example.com/terms",
contact: {
name: "API Support Team",
email: "[email protected]",
url: "https://example.com/support",
},
license: {
name: "MIT",
url: "https://opensource.org/licenses/MIT",
},
},
// Optional: Server configurations
// Recommended in production. When omitted, `servers` is not derived from
// the request Host header unless `trustHostHeader` is explicitly enabled.
servers: [
{
url: "https://api.example.com",
description: "Production server",
},
{
url: "https://staging-api.example.com",
description: "Staging server",
},
{
url: "http://localhost:7071",
description: "Local development",
},
],
// Optional: Global security requirements
security: [
{ BearerAuth: [] }, // Apply to all endpoints by default
],
// Optional: External documentation
externalDocs: {
description: "Full API Documentation",
url: "https://docs.example.com/api",
},
// Optional: Tags for organizing endpoints
tags: [
{
name: "Todos",
description: "Todo item operations",
externalDocs: {
description: "Todo guide",
url: "https://docs.example.com/todos",
},
},
{
name: "Users",
description: "User management operations",
},
],
// Optional: Azure Functions route prefix (default: 'api')
routePrefix: "api",
// Optional: Authorization level for OpenAPI endpoints (default: 'anonymous')
authLevel: "anonymous",
// Optional: OpenAPI versions to generate (default: ['3.1.0'])
versions: ["3.1.0", "3.0.3", "2.0"],
// Optional: Output formats (default: ['json', 'yaml'])
formats: ["json", "yaml"],
// Optional: trust the incoming Host header for generated `servers`
// (default: false). Prefer explicit `servers` in production.
trustHostHeader: false,
// Optional: host allowlist used only when `trustHostHeader: true`
trustedHosts: ["api.example.com", "staging-api.example.com"],
// Optional: Swagger UI configuration
swaggerUI: {
enabled: true, // default: true
route: "docs", // default: 'swagger-ui'
authLevel: "anonymous", // default: same as main authLevel
},
});Configuration Options Reference
| Option | Type | Default | Description |
| --------------------- | -------------------------------------- | ------------------------ | ------------------------------------------------ |
| info | InfoObject | Required | API metadata (title, version, description, etc.) |
| servers | ServerObject[] | undefined | Server URLs for different environments |
| security | SecurityRequirementObject[] | undefined | Global security requirements |
| externalDocs | ExternalDocumentationObject | undefined | Link to external documentation |
| tags | TagObject[] | undefined | Tags for organizing endpoints |
| routePrefix | string | 'api' | Azure Functions route prefix |
| authLevel | 'anonymous' \| 'function' \| 'admin' | 'anonymous' | Auth level for OpenAPI/Swagger endpoints |
| versions | Array<'2.0' \| '3.0.3' \| '3.1.0'> | ['3.1.0'] | OpenAPI versions to generate |
| formats | Array<'json' \| 'yaml'> | ['json', 'yaml'] | Output formats |
| trustHostHeader | boolean | false | Derive servers from the request Host header only when explicitly enabled |
| trustedHosts | string[] | [] | Hostname allowlist used when trustHostHeader is enabled |
| swaggerUI.enabled | boolean | true | Enable/disable Swagger UI |
| swaggerUI.route | string | 'swagger-ui' | Swagger UI route |
| swaggerUI.authLevel | 'anonymous' \| 'function' \| 'admin' | Same as main authLevel | Auth level for Swagger UI |
Generated Endpoints
Based on your configuration, the following endpoints are automatically created. With the default versions: ["3.1.0"], only the 3.1.0 document routes are registered; 3.0.3 and 2.0 routes are registered only when you add those versions.
OpenAPI Documents:
GET /{routePrefix}/openapi/3.1.0.json
GET /{routePrefix}/openapi/3.1.0.yaml
GET /{routePrefix}/openapi/3.0.3.json
GET /{routePrefix}/openapi/3.0.3.yaml
GET /{routePrefix}/openapi/2.0.json
GET /{routePrefix}/openapi/2.0.yamlSwagger UI:
GET /{swaggerUI.route}
GET /{swaggerUI.route}/assets/{file}Example: Multiple Environments
Configure different servers for different deployment stages:
app.openAPISetup({
info: {
title: "Multi-Environment API",
version: "1.0.0",
},
servers: [
{
url: "https://{environment}.api.example.com",
description: "Environment-based server",
variables: {
environment: {
default: "prod",
enum: ["prod", "staging", "dev"],
description: "Environment name",
},
},
},
],
});Example: Secured Documentation
Require authentication to view OpenAPI docs and Swagger UI:
app.openAPISetup({
info: {
title: "Secured API",
version: "1.0.0",
},
authLevel: "function", // Require function key
swaggerUI: {
enabled: true,
route: "docs",
authLevel: "admin", // Require admin key for Swagger UI
},
});Access requires a key:
# Access OpenAPI with function key
curl "http://localhost:7071/api/openapi/3.1.0.json?code=YOUR_FUNCTION_KEY"
# Access Swagger UI with admin key
curl "http://localhost:7071/docs?code=YOUR_ADMIN_KEY"Registering HTTP Endpoints
The app.openAPIPath() method registers HTTP endpoints with OpenAPI documentation. It replaces the traditional app.http() method and automatically handles both Azure Functions registration and OpenAPI documentation generation.
Basic Endpoint
Simple GET endpoint without parameters:
import { z } from "zod";
const StatusSchema = z.object({
status: z.string(),
timestamp: z.string(),
version: z.string(),
});
app.openAPIPath('GetStatus', 'Get API status', {
handler: async (request, context) => {
return {
jsonBody: {
status: "healthy",
timestamp: new Date().toISOString(),
version: "1.0.0",
},
};
},
methods: ["GET"],
route: "status",
response: StatusSchema,
});Endpoint with Path Parameters
Extract values from the URL path:
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});
app.openAPIPath('GetUser', 'Get user by ID', {
typedHandler: async ({ params, context }) => {
// params.id is automatically typed as string and validated as UUID
context.log(`Fetching user: ${params.id}`);
const user = await getUserById(params.id);
return { jsonBody: user };
},
methods: ["GET"],
route: "users/{id}",
params: z.object({
id: z.string().uuid().describe("User unique identifier"),
}),
response: UserSchema,
});Endpoint with Query Parameters
Handle query strings with validation:
const TodoListSchema = z.object({
todos: z.array(TodoSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
});
app.openAPIPath('ListTodos', 'List todos with pagination', {
typedHandler: async ({ query, context }) => {
// query parameters are automatically typed and coerced
const page = query.page || 1;
const pageSize = query.pageSize || 10;
const status = query.status;
const todos = await getTodos({ page, pageSize, status });
return {
jsonBody: {
todos,
total: todos.length,
page,
pageSize,
},
};
},
methods: ["GET"],
route: "todos",
query: z.object({
page: z.coerce.number().int().positive().default(1).describe("Page number"),
pageSize: z.coerce
.number()
.int()
.positive()
.max(100)
.default(10)
.describe("Items per page"),
status: z
.enum(["pending", "completed", "all"])
.optional()
.describe("Filter by status"),
}),
response: TodoListSchema,
});Endpoint with Request Body
POST/PUT/PATCH endpoints with JSON body:
const CreateTodoSchema = z.object({
title: z.string().min(1).max(200).describe("Todo title"),
description: z.string().optional().describe("Todo description"),
dueDate: z.string().datetime().optional().describe("Due date in ISO format"),
});
const TodoSchema = z.object({
id: z.string().uuid(),
title: z.string(),
description: z.string().optional(),
dueDate: z.string().optional(),
completed: z.boolean(),
createdAt: z.string(),
});
app.openAPIPath('CreateTodo', 'Create a new todo', {
typedHandler: async ({ body, context }) => {
// body is automatically typed and validated
context.log(`Creating todo: ${body.title}`);
const newTodo = await createTodo(body);
return {
status: 201,
jsonBody: newTodo,
};
},
methods: ["POST"],
route: "todos",
body: CreateTodoSchema,
response: TodoSchema,
});Endpoint with Multiple HTTP Methods
Support multiple methods on the same route:
app.openAPIPath('ManageTodo', 'Manage todo item', {
typedHandler: async ({ request, params, body, context }) => {
const method = request.method;
const todoId = params.id;
switch (method) {
case "GET":
return { jsonBody: await getTodo(todoId) };
case "PUT":
return { jsonBody: await updateTodo(todoId, body) };
case "DELETE":
await deleteTodo(todoId);
return { status: 204 };
default:
return { status: 405, body: "Method not allowed" };
}
},
methods: ["GET", "PUT", "DELETE"],
route: "todos/{id}",
params: z.object({ id: z.string().uuid() }),
body: UpdateTodoSchema,
responses: [
{ httpCode: 200, schema: TodoSchema, description: "Todo retrieved" },
{ httpCode: 204, description: "Todo deleted" },
],
});Endpoint with Custom Headers
Validate custom request headers:
app.openAPIPath('SecureEndpoint', 'Endpoint with custom API key', {
typedHandler: async ({ headers, context }) => {
// headers are automatically typed and validated
const apiKey = headers["x-api-key"];
context.log(`Request with API key: ${apiKey.substring(0, 8)}...`);
return { jsonBody: { authenticated: true } };
},
methods: ["GET"],
route: "secure/data",
headers: z.object({
"x-api-key": z.string().min(32).describe("API key for authentication"),
"x-request-id": z
.string()
.uuid()
.optional()
.describe("Optional request tracking ID"),
}),
response: z.object({ authenticated: z.boolean() }),
});Endpoint with Multiple Responses
Document different response codes:
const ErrorSchema = z.object({
error: z.string(),
code: z.string(),
details: z.any().optional(),
});
app.openAPIPath('UpdateTodo', 'Update an existing todo', {
typedHandler: async ({ params, body, context }) => {
try {
const todo = await getTodo(params.id);
if (!todo) {
return {
status: 404,
jsonBody: {
error: "Todo not found",
code: "TODO_NOT_FOUND",
},
};
}
const updated = await updateTodo(params.id, body);
return {
status: 200,
jsonBody: updated,
};
} catch (error) {
return {
status: 500,
jsonBody: {
error: "Internal server error",
code: "INTERNAL_ERROR",
details: error.message,
},
};
}
},
methods: ["PUT"],
route: "todos/{id}",
params: z.object({ id: z.string().uuid() }),
body: UpdateTodoSchema,
responses: [
{
httpCode: 200,
description: "Todo updated successfully",
schema: TodoSchema,
},
{
httpCode: 404,
description: "Todo not found",
schema: ErrorSchema,
},
{
httpCode: 500,
description: "Internal server error",
schema: ErrorSchema,
},
],
});Configuration Options
| Option | Type | Required | Description |
| -------------- | -------------------------------------- | ---------------------------------- | ----------------------------------------- |
| handler | HttpHandler | One of handler or typedHandler | Traditional Azure Functions handler |
| typedHandler | TypedHandler | One of handler or typedHandler | Typed handler with auto validation |
| methods | HttpMethod[] | ✅ Yes | HTTP methods (GET, POST, PUT, etc.) |
| route | string | ✅ Yes | Route path (without prefix) |
| params | ZodSchema | No | Route parameters schema |
| query | ZodSchema | No | Query string parameters schema |
| body | ZodSchema | No | Request body schema (JSON) |
| headers | ZodObject | No | Request headers schema |
| response | ZodSchema | No | Single response shortcut (assumes 200 OK) |
| responses | ResponseConfig[] | No | Multiple response configurations |
| authLevel | 'anonymous' \| 'function' \| 'admin' | No | Azure Functions auth level |
| security | SecurityRequirementObject[] | No | Security requirements for this endpoint |
| tags | string[] | No | OpenAPI tags for organization |
| description | string | No | Detailed description |
| deprecated | boolean | No | Mark endpoint as deprecated |
| operationId | string | No | Unique operation ID (defaults to name) |
Traditional Handler vs Typed Handler
Traditional Handler - Manual parsing and validation:
app.openAPIPath('CreateUser', 'Create user', {
handler: async (request, context) => {
// Manual parsing required
const body = await request.json();
const { name, email } = body;
// Manual validation
if (!name || !email) {
return { status: 400, body: "Missing required fields" };
}
const user = await createUser({ name, email });
return { jsonBody: user };
},
methods: ["POST"],
route: "users",
body: CreateUserSchema,
});Typed Handler - Automatic parsing and validation:
app.openAPIPath('CreateUser', 'Create user', {
typedHandler: async ({ body, context }) => {
// body is already parsed, validated, and typed!
// No manual checks needed
const user = await createUser(body);
return { jsonBody: user };
},
methods: ["POST"],
route: "users",
body: CreateUserSchema,
});Typed Handlers
The Typed Handler feature is the most powerful addition in v2.0. It provides automatic type inference from Zod schemas, eliminating manual type assertions and reducing boilerplate code.
How It Works
When you define schemas for params, query, body, or headers, TypeScript automatically infers the types for your handler parameters:
// Define schemas
const ParamsSchema = z.object({ id: z.string().uuid() });
const QuerySchema = z.object({ include: z.string().optional() });
const BodySchema = z.object({ title: z.string(), completed: z.boolean() });
app.openAPIPath('UpdateTodo', 'Update todo', {
typedHandler: async ({ params, query, body, context }) => {
// TypeScript knows:
// - params.id is string (from UUID schema)
// - query.include is string | undefined
// - body.title is string
// - body.completed is boolean
// No type assertions needed! ✨
const todo = await updateTodo(params.id, body);
return { jsonBody: todo };
},
methods: ["PUT"],
route: "todos/{id}",
params: ParamsSchema,
query: QuerySchema,
body: BodySchema,
});Automatic Validation
Typed handlers automatically validate all request data. If validation fails, a 400 Bad Request response is returned with detailed error information:
app.openAPIPath('CreateUser', 'Create new user', {
typedHandler: async ({ body, context }) => {
// If we reach here, body is guaranteed to be valid
const user = await createUser(body);
return { status: 201, jsonBody: user };
},
methods: ["POST"],
route: "users",
body: z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().positive().min(18),
}),
});Invalid request:
curl -X POST http://localhost:7071/api/users \
-H "Content-Type: application/json" \
-d '{"name":"","email":"invalid","age":15}'Automatic error response:
{
"error": "Request body validation failed",
"issues": [
{
"path": ["name"],
"message": "String must contain at least 1 character(s)"
},
{
"path": ["email"],
"message": "Invalid email"
},
{
"path": ["age"],
"message": "Number must be greater than or equal to 18"
}
]
}Handler Arguments
The typed handler receives a single object with the following properties:
type TypedHandlerArgs = {
params: /* inferred from params schema */;
query: /* inferred from query schema or URLSearchParams */;
body: /* inferred from body schema or undefined */;
headers: /* inferred from headers schema or raw headers */;
request: SafeHttpRequest; // Original request (body methods removed if parsed)
context: InvocationContext; // Azure Functions context
};Example with all parameters:
app.openAPIPath('ComplexEndpoint', 'Example with all parameters', {
typedHandler: async ({ params, query, body, headers, request, context }) => {
context.log(`Method: ${request.method}`);
context.log(`URL: ${request.url}`);
context.log(`Params:`, params);
context.log(`Query:`, query);
context.log(`Body:`, body);
context.log(`Headers:`, headers);
return { jsonBody: { success: true } };
},
methods: ["POST"],
route: "complex/{id}",
params: z.object({ id: z.string() }),
query: z.object({ filter: z.string().optional() }),
body: z.object({ data: z.any() }),
headers: z.object({
"x-api-key": z.string(),
}),
});Type Inference Examples
Simple types:
// String parameter
params: z.object({ id: z.string() });
// → params.id is string
// Number with coercion
query: z.object({ page: z.coerce.number() });
// → query.page is number
// Boolean
body: z.object({ enabled: z.boolean() });
// → body.enabled is booleanOptional types:
query: z.object({
search: z.string().optional(),
limit: z.coerce.number().default(10),
});
// → query.search is string | undefined
// → query.limit is numberComplex types:
body: z.object({
user: z.object({
name: z.string(),
email: z.string().email(),
}),
tags: z.array(z.string()),
metadata: z.record(z.string(), z.any()),
});
// → body.user is { name: string; email: string }
// → body.tags is string[]
// → body.metadata is Record<string, any>Enums:
query: z.object({
status: z.enum(["active", "inactive", "pending"]),
});
// → query.status is "active" | "inactive" | "pending"Union types:
body: z.object({
value: z.union([z.string(), z.number()]),
});
// → body.value is string | numberUsing createTypedHandler
For reusable handlers, use the createTypedHandler utility:
import { createTypedHandler } from "@apvee/azure-functions-openapi";
// Define schemas
const TodoParamsSchema = z.object({ id: z.string().uuid() });
const UpdateTodoBodySchema = z.object({
title: z.string().optional(),
completed: z.boolean().optional(),
});
// Create reusable typed handler
const updateTodoHandler = createTypedHandler(
{
params: TodoParamsSchema,
body: UpdateTodoBodySchema,
},
async ({ params, body, context }) => {
// Fully typed parameters
context.log(`Updating todo ${params.id}`);
const updated = await updateTodo(params.id, body);
return { jsonBody: updated };
}
);
// Use in endpoint registration
app.openAPIPath('UpdateTodo', 'Update todo', {
handler: updateTodoHandler, // Use as regular handler
methods: ["PATCH"],
route: "todos/{id}",
params: TodoParamsSchema,
body: UpdateTodoBodySchema,
response: TodoSchema,
});Inline vs External Handlers
Inline Handler (best for simple logic):
app.openAPIPath('DeleteTodo', 'Delete todo', {
typedHandler: async ({ params, context }) => {
await deleteTodo(params.id);
return { status: 204 };
},
methods: ["DELETE"],
route: "todos/{id}",
params: z.object({ id: z.string().uuid() }),
});External Handler (best for complex logic):
// In handlers/deleteTodo.ts
export const deleteTodoHandler = createTypedHandler(
{ params: TodoParamsSchema },
async ({ params, context }) => {
context.log(`Deleting todo: ${params.id}`);
const todo = await getTodo(params.id);
if (!todo) {
return { status: 404, jsonBody: { error: "Todo not found" } };
}
await deleteTodo(params.id);
return { status: 204 };
}
);
// In index.ts
import { deleteTodoHandler } from "./handlers/deleteTodo";
app.openAPIPath('DeleteTodo', 'Delete todo', {
handler: deleteTodoHandler,
methods: ["DELETE"],
route: "todos/{id}",
params: TodoParamsSchema,
});Error Handling in Typed Handlers
Validation errors are handled automatically, but you can add custom error handling:
app.openAPIPath('RiskyOperation', 'Operation that might fail', {
typedHandler: async ({ body, context }) => {
try {
const result = await performRiskyOperation(body);
return { jsonBody: result };
} catch (error) {
context.error("Operation failed:", error);
if (error instanceof DatabaseError) {
return {
status: 503,
jsonBody: { error: "Service temporarily unavailable" },
};
}
return {
status: 500,
jsonBody: { error: "Internal server error" },
};
}
},
methods: ["POST"],
route: "risky",
body: OperationSchema,
responses: [
{ httpCode: 200, schema: ResultSchema },
{ httpCode: 500, schema: ErrorSchema },
{ httpCode: 503, schema: ErrorSchema },
],
});Benefits of Typed Handlers
✅ Type Safety - Compiler catches type errors before runtime
✅ Less Boilerplate - No manual parsing or validation code
✅ Better IDE Support - IntelliSense shows exact types
✅ Automatic Validation - Invalid requests rejected automatically
✅ Consistent Error Responses - Standardized validation errors
✅ Self-Documenting - Schemas serve as both validation and documentation
Registering Reusable Schemas
The app.openAPISchema() method registers Zod schemas as named types in the OpenAPI registry. This promotes reusability and keeps your OpenAPI specification clean by using references instead of inlining schemas everywhere.
Basic Schema Registration
Register a simple schema:
import { z } from "zod";
// Define schema
const UserSchema = z.object({
id: z.string().uuid().describe("Unique user identifier"),
name: z.string().min(1).max(100).describe("User full name"),
email: z.string().email().describe("User email address"),
createdAt: z.string().datetime().describe("Account creation timestamp"),
});
// Register schema with a name
app.openAPISchema('User', UserSchema);
// Now use it in endpoints
app.openAPIPath('GetUser', 'Get user by ID', {
typedHandler: async ({ params }) => {
const user = await getUserById(params.id);
return { jsonBody: user };
},
methods: ["GET"],
route: "users/{id}",
params: z.object({ id: z.string().uuid() }),
response: UserSchema, // References 'User' in OpenAPI spec
});Nested Schema Registration
Register complex schemas with nested objects:
// Address schema
const AddressSchema = z.object({
street: z.string().describe("Street address"),
city: z.string().describe("City name"),
state: z.string().length(2).describe("State code (2 letters)"),
zipCode: z
.string()
.regex(/^\d{5}$/)
.describe("5-digit ZIP code"),
});
app.openAPISchema('Address', AddressSchema);
// User profile with nested address
const UserProfileSchema = z.object({
user: UserSchema,
address: AddressSchema,
phoneNumber: z.string().optional(),
preferences: z.object({
newsletter: z.boolean(),
notifications: z.boolean(),
}),
});
app.openAPISchema('UserProfile', UserProfileSchema);Array Schemas
Register schemas for collections:
// Single todo item
const TodoSchema = z.object({
id: z.string().uuid(),
title: z.string(),
completed: z.boolean(),
createdAt: z.string().datetime(),
});
app.openAPISchema('Todo', TodoSchema);
// Paginated list
const PaginatedTodosSchema = z.object({
items: z.array(TodoSchema).describe("List of todos"),
total: z.number().int().describe("Total number of items"),
page: z.number().int().describe("Current page number"),
pageSize: z.number().int().describe("Items per page"),
hasMore: z.boolean().describe("Whether more pages exist"),
});
app.openAPISchema('PaginatedTodos', PaginatedTodosSchema);
// Use in endpoint
app.openAPIPath('ListTodos', 'List all todos with pagination', {
typedHandler: async ({ query }) => {
const todos = await getTodos(query);
return { jsonBody: todos };
},
methods: ["GET"],
route: "todos",
query: z.object({
page: z.coerce.number().default(1),
pageSize: z.coerce.number().default(10),
}),
response: PaginatedTodosSchema,
});Error Schemas
Create standardized error responses:
// Base error schema
const ErrorSchema = z.object({
error: z.string().describe("Error message"),
code: z.string().describe("Error code"),
timestamp: z.string().datetime().describe("When the error occurred"),
path: z.string().optional().describe("Request path that caused the error"),
details: z.any().optional().describe("Additional error details"),
});
app.openAPISchema('Error', ErrorSchema);
// Validation error schema
const ValidationErrorSchema = z.object({
error: z.string(),
code: z.literal("VALIDATION_ERROR"),
issues: z.array(
z.object({
path: z.array(z.union([z.string(), z.number()])),
message: z.string(),
})
),
});
app.openAPISchema('ValidationError', ValidationErrorSchema);
// Use in endpoints
app.openAPIPath('CreateUser', 'Create new user', {
typedHandler: async ({ body }) => {
const user = await createUser(body);
return { status: 201, jsonBody: user };
},
methods: ["POST"],
route: "users",
body: CreateUserSchema,
responses: [
{
httpCode: 201,
schema: UserSchema,
description: "User created successfully",
},
{
httpCode: 400,
schema: ValidationErrorSchema,
description: "Invalid request data",
},
{
httpCode: 500,
schema: ErrorSchema,
description: "Internal server error",
},
],
});Discriminated Union Schemas
Register schemas with discriminated unions for polymorphic data:
// Base notification
const BaseNotificationSchema = z.object({
id: z.string().uuid(),
createdAt: z.string().datetime(),
});
// Email notification
const EmailNotificationSchema = BaseNotificationSchema.extend({
type: z.literal("email"),
to: z.string().email(),
subject: z.string(),
body: z.string(),
});
app.openAPISchema('EmailNotification', EmailNotificationSchema);
// SMS notification
const SmsNotificationSchema = BaseNotificationSchema.extend({
type: z.literal("sms"),
to: z.string().regex(/^\+?[1-9]\d{1,14}$/),
message: z.string().max(160),
});
app.openAPISchema('SmsNotification', SmsNotificationSchema);
// Union of all notification types
const NotificationSchema = z.discriminatedUnion("type", [
EmailNotificationSchema,
SmsNotificationSchema,
]);
app.openAPISchema('Notification', NotificationSchema);Schema Organization Best Practices
Organize by domain:
// schemas/user.ts
export const UserSchema = z.object({
/* ... */
});
export const CreateUserSchema = z.object({
/* ... */
});
export const UpdateUserSchema = z.object({
/* ... */
});
// schemas/todo.ts
export const TodoSchema = z.object({
/* ... */
});
export const CreateTodoSchema = z.object({
/* ... */
});
export const UpdateTodoSchema = z.object({
/* ... */
});
// schemas/index.ts
import { UserSchema, CreateUserSchema, UpdateUserSchema } from "./user";
import { TodoSchema, CreateTodoSchema, UpdateTodoSchema } from "./todo";
export function registerSchemas(app: typeof import("@azure/functions").app) {
// User schemas
app.openAPISchema('User', UserSchema);
app.openAPISchema('CreateUser', CreateUserSchema);
app.openAPISchema('UpdateUser', UpdateUserSchema);
// Todo schemas
app.openAPISchema('Todo', TodoSchema);
app.openAPISchema('CreateTodo', CreateTodoSchema);
app.openAPISchema('UpdateTodo', UpdateTodoSchema);
}
// In index.ts
import "@apvee/azure-functions-openapi";
import { app } from "@azure/functions";
import { registerSchemas } from "./schemas";
app.openAPISetup({ /* config */ });
registerSchemas(app);Benefits of Schema Registration
✅ DRY Principle - Define schemas once, use everywhere
✅ Cleaner OpenAPI Spec - Uses $ref instead of inline schemas
✅ Better Documentation - Named types are easier to understand
✅ Consistency - Same schema used for validation and documentation
✅ Type Reusability - Share types across multiple endpoints
✅ Easier Maintenance - Update schema in one place
When to Register Schemas
Register schemas when:
- Used in multiple endpoints
- Complex nested structures
- Part of your domain model
- Error response formats
- Common request/response patterns
Inline schemas when:
- Used only once
- Very simple structures
- Endpoint-specific parameters
- Quick prototyping
Example - Mixed approach:
// Register common schemas
app.openAPISchema('Todo', TodoSchema);
app.openAPISchema('Error', ErrorSchema);
app.openAPIPath('UpdateTodo', 'Update todo', {
typedHandler: async ({ params, body }) => {
const todo = await updateTodo(params.id, body);
return { jsonBody: todo };
},
methods: ["PATCH"],
route: "todos/{id}",
// Inline simple param schema
params: z.object({ id: z.string().uuid() }),
// Inline endpoint-specific body schema
body: z.object({
title: z.string().optional(),
completed: z.boolean().optional(),
}),
// Reference registered schema
response: TodoSchema,
});Webhooks (OpenAPI 3.1.0)
Webhooks are a new feature in OpenAPI 3.1.0 that allows you to document callback endpoints - HTTP requests that your API makes to external URLs. Unlike regular endpoints that clients call, webhooks represent notifications that your API sends to client-provided URLs.
Understanding Webhooks
Regular API Endpoint (Path):
Client → Your API
Example: GET /api/orders/123Webhook (Callback):
Your API → Client's URL
Example: POST https://client.com/webhooks/order-completedBasic Webhook Registration
Document a simple webhook notification:
import { z } from "zod";
// Define the payload your API will send
const OrderCompletedSchema = z.object({
orderId: z.string().uuid().describe("Order identifier"),
status: z.literal("completed").describe("Order status"),
completedAt: z.string().datetime().describe("Completion timestamp"),
totalAmount: z.number().describe("Total order amount"),
});
// Register webhook
app.openAPIWebhook('OrderCompleted', 'Notifies when an order is completed', {
typedHandler: async ({ body, context }) => {
// This is the handler that receives the webhook at YOUR endpoint
// (for testing or development purposes)
context.log(`Webhook received: Order ${body.orderId} completed`);
return { jsonBody: { received: true } };
},
methods: ["POST"],
body: OrderCompletedSchema,
responses: [
{
httpCode: 200,
description: "Webhook received successfully",
schema: z.object({ received: z.boolean() }),
},
],
});Webhook with Authentication
Document webhooks that require authentication:
// Register security scheme for webhook signatures
const webhookSignature = app.openAPIKeySecurity(
'X-Webhook-Signature',
'header',
'HMAC signature for webhook verification'
);
app.openAPIWebhook('PaymentProcessed', 'Notifies when payment is processed', {
typedHandler: async ({ body, headers, context }) => {
// Verify webhook signature
const signature = headers["x-webhook-signature"];
const isValid = verifyWebhookSignature(body, signature);
if (!isValid) {
return { status: 401, jsonBody: { error: "Invalid signature" } };
}
context.log(`Payment processed: ${body.paymentId}`);
return { jsonBody: { acknowledged: true } };
},
methods: ["POST"],
body: z.object({
paymentId: z.string().uuid(),
amount: z.number(),
currency: z.string().length(3),
status: z.enum(["success", "failed", "pending"]),
}),
headers: z.object({
"x-webhook-signature": z.string(),
}),
security: [webhookSignature],
});Multiple Webhook Events
Document different webhook events for various scenarios:
// User registration webhook
app.openAPIWebhook('UserRegistered', 'Notifies when a new user registers', {
typedHandler: async ({ body, context }) => {
context.log(`New user: ${body.userId}`);
return { status: 200 };
},
methods: ["POST"],
body: z.object({
userId: z.string().uuid(),
email: z.string().email(),
registeredAt: z.string().datetime(),
}),
tags: ["User Events"],
});
// User deletion webhook
app.openAPIWebhook('UserDeleted', 'Notifies when a user is deleted', {
typedHandler: async ({ body, context }) => {
context.log(`User deleted: ${body.userId}`);
return { status: 200 };
},
methods: ["POST"],
body: z.object({
userId: z.string().uuid(),
deletedAt: z.string().datetime(),
reason: z.string().optional(),
}),
tags: ["User Events"],
});
// Subscription events
app.openAPIWebhook('SubscriptionChanged', 'Notifies when subscription status changes', {
typedHandler: async ({ body, context }) => {
context.log(`Subscription ${body.subscriptionId} changed to ${body.status}`);
return { status: 200 };
},
methods: ['POST'],
body: z.object({
subscriptionId: z.string().uuid(),
userId: z.string().uuid(),
status: z.enum(['active', 'cancelled', 'expired', 'paused']),
changedAt: z.string().datetime()
}),
tags: ['Subscription Events']
});Webhook Retry Logic Documentation
Document how your API handles webhook failures:
app.openAPIWebhook('OrderShipped', 'Notifies when order is shipped', {
typedHandler: async ({ body, headers, context }) => {
// Document retry attempt in description
const attemptNumber = headers["x-webhook-attempt"];
context.log(`Webhook attempt ${attemptNumber} for order ${body.orderId}`);
return { status: 200 };
},
methods: ["POST"],
description: `
Webhook sent when an order is shipped.
**Retry Policy:**
- Initial attempt immediately after shipping
- Retries after 1 min, 5 min, 15 min, 1 hour, 6 hours
- Maximum 5 retry attempts
- Exponential backoff applied
**Request Headers:**
- \`X-Webhook-Attempt\`: Retry attempt number (1-5)
- \`X-Webhook-Id\`: Unique webhook delivery ID
- \`X-Webhook-Timestamp\`: ISO 8601 timestamp
`,
body: z.object({
orderId: z.string().uuid(),
trackingNumber: z.string(),
carrier: z.string(),
shippedAt: z.string().datetime(),
estimatedDelivery: z.string().datetime(),
}),
headers: z.object({
"x-webhook-attempt": z.coerce.number().int().min(1).max(5),
"x-webhook-id": z.string().uuid(),
"x-webhook-timestamp": z.string().datetime(),
}),
responses: [
{
httpCode: 200,
description: "Webhook acknowledged successfully",
},
{
httpCode: 500,
description: "Server error - webhook will be retried",
},
],
});Real-World Webhook Example: Stripe-style
Comprehensive webhook similar to Stripe's approach:
// Register webhook schemas
const WebhookEventSchema = z.object({
id: z.string().uuid().describe("Unique event identifier"),
type: z.string().describe("Event type"),
created: z.number().int().describe("Unix timestamp"),
data: z.object({
object: z.any(),
}),
livemode: z.boolean().describe("Whether in production mode"),
});
app.openAPISchema('WebhookEvent', WebhookEventSchema);
// Webhook signature security
const stripeSignature = app.openAPIKeySecurity(
'Stripe-Signature',
'header',
'Webhook signature for verification (see Stripe documentation)'
);
app.openAPIWebhook('StripeWebhook', 'Generic Stripe-style webhook endpoint', {
typedHandler: async ({ body, headers, context }) => {
const signature = headers["stripe-signature"];
// Verify signature (pseudo-code)
if (!verifyStripeSignature(body, signature, process.env.WEBHOOK_SECRET)) {
return { status: 400, jsonBody: { error: "Invalid signature" } };
}
// Handle different event types
context.log(`Event received: ${body.type}`);
switch (body.type) {
case "payment_intent.succeeded":
await handlePaymentSuccess(body.data.object);
break;
case "payment_intent.payment_failed":
await handlePaymentFailure(body.data.object);
break;
case "customer.subscription.created":
await handleSubscriptionCreated(body.data.object);
break;
default:
context.warn(`Unhandled event type: ${body.type}`);
}
return { jsonBody: { received: true } };
},
methods: ["POST"],
description: `
Webhook endpoint for receiving events from Stripe.
**Verification:**
All webhook requests include a \`Stripe-Signature\` header. Verify this signature
using your webhook secret to ensure the request came from Stripe.
**Event Types:**
- \`payment_intent.succeeded\` - Payment completed successfully
- \`payment_intent.payment_failed\` - Payment failed
- \`customer.subscription.created\` - New subscription created
- \`customer.subscription.deleted\` - Subscription cancelled
- And many more...
**Best Practices:**
1. Always verify the signature
2. Return 2xx status code quickly
3. Process events asynchronously
4. Handle duplicate events (idempotency)
`,
body: WebhookEventSchema,
headers: z.object({
"stripe-signature": z.string().describe("HMAC signature of the payload"),
}),
security: [stripeSignature],
responses: [
{
httpCode: 200,
description: "Event received and queued for processing",
schema: z.object({ received: z.boolean() }),
},
{
httpCode: 400,
description: "Invalid signature or malformed payload",
schema: z.object({ error: z.string() }),
},
],
tags: ["Webhooks"],
});Webhook Configuration Options
The app.openAPIWebhook() method accepts the same options as app.openAPIPath():
| Option | Description |
| --------------------------- | -------------------------------------------- |
| handler or typedHandler | Function to handle webhook (for testing) |
| methods | HTTP methods (usually ['POST']) |
| body | Expected webhook payload schema |
| headers | Expected headers (signatures, IDs, etc.) |
| security | Security requirements (signatures, API keys) |
| responses | Possible response codes |
| description | Detailed webhook documentation |
| tags | Tags for organization |
Webhook vs Path Differences
Webhooks:
- Documented in
webhookssection of OpenAPI spec - Represent outbound requests from your API
- Typically POST requests
- Often include signature verification
- Usually retry on failure
Paths:
- Documented in
pathssection of OpenAPI spec - Represent inbound requests to your API
- Support all HTTP methods
- May require authentication
- Client handles retries
Testing Webhooks Locally
Use tools like ngrok or webhook.site for local testing:
# Start ngrok tunnel
ngrok http 7071
# Your webhook URL becomes:
# https://abc123.ngrok.io/api/webhooks/order-completed
# Test with curl
curl -X POST https://abc123.ngrok.io/api/webhooks/order-completed \
-H "Content-Type: application/json" \
-d '{
"orderId": "123e4567-e89b-12d3-a456-426614174000",
"status": "completed",
"completedAt": "2025-11-06T12:00:00Z",
"totalAmount": 99.99
}'🔒 Security Schemas
Security is a critical aspect of API development. This library provides native support for multiple Azure authentication methods and custom security schemes, all properly documented in your OpenAPI
