# Add an endpoint

> Add a .NET 10 Minimal API endpoint to an AppointMe module, with an auto-discovered IEndpoint class, a Wolverine handler that gets CompanyId and IPrincipal injected, and typed results for OpenAPI.

Source page: https://appointme.dev/docs/guides/add-an-endpoint/

* **You'll build:** One endpoint slice in an existing module
* **Time:** About 15 minutes
* **Touches:** One slice folder and the generated client
* **Before you start:** A module that calls `AddEndpoints`

## How endpoints are found and mapped

**An endpoint is a class that implements `IEndpoint`. Each module registers its own with one assembly scan, and the API host maps them all under `api/v1`.**

| Piece                       | Where                                                                                              | Does                                                                                          |
| --------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `IEndpoint`                 | `AppointMe.Shared/Endpoints/IEndpoint.cs`                                                          | One method, `MapEndpoint(IEndpointRouteBuilder builder)`                                      |
| `AddEndpoints(assembly)`    | `AppointMe.Shared/Endpoints/EndpointsServiceCollectionExtensions.cs`                               | A Scrutor scan that registers every class assignable to `IEndpoint`, `internal` ones included |
| The module’s `Add…Module()` | For example `CRM/AppointMe.Crm/Configuration/CrmModule.cs`                                         | Calls `.AddEndpoints(ModuleAssembly.Instance)`                                                |
| `MapEndpoints()`            | `AppointMe.Shared/Endpoints/EndpointsApplicationBuilderExtensions.cs`, called once in `Program.cs` | Builds an `api/v1` route group with an API version set and passes it to every endpoint        |

src/AppointMe.Shared/Endpoints/EndpointsServiceCollectionExtensions.cs

```cs
        public IServiceCollection AddEndpoints(Assembly assembly)
        {
            return services.Scan(scan => scan
                .FromAssemblies(assembly)
                .AddClasses(classes => classes.AssignableTo<IEndpoint>(), false)
                .AsImplementedInterfaces()
                .WithTransientLifetime());
        }
```

src/AppointMe.Shared/Endpoints/EndpointsApplicationBuilderExtensions.cs

```cs
            var versionedGroup = app
                .MapGroup("api/v1")
                .WithApiVersionSet(apiVersionSet);


            var endpoints = app.Services
                .GetRequiredService<IEnumerable<IEndpoint>>();


            foreach (var endpoint in endpoints)
            {
                endpoint.MapEndpoint(versionedGroup);
            }
```

So routes inside `MapEndpoint` are relative: `"/customers"` serves `/api/v1/customers`. Version 1 is the only version, and there’s no attribute or registration list to keep in sync.

## Add the endpoint

**One folder per use case, named verb-first. The endpoint stays thin: it binds HTTP, sends a message through Wolverine and returns a typed result.**

1. **Create the slice folder.** Put it in the feature folder it belongs to, named after the action. `Customers/RegisterCustomer/` is the model:

   | File                                | Role                                                      |
   | ----------------------------------- | --------------------------------------------------------- |
   | `RegisterCustomerEndpoint.cs`       | The `IEndpoint`: route, binding, result                   |
   | `RegisterCustomerRequest.cs`        | The HTTP body, plus a `ToCommand()` extension             |
   | `RegisterCustomerCommand.cs`        | The message Wolverine routes to the handler               |
   | `RegisterCustomerCommandHandler.cs` | The use case                                              |
   | `RegisterCustomerResponse.cs`       | `public sealed record RegisterCustomerResponse(Guid Id);` |
   | `RegisterCustomer.cs`               | The domain factory, an `extension(Customer)` block        |

   Queries are slices too. `GetCustomers/` has a `GetCustomerQuery`, its handler, the endpoint, a request and a response. See [Vertical slices](/docs/concepts/vertical-slices/).

2. **Define the request and response.** Requests are `sealed class`es with `required` init properties for what the caller must send. A `ToCommand()` extension maps the request to the command, and takes route values as arguments where needed, for example `UpdateCustomerRequest.ToCommand(Guid id)`.

   src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomerRequest.cs

   ```cs
   public static class RegisterCustomerRequestExtensions
   {
       extension(RegisterCustomerRequest request)
       {
           public RegisterCustomerCommand ToCommand()
           {
               return new RegisterCustomerCommand
               {
                   FirstName = request.FirstName,
                   LastName = request.LastName,
                   DateOfBirth = request.DateOfBirth,
                   Gender = request.Gender,
                   Email = request.Email
               };
           }
       }
   ```

   For query-string input, bind a class with `[AsParameters]`. `GetCustomersRequest` extends `PaginationRequest` (the `limit` and `page` parameters) and adds its own:

   src/CRM/AppointMe.Crm/Customers/GetCustomers/GetCustomersRequest.cs

   ```cs
   public sealed class GetCustomersRequest : PaginationRequest
   {
       [FromQuery(Name = "search")]
       public string? Search { get; init; }
   }
   ```

