Example: API endpoint document¶
An endpoint document covers one controller in full: every route it exposes, the
parameters and DTOs on each, the status codes a caller can receive, and the side
effects of calling it. Doc gen produces one per controller at
docs/apis/<api-slug>/endpoints/<controller-slug>.md, and the catalog rows in
endpoints.md link into its per-endpoint anchors.
What reviewers should check¶
- Each H2 is the fully resolved route, so its anchor is what the catalog row in
endpoints.mdlinks to.## GET {agencyNumber}would break every inbound link as well as being wrong. - The parameter table lists every parameter, including implicitly bound ones, and optionality follows the action signature: a nullable or defaulted parameter is optional, a route parameter with no default is required.
- Status codes say what produced them. A
404raised by the framework because no route matched is a different finding from a404the action returns for a missing agency, and an operator triaging a report needs to tell them apart. - Every business rule is cited as a link into
../business-rules.md. A bareBR-001is a contract violation. - The side effects table is filled in for reads too. "Records written: None" is information; an empty cell is not.
- Repeat-call behaviour is described concretely — same result, duplicate record, or conflict status — rather than restating the Idempotent row.
- The example request and response are realistic: a real route, a body that matches the DTO table, and a status line that appears in the status code table.
- Links to sibling pages are prefixed with
../, because this page lives one directory deeper than the rest of the set.
Example¶
The document below is a complete, correct api-endpoint-document.md for a
fictional API's agencies controller.
View rendered example — the same document as a real page, so you can compare rendering as well as source.
---
title: AgenciesController
summary: Agency lookup, search, roster, and contact maintenance endpoints.
status: draft
owner: Agency Systems Team
last_reviewed: 2026-09-10
---
# AgenciesController
`AgenciesController` exposes the agency resource: a single agency by its six
digit agency number, a producer-code search, the roster of agents currently
appointed at an agency, and the one write endpoint in the service, which
replaces an agency's contact block. The agency portal calls all four;
`SAIF.Claims.Intake` uses the search and lookup endpoints to attribute a claim
to a servicing agency; `SAIF.Policy.RenewalNotice` uses the lookup and roster
endpoints through the `SAIF.Agency.Api.Client` package to name a servicing agent
on each notice.
| Field | Value |
| --- | --- |
| **Controller** | `AgenciesController` |
| **Base** | `AgencyApiBase` : `ApiController` |
| **Route prefix** | `[RoutePrefix("v1/agencies")]` |
| **Source** | `src/SAIF.Agency.Api/Controllers/v1/AgenciesController.cs` |
| **Controller-level authorization** | `[SaifAuthorize(Roles = "AgencyRead")]` |
| **Error handling** | Global `AgencyApiExceptionFilter`; `UpdateAgencyContact` additionally catches `StaleAgencyVersionException` to attach the current version to the `409` body |
| **Endpoints** | 4 |
---
## GET /v1/agencies/{agencyNumber}
Returns a single agency, its producer codes, and its current contact block.
| Field | Value |
| --- | --- |
| **HTTP verb** | `GET` |
| **Resolved route** | `/v1/agencies/{agencyNumber}` |
| **Route composition** | `[RoutePrefix("v1/agencies")]` + `[Route("{agencyNumber}")]` |
| **Action** | `GetAgency` |
| **Return type** | `IHttpActionResult` (`Ok<AgencyDetailDto>`) |
| **Authorization** | [AgencyRead](../authentication-authorization.md#roles-and-resources) |
| **Idempotent** | Yes |
| **Operation** | — |
### Parameters
| Name | In | Type | Required | Default | Binding | Purpose |
| --- | --- | --- | --- | --- | --- | --- |
| `agencyNumber` | Route | `string` | Yes | — | Implicit | Six digit agency number identifying the agency ([BR-001](../business-rules.md#br-001-agency-number-must-be-six-digits)) |
| `includeTerminated` | Query | `bool` | No | `false` | `[FromUri]` | Returns the agency even when its status is `Terminated` ([BR-003](../business-rules.md#br-003-terminated-agencies-are-excluded-unless-explicitly-requested)) |
### Response
| Field | Value |
| --- | --- |
| **DTO** | `AgencyDetailDto` (`src/SAIF.Agency.Api.Core/Dtos/AgencyDetailDto.cs`) |
| **Media type** | `application/json` |
| Property | Type | Nullable | Purpose |
| --- | --- | --- | --- |
| `agencyNumber` | `string` | No | Six digit agency number |
| `name` | `string` | No | Legal name of the agency |
| `status` | `string` | No | `Active`, `Suspended`, or `Terminated` |
| `producerCodes` | `string[]` | No | Producer codes assigned to the agency; empty for a terminated agency |
| `appointedDate` | `DateTime` | No | Date the agency's first appointment took effect |
| `terminatedDate` | `DateTime` | Yes | Date the agency was terminated; `null` while the agency is active or suspended |
| `contact` | `AgencyContactDto` | No | Mailing address, contact email, and contact phone |
| `version` | `int` | No | Row version used for optimistic concurrency on the contact update ([BR-008](../business-rules.md#br-008-a-contact-update-must-carry-the-expected-agency-version)) |
### Status codes
| Status | Condition | Body |
| --- | --- | --- |
| `200 OK` | An agency with that number exists and is visible under the `includeTerminated` filter | `AgencyDetailDto` |
| Error status | Trigger | Produced by | Body |
| --- | --- | --- | --- |
| `400 Bad Request` | `agencyNumber` is not exactly six digits ([BR-001](../business-rules.md#br-001-agency-number-must-be-six-digits)) | Action, via `AgencyApiBase.ValidationFailure` | `ApiErrorDto` with `code` `AGENCY_NUMBER_FORMAT` |
| `404 Not Found` | No agency has that number, or the agency is terminated and `includeTerminated` is `false` ([BR-003](../business-rules.md#br-003-terminated-agencies-are-excluded-unless-explicitly-requested)) | Action, via `AgencyApiBase.NotFoundForAgency` | `ApiErrorDto` with `code` `AGENCY_NOT_FOUND` |
| `500 Internal Server Error` | Oracle unavailable or an unmapped exception escapes `AgencyService` | `AgencyApiExceptionFilter` | `ApiErrorDto` with `code` `INTERNAL_ERROR` and the correlation id |
### Authorization
The controller-level `[SaifAuthorize(Roles = "AgencyRead")]` applies to this
action; there is no action-level attribute and no programmatic check inside the
action. A caller without the role receives `401 Unauthorized` from the filter
before `GetAgency` runs. See
[Authentication and authorization](../authentication-authorization.md).
### Side effects and idempotency
| Aspect | Finding |
| --- | --- |
| Records written | None |
| Downstream calls | None |
| Files or messages produced | None |
| Repeat-call behavior | Identical response for identical input; the read runs in its own `IUnitOfWork` and holds no lock |
### Example
```http
GET /agency/v1/agencies/104782 HTTP/1.1
Host: agencyapi.example.saif
Accept: application/json
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
{
"agencyNumber": "104782",
"name": "Cascade Risk Partners LLC",
"status": "Active",
"producerCodes": ["CRP01", "CRP07"],
"appointedDate": "2009-04-01T00:00:00Z",
"contact": {
"mailingAddress": {
"line1": "2200 SW Water Ave",
"line2": "Suite 310",
"city": "Portland",
"state": "OR",
"postalCode": "97201"
},
"email": "service@cascaderisk.example",
"phone": "5035550142"
},
"version": 14
}
```
---
## GET /v1/agencies
Returns the agencies that hold a given producer code.
| Field | Value |
| --- | --- |
| **HTTP verb** | `GET` |
| **Resolved route** | `/v1/agencies` |
| **Route composition** | `[RoutePrefix("v1/agencies")]` + `[Route("")]` |
| **Action** | `SearchAgencies` |
| **Return type** | `IHttpActionResult` (`Ok<IEnumerable<AgencySummaryDto>>`) |
| **Authorization** | [AgencyRead](../authentication-authorization.md#roles-and-resources) |
| **Idempotent** | Yes |
| **Operation** | — |
### Parameters
| Name | In | Type | Required | Default | Binding | Purpose |
| --- | --- | --- | --- | --- | --- | --- |
| `producerCode` | Query | `string` | Yes | — | `[FromUri]` | Producer code to search on; one to ten characters ([BR-002](../business-rules.md#br-002-agency-search-must-be-bounded-by-a-producer-code)) |
| `activeOnly` | Query | `bool` | No | `true` | `[FromUri]` | When `false`, suspended and terminated agencies are included ([BR-003](../business-rules.md#br-003-terminated-agencies-are-excluded-unless-explicitly-requested)) |
### Response
| Field | Value |
| --- | --- |
| **DTO** | `AgencySummaryDto` (`src/SAIF.Agency.Api.Core/Dtos/AgencySummaryDto.cs`), returned as a JSON array |
| **Media type** | `application/json` |
| Property | Type | Nullable | Purpose |
| --- | --- | --- | --- |
| `agencyNumber` | `string` | No | Six digit agency number, usable with the lookup endpoint |
| `name` | `string` | No | Legal name of the agency |
| `status` | `string` | No | `Active`, `Suspended`, or `Terminated` |
| `producerCode` | `string` | No | The producer code that matched the search |
| `city` | `string` | Yes | Mailing address city, `null` when no contact block exists |
| `state` | `string` | Yes | Mailing address state, `null` when no contact block exists |
### Status codes
| Status | Condition | Body |
| --- | --- | --- |
| `200 OK` | The search completed; an empty array is a valid result | `AgencySummaryDto[]` |
| Error status | Trigger | Produced by | Body |
| --- | --- | --- | --- |
| `400 Bad Request` | `producerCode` missing, empty, or longer than ten characters ([BR-002](../business-rules.md#br-002-agency-search-must-be-bounded-by-a-producer-code)) | Action, via `AgencyApiBase.ValidationFailure` | `ApiErrorDto` with `code` `PRODUCER_CODE_REQUIRED` |
| `500 Internal Server Error` | Oracle unavailable, or the query exceeds the command timeout | `AgencyApiExceptionFilter` | `ApiErrorDto` with `code` `INTERNAL_ERROR` and the correlation id |
### Authorization
Inherited from the controller-level `[SaifAuthorize(Roles = "AgencyRead")]`. See
[Authentication and authorization](../authentication-authorization.md).
### Side effects and idempotency
| Aspect | Finding |
| --- | --- |
| Records written | None |
| Downstream calls | None |
| Files or messages produced | None |
| Repeat-call behavior | Identical response for identical input; results are not paged, and a producer code returns at most a handful of agencies |
### Example
```http
GET /agency/v1/agencies?producerCode=CRP01&activeOnly=true HTTP/1.1
Host: agencyapi.example.saif
Accept: application/json
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
[
{
"agencyNumber": "104782",
"name": "Cascade Risk Partners LLC",
"status": "Active",
"producerCode": "CRP01",
"city": "Portland",
"state": "OR"
}
]
```
---
## GET /v1/agencies/{agencyNumber}/agents
Returns the agents currently appointed at an agency.
| Field | Value |
| --- | --- |
| **HTTP verb** | `GET` |
| **Resolved route** | `/v1/agencies/{agencyNumber}/agents` |
| **Route composition** | `[RoutePrefix("v1/agencies")]` + `[Route("{agencyNumber}/agents")]` |
| **Action** | `GetAgentsForAgency` |
| **Return type** | `IHttpActionResult` (`Ok<IEnumerable<AgentSummaryDto>>`) |
| **Authorization** | [AgencyRead](../authentication-authorization.md#roles-and-resources) |
| **Idempotent** | Yes |
| **Operation** | — |
### Parameters
| Name | In | Type | Required | Default | Binding | Purpose |
| --- | --- | --- | --- | --- | --- | --- |
| `agencyNumber` | Route | `string` | Yes | — | Implicit | Six digit agency number whose roster is requested ([BR-001](../business-rules.md#br-001-agency-number-must-be-six-digits)) |
| `asOfDate` | Query | `DateTime?` | No | Current date | `[FromUri]` | Evaluates appointment status on this date instead of today ([BR-004](../business-rules.md#br-004-an-agency-roster-returns-only-agents-with-an-active-appointment)) |
### Response
| Field | Value |
| --- | --- |
| **DTO** | `AgentSummaryDto` (`src/SAIF.Agency.Api.Core/Dtos/AgentSummaryDto.cs`), returned as a JSON array |
| **Media type** | `application/json` |
| Property | Type | Nullable | Purpose |
| --- | --- | --- | --- |
| `agentNumber` | `string` | No | Agent number, usable with the agents endpoints |
| `firstName` | `string` | No | Agent given name |
| `lastName` | `string` | No | Agent surname |
| `licenseNumber` | `string` | No | State producer license number |
| `licenseState` | `string` | No | Two letter state code the license was issued in |
| `appointmentEffectiveDate` | `DateTime` | No | Date the agent's appointment at this agency took effect |
| `isPrimaryContact` | `bool` | No | True for the agent recorded as the agency's primary servicing contact |
### Status codes
| Status | Condition | Body |
| --- | --- | --- |
| `200 OK` | The agency exists; an agency with no currently appointed agents returns an empty array | `AgentSummaryDto[]` |
| Error status | Trigger | Produced by | Body |
| --- | --- | --- | --- |
| `400 Bad Request` | `agencyNumber` is not exactly six digits ([BR-001](../business-rules.md#br-001-agency-number-must-be-six-digits)), or `asOfDate` is not a parseable date | Action, via `AgencyApiBase.ValidationFailure`; the date parse failure is produced by model binding | `ApiErrorDto` with `code` `AGENCY_NUMBER_FORMAT`, or the framework's model state body for the date |
| `404 Not Found` | No agency has that number | Action, via `AgencyApiBase.NotFoundForAgency` | `ApiErrorDto` with `code` `AGENCY_NOT_FOUND` |
| `500 Internal Server Error` | Oracle unavailable or an unmapped exception escapes `AgencyService` | `AgencyApiExceptionFilter` | `ApiErrorDto` with `code` `INTERNAL_ERROR` and the correlation id |
### Authorization
Inherited from the controller-level `[SaifAuthorize(Roles = "AgencyRead")]`. A
terminated agency's roster is readable by any caller holding that role; there is
no data-level restriction limiting a caller to their own agency. See
[Authentication and authorization](../authentication-authorization.md).
### Side effects and idempotency
| Aspect | Finding |
| --- | --- |
| Records written | None |
| Downstream calls | None |
| Files or messages produced | None |
| Repeat-call behavior | Identical response for identical input on the same date; a request without `asOfDate` can change between days as appointments begin and end |
### Example
```http
GET /agency/v1/agencies/104782/agents HTTP/1.1
Host: agencyapi.example.saif
Accept: application/json
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
[
{
"agentNumber": "A0041627",
"firstName": "Dana",
"lastName": "Whitfield",
"licenseNumber": "OR-884120",
"licenseState": "OR",
"appointmentEffectiveDate": "2018-07-01T00:00:00Z",
"isPrimaryContact": true
},
{
"agentNumber": "A0052918",
"firstName": "Marcus",
"lastName": "Ibarra",
"licenseNumber": "OR-901554",
"licenseState": "OR",
"appointmentEffectiveDate": "2023-01-15T00:00:00Z",
"isPrimaryContact": false
}
]
```
---
## PUT /v1/agencies/{agencyNumber}/contact
Replaces an agency's mailing address, contact email, and contact phone.
| Field | Value |
| --- | --- |
| **HTTP verb** | `PUT` |
| **Resolved route** | `/v1/agencies/{agencyNumber}/contact` |
| **Route composition** | `[RoutePrefix("v1/agencies")]` + `[Route("{agencyNumber}/contact")]` |
| **Action** | `UpdateAgencyContact` |
| **Return type** | `IHttpActionResult` (`StatusCode(HttpStatusCode.NoContent)`) |
| **Authorization** | [AgencyContactWrite](../authentication-authorization.md#roles-and-resources) |
| **Idempotent** | Yes |
| **Operation** | [OP-001](../operations/update-agency-contact.md) |
### Parameters
| Name | In | Type | Required | Default | Binding | Purpose |
| --- | --- | --- | --- | --- | --- | --- |
| `agencyNumber` | Route | `string` | Yes | — | Implicit | Six digit agency number whose contact block is replaced ([BR-001](../business-rules.md#br-001-agency-number-must-be-six-digits)) |
| `request` | Body | `AgencyContactUpdateDto` | Yes | — | `[FromBody]` | Replacement contact block and the caller's expected agency version |
### Request body
| Field | Value |
| --- | --- |
| **DTO** | `AgencyContactUpdateDto` (`src/SAIF.Agency.Api.Core/Dtos/AgencyContactUpdateDto.cs`) |
| **Media type** | `application/json` |
| **Validation** | `AgencyService.UpdateContact` calls `IContactValidator.Validate` from `SAIF.Framework.Validation`; a null body is rejected by the action before the service is called |
| Property | Type | Required | Constraints | Purpose |
| --- | --- | --- | --- | --- |
| `email` | `string` | Yes | Deliverable address form, 255 characters maximum ([BR-005](../business-rules.md#br-005-contact-details-must-be-a-deliverable-email-address-and-phone-number)) | Address correspondence and renewal notices are sent to |
| `phone` | `string` | Yes | Exactly ten digits, punctuation stripped before validation ([BR-005](../business-rules.md#br-005-contact-details-must-be-a-deliverable-email-address-and-phone-number)) | Contact telephone number |
| `mailingAddress.line1` | `string` | Yes | 60 characters maximum | First line of the mailing address |
| `mailingAddress.line2` | `string` | No | 60 characters maximum | Second line of the mailing address |
| `mailingAddress.city` | `string` | Yes | 40 characters maximum | Mailing address city |
| `mailingAddress.state` | `string` | Yes | Two letter state code | Mailing address state |
| `mailingAddress.postalCode` | `string` | Yes | Five or nine digits | Mailing address postal code |
| `expectedVersion` | `int` | Yes | Must equal the agency's current `version` ([BR-008](../business-rules.md#br-008-a-contact-update-must-carry-the-expected-agency-version)) | Optimistic concurrency token read from the lookup endpoint |
### Response
| Field | Value |
| --- | --- |
| **DTO** | — (no response body) |
| **Media type** | — |
| Property | Type | Nullable | Purpose |
| --- | --- | --- | --- |
| `X-Agency-Version` | `int` (response header) | No | The agency's new version, to be sent as `expectedVersion` on the next update |
### Status codes
| Status | Condition | Body |
| --- | --- | --- |
| `204 No Content` | The contact block was replaced and the audit row written | Empty; `X-Agency-Version` carries the new version |
| Error status | Trigger | Produced by | Body |
| --- | --- | --- | --- |
| `400 Bad Request` | `agencyNumber` is not six digits ([BR-001](../business-rules.md#br-001-agency-number-must-be-six-digits)), the body is absent, or email or phone fails validation ([BR-005](../business-rules.md#br-005-contact-details-must-be-a-deliverable-email-address-and-phone-number)) | Action for the route and null-body checks; `AgencyApiExceptionFilter` for `AgencyValidationException` raised by the service | `ApiErrorDto` with `code` `CONTACT_INVALID` and one `details` entry per failing field |
| `404 Not Found` | No agency has that number | Action, via `AgencyApiBase.NotFoundForAgency` | `ApiErrorDto` with `code` `AGENCY_NOT_FOUND` |
| `409 Conflict` | The agency is `Suspended` or `Terminated` ([BR-006](../business-rules.md#br-006-contact-details-may-only-be-changed-on-an-active-agency)) | `AgencyApiExceptionFilter`, from `AgencyStateConflictException` | `ApiErrorDto` with `code` `AGENCY_NOT_ACTIVE` |
| `409 Conflict` | `expectedVersion` does not match the stored version ([BR-008](../business-rules.md#br-008-a-contact-update-must-carry-the-expected-agency-version)) | `AgencyApiExceptionFilter`, from `StaleAgencyVersionException` caught and enriched by the action | `ApiErrorDto` with `code` `AGENCY_VERSION_STALE` and the current version in `details` |
| `500 Internal Server Error` | The unit of work fails to commit, or an unmapped exception escapes `AgencyService` | `AgencyApiExceptionFilter` | `ApiErrorDto` with `code` `INTERNAL_ERROR` and the correlation id |
### Authorization
This is the only action in the controller that carries its own attribute:
`[SaifAuthorize(Roles = "AgencyContactWrite")]` replaces the controller-level
`AgencyRead` requirement rather than adding to it, so a caller holding
`AgencyRead` alone can read the agency but receives `401 Unauthorized` here. The
role is held by the agency portal's application identity and by the Agency
Systems support group. See
[Authentication and authorization](../authentication-authorization.md).
### Side effects and idempotency
| Aspect | Finding |
| --- | --- |
| Records written | `AGENCY_CONTACT` replaced in place and `AGENCY.VERSION` incremented; one `AGENCY_CONTACT_AUDIT` row inserted ([data model](../data-model.md#agency_contact_audit)) ([BR-007](../business-rules.md#br-007-every-accepted-contact-change-writes-an-audit-row)) |
| Downstream calls | None |
| Files or messages produced | None |
| Repeat-call behavior | Replaying the same request fails with `409 AGENCY_VERSION_STALE`, because the first call incremented the version. Re-reading the agency and resubmitting with the new `expectedVersion` writes the same contact values and a second audit row. |
### Example
```http
PUT /agency/v1/agencies/104782/contact HTTP/1.1
Host: agencyapi.example.saif
Content-Type: application/json
{
"email": "service@cascaderisk.example",
"phone": "(503) 555-0142",
"mailingAddress": {
"line1": "2200 SW Water Ave",
"line2": "Suite 420",
"city": "Portland",
"state": "OR",
"postalCode": "97201"
},
"expectedVersion": 14
}
```
```http
HTTP/1.1 204 No Content
X-Agency-Version: 15
```
---
## Related documentation
- [API index](../index.md)
- [Overview](../overview.md)
- [Architecture](../architecture.md)
- [Endpoints](../endpoints.md)
- [Authentication and authorization](../authentication-authorization.md)
- [Dependencies](../dependencies.md)
- [Configuration](../configuration.md)
- [Operations and processes](../operations-processes.md)
- [Business rules](../business-rules.md)
- [Data model](../data-model.md)
- [Operational runbook](../operational-runbook.md)
- [Tech debt](../tech-debt.md)
## Sources
- `src/SAIF.Agency.Api/Controllers/v1/AgenciesController.cs`
- `src/SAIF.Agency.Api/Controllers/AgencyApiBase.cs`
- `src/SAIF.Agency.Api.Services/AgencyService.cs`
- `src/SAIF.Agency.Api.Core/Dtos/AgencyDetailDto.cs`
- `src/SAIF.Agency.Api.Core/Dtos/AgencySummaryDto.cs`
- `src/SAIF.Agency.Api.Core/Dtos/AgentSummaryDto.cs`
- `src/SAIF.Agency.Api.Core/Dtos/AgencyContactUpdateDto.cs`
- `src/SAIF.Agency.Api.Data/Maps/AgencyMap.cs`