3. **Write the handler.** A handler is a class whose name ends in `Handler`, with a `HandleAsync` method that takes the message first. Wolverine discovers it because the module’s assembly carries `[assembly: WolverineModule]`. Declare `CompanyId` and `IPrincipal` as parameters, inject everything else through the constructor, and check the permission before anything else:

   src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomerCommandHandler.cs

   ```cs
       public async Task<RegisterCustomerResponse> HandleAsync(RegisterCustomerCommand command, CompanyId companyId,
           IPrincipal principal, CancellationToken cancellationToken)
       {
           principal.Require(CustomerPermissions.Create);


           var customer = Customer.Register(
               companyId: companyId,
               name: PersonName.Create(command.FirstName, command.LastName),
               dateOfBirth: DateOfBirth.CreateOrNull(command.DateOfBirth, timeProvider),
               gender: Gender.ParseOrNull(command.Gender),
               email: Email.CreateOrNull(command.Email),
               registrationDate: timeProvider.GetUtcNow()
           );
   ```

   The command carries raw input, and the handler turns it into value objects through their factories (`PersonName.Create`, `Email.CreateOrNull`), which throw `ValidationException` on bad input. Writes go through the module’s `DbContext`, and reads through a Dapper repository. See [CQRS](/docs/concepts/cqrs/).

4. **Write the endpoint.** Map the route relative to the group, name it, and send the message with `IMessageBus`:

   src/CRM/AppointMe.Crm/Customers/RegisterCustomer/RegisterCustomerEndpoint.cs

   ```cs
   internal sealed class RegisterCustomerEndpoint : IEndpoint
   {
       public void MapEndpoint(IEndpointRouteBuilder builder)
       {
           builder.MapPost("/customers", RegisterCustomer).WithName(nameof(RegisterCustomer));
       }


       private static async Task<Created<RegisterCustomerResponse>> RegisterCustomer(
           [FromBody] RegisterCustomerRequest request, IMessageBus bus, CancellationToken cancellationToken)
       {
           var result = await bus.InvokeAsync<RegisterCustomerResponse>(request.ToCommand(), cancellationToken);
           return TypedResults.Created($"/customers/{result.Id}", result);
       }
   }
   ```

   | Part         | Convention                                                                                                                                               |
   | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
   | Route values | A typed parameter and a constraint: `Guid id` with `"/customers/{id:guid}"`                                                                              |
   | Body         | `[FromBody]` on the request class                                                                                                                        |
   | Query string | `[AsParameters]` on the request class                                                                                                                    |
   | Sending      | `bus.InvokeAsync<TResponse>(message, cancellationToken)` when the handler returns a value, `bus.InvokeAsync(message, cancellationToken)` when it doesn’t |
   | Name         | `.WithName(nameof(Method))`, which becomes the OpenAPI `operationId`                                                                                     |

5. **Regenerate the client.** Any change to a route, verb, request or response changes the contract, so regenerate in the same change:

   ```bash
   cd src/AppointMe.Frontend
   npm run generate:api
   npm run check
   ```

   `generate:api` builds the API in Release, which writes `src/AppointMe.Api/obj/openapi.json`, then runs orval to rewrite `src/api/client.ts` and `src/api/client.schemas.ts`. `check` type-checks the call sites against the new client. See [Typed API client](/docs/frontend/api-client/).

[Build your first feature](/docs/start/first-feature/) walks through a command slice and a query slice end to end, from the migration to the React hook.

## What `CompanyId` and `IPrincipal` do

**Declaring either parameter makes a Wolverine policy add middleware that loads it before your code runs, or fails the request with `403`.**

| Parameter                          | Loaded by                                                               | Fails with                                                                                                  |
| ---------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `CompanyId companyId`              | `CompanyContextBehavior.Load`, added by `CompanyContextPolicy`          | `403` `Active company was not specified.` when the request has no `X-Company-Id`                            |
| `IPrincipal principal`             | `PrincipalContextBehavior.LoadAsync`, added by `PrincipalContextPolicy` | `403` `User is not authorized to access requested company.` when the user isn’t an employee of that company |
| Your `principal.Require(...)` call | Your handler’s first line                                               | `403` `The following permissions are required: <codes>.`                                                    |

You can see the middleware in the code Wolverine generates for a handler. This is the generated handler for the tutorial’s `AddCustomerNoteCommandHandler`:

src/AppointMe.Api/Internal/Generated/WolverineHandlers/AddCustomerNoteCommandHandler….cs (generated)

```cs
                var principal = await AppointMe.Api.Wolverine.HandlerContext.PrincipalContextBehavior.LoadAsync(currentPrincipalResolver, cancellation).ConfigureAwait(false);
                var companyId = AppointMe.Api.Wolverine.HandlerContext.CompanyContextBehavior.Load(_currentCompany, context);
                var addCustomerNoteCommandHandler = new AppointMe.Crm.CustomerNotes.AddCustomerNote.AddCustomerNoteCommandHandler(crmDbContext, _timeProvider);
                // …
                var outgoing1 = await addCustomerNoteCommandHandler.HandleAsync(addCustomerNoteCommand, companyId, principal, cancellation).ConfigureAwait(false);
```

The same file wraps the call in an EF Core transaction enlisted in the Wolverine outbox, and calls `SaveChangesAsync` after it. To write these files, run `ASPNETCORE_ENVIRONMENT=Codegen dotnet run --project src/AppointMe.Api/AppointMe.Api.csproj -c Release --no-launch-profile -- codegen write`, the same `codegen write` the Dockerfile runs. The output folder, `src/AppointMe.Api/Internal/Generated/`, is git-ignored.

Why the company comes from a header: [Multi-tenancy](/docs/concepts/multi-tenancy/#handlers-companyid-is-injected).

## Errors: throw, don’t return

**Handlers signal failure by throwing a domain exception. Exception handlers in the API turn each type into a ProblemDetails response.**

src/AppointMe.Api/ErrorHandling/ErrorHandlingServiceCollectionExtensions.cs

```cs
            return services
                .AddProblemDetails()
                .AddExceptionHandler<BadHttpRequestExceptionHandler>()
                .AddExceptionHandler<ValidationExceptionHandler>()
                .AddExceptionHandler<NotFoundExceptionHandler>()
                .AddExceptionHandler<ConflictExceptionHandler>()
                .AddExceptionHandler<AccessDeniedExceptionHandler>()
                .AddExceptionHandler<ConcurrencyExceptionHandler>()
                .AddExceptionHandler<GlobalExceptionHandler>();
```

| Thrown                                               | Status                                | ProblemDetails                                                                   |
| ---------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------- |
| `BadHttpRequestException` (binding failed)           | The exception’s status, usually `400` | `title: "Bad Request"`, `detail`: the message                                    |
| `ValidationException`                                | `400`                                 | `title: "Validation Error"`, an `error` extension with the message               |
| `NotFoundException`                                  | `404`                                 | `title: "Resource Not Found"`, `detail`: the message                             |
| `ConflictException`                                  | `409`                                 | `title: "Conflict"`, an `error` extension, and `code` when the exception has one |
| `AccessDeniedException`                              | `403`                                 | `title: "Access denied"`, `detail`: the message                                  |
| `DbUpdateConcurrencyException` (a stale row version) | `409`                                 | `title: "Conflict"`, `code: "concurrency_conflict"`                              |
| Anything else                                        | `500`                                 | `title: "Internal Server Error"`, `detail: "Unexpected error occurred."`         |

The common ones come from shared code: value-object factories throw `ValidationException`, `LoadAsync` query helpers throw `NotFoundException`, and `principal.Require` throws `AccessDeniedException`. The exception types live in `AppointMe.Shared/Domain/Errors`.

## OpenAPI metadata and authorization

**The OpenAPI document is generated from the endpoint’s signature, so the return type decides what the typed client knows.**

| You write                                        | The document says                                                                                   | Example                         |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------- |
| `Task<Created<T>>` and `TypedResults.Created(…)` | `201` with `T`’s schema                                                                             | `RegisterCustomerEndpoint`      |
| `Task<NoContent>` and `TypedResults.NoContent()` | `204`                                                                                               | `RescheduleAppointmentEndpoint` |
| `Task<T>`                                        | `200` with `T`’s schema                                                                             | `GetCustomersEndpoint`          |
| `Task<IResult>`                                  | `200` with no schema, even when the body returns `Results.NoContent()`                              | `UpdateCustomerEndpoint`        |
| `Task`                                           | `200` with no schema                                                                                | `DeleteCustomerEndpoint`        |
| `.WithName(nameof(GetCustomers))`                | `operationId: GetCustomers`, which orval turns into `useGetCustomers` and `getGetCustomersQueryKey` | Every endpoint                  |
| `.ExcludeFromDescription()`                      | Nothing; the endpoint is left out                                                                   | `DemoLoginEndpoint`             |

Each operation is tagged with its endpoint class name. Only the success response is documented, so the generated client types errors as `unknown` (for example `GetCustomerQueryError`). Prefer `TypedResults` and a typed return over `IResult`, so the client gets the real status and shape.

Authorization is on by default. `AddAppointMeAuthorization()` sets a fallback policy, so every endpoint without its own metadata requires an authenticated, registered user:

src/AppointMe.Api/Authorization/AuthorizationServiceCollectionExtensions.cs

```cs
            .SetFallbackPolicy(new AuthorizationPolicyBuilder()
                .RequireAuthenticatedUser()
                .AddRequirements(new RegisteredUserRequirement())
                .Build());
```

| Endpoint metadata         | Effect                                                                        | Used by                                                                       |
| ------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| None                      | The fallback policy: authenticated and registered                             | Almost every endpoint                                                         |
| `.AllowAnonymous()`       | No authentication                                                             | `GetCurrentUserEndpoint`, `LoginEndpoint`, `SignupEndpoint`                   |
| `.RequireAuthorization()` | ASP.NET Core’s default policy, an authenticated user, instead of the fallback | `AcceptInvitationEndpoint`, `GetPendingInvitationsEndpoint`, `LogoutEndpoint` |

Permissions aren’t endpoint metadata. The handler checks them with `principal.Require`, so they apply however the message arrives. See [Permissions](/docs/concepts/permissions/).

## Verify it works

**Build, read the generated document, regenerate the client, and run the same Wolverine check CI runs.**

| Check                      | Command                                                                                                                 | Expect                                                            |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| It compiles                | `dotnet build AppointMe.sln`                                                                                            | No errors                                                         |
| It’s in the contract       | Open `src/AppointMe.Api/obj/openapi.json`                                                                               | Your route under `/api/v1/…`, with your `operationId`             |
| The client matches         | `npm run generate:api`, then `git diff --stat src/AppointMe.Frontend/src/api/`                                          | The new hook in `client.ts`                                       |
| The handler code generates | `dotnet run --project src/AppointMe.Api/AppointMe.Api.csproj -c Release --no-build --no-launch-profile -- codegen test` | `Success!`. Needs a Release build first; `generate:api` does one. |

In the running app (Development only), `https://localhost:7233/openapi/v1.json` serves the same document.

## Troubleshooting

**Most problems are a route that was never mapped, a missing header, or a client that wasn’t regenerated.**

| Symptom                                                     | Cause                                                                                                       | Fix                                                                                           |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `404` for the new route                                     | The class doesn’t implement `IEndpoint`, or its module doesn’t call `AddEndpoints(ModuleAssembly.Instance)` | Implement `IEndpoint`; add the call to the module’s `Add…Module()`                            |
| The route is served at `/api/v1/api/v1/…`                   | `MapEndpoint` repeats the prefix                                                                            | Map relative to the group: `"/customers"`                                                     |
| `403` `Active company was not specified.`                   | The request has no `X-Company-Id` header                                                                    | Send it. The SPA’s axios client adds it from the current company.                             |
| The new hook isn’t in `client.ts`                           | The client wasn’t regenerated, or `npx orval` read an old `obj/openapi.json`                                | Run `npm run generate:api`                                                                    |
| The generated mutation has no response type                 | The endpoint returns `Task<IResult>`                                                                        | Return `Task<Created<T>>`, `Task<NoContent>` or `Task<T>` with `TypedResults`                 |
| `dotnet build` fails in the `GenerateOpenApiDocuments` step | The build runs the app’s startup without a database, and new startup code needs one                         | Gate it on `CodeGenerationDetection.IsRunningGeneration()`, as `Program.cs` does for Hangfire |

### Why send every request through Wolverine instead of calling the handler?

The cross-cutting work lives in Wolverine middleware: the company, the principal, the EF transaction with the outbox, and publishing the aggregates’ domain events. `bus.InvokeAsync` gets all of it for one line in the endpoint, and the endpoint stays a thin HTTP adapter.

It also keeps the use case reachable from places that aren’t HTTP. A recurring job sends the same message for a given company with `bus.InvokeForCompany`, and the same middleware runs.

## Next

* [Build your first feature](/docs/start/first-feature/) (Tutorial)
* [API conventions](/docs/concepts/api-conventions/) (Concept)
* [Typed API client](/docs/frontend/api-client/) (Guide)
