Introduction to Designing a Customer Support Inquiry API with FastAPI: Practical Ticket Management, Status Transitions, Permissions, and Audit Logs
Summary
- A customer support inquiry API is not just a storage destination for a contact form. In practice, it becomes a business foundation that includes inquiry intake, assignee management, reply history, status management, internal notes, escalation, and audit logs.
- With FastAPI, you can organize APIs required for CS operations by combining authentication and permission management with
Depends, clear error responses withHTTPException, lightweight notification processing withBackgroundTasks, and request ID assignment through middleware. - What matters most in an inquiry support API is clearly defining “who can view,” “who can reply,” and “who can change statuses.” Since it handles customer information and conversation history, authorization design must be more careful than ordinary CRUD.
- Status transitions should define states such as
open,pending,waiting_customer,resolved, andclosed, and only permitted transitions should be controlled in the service layer. - This article explains the basic design of a CS inquiry support API with FastAPI, step by step, covering data models, routers, search, replies, internal notes, audit logs, notifications, and tests.
Who Will Benefit from Reading This
Independent Developers and Learners
This is useful for people who want to add an “inquiry feature” to their own service. At first, it may seem enough to simply save a name, email address, and message body. However, as the number of inquiries grows, problems begin to appear, such as “I don’t know whether this has already been handled,” “I don’t know who replied,” or “I can’t follow past exchanges.”
This article introduces a path for growing from a minimal inquiry intake feature into a ticket management API that is easy for CS staff to use. Using FastAPI’s dependency system and status code management, you can gradually move toward a practical production-style design.
Backend Engineers in Small Teams
This is suitable for environments where CS staff, sales, and developers handle the same inquiries together. For example, CS may handle the first response, escalate only technical issues to developers, and route billing-related inquiries to accounting.
At this stage, a simple inquiry list is not enough. You need assignees, categories, priorities, statuses, internal notes, and audit logs. This article explains concretely how to separate those concepts in a FastAPI API design.
SaaS Development Teams and Startups
This is useful for SaaS teams that already have multiple tenants, multiple plans, an internal admin panel, audit logs, and permission management. Inquiry handling is an area that directly affects customer experience and churn.
If you organize the inquiry API as a business foundation, not only will CS response quality improve, but it will also become easier to collect metrics such as inquiry categories, response time, unresolved ticket count, and escalation rate. If you later introduce AI summaries or automatic classification, it is important that conversation history and state transitions are already well structured.
Accessibility Evaluation
- A summary is placed at the beginning so readers can easily understand the purpose of the whole article.
- Headings are arranged in the order of “concepts → data models → API design → operations → testing,” so readers can understand even if they read only the sections they need.
- Technical terms are briefly explained when they first appear, and the same terms are repeated afterward to reduce cognitive load.
- Code examples are split into short blocks, and each block shows only one responsibility.
- The target level is roughly equivalent to AA.
1. How Is a CS Inquiry Support API Different from Ordinary CRUD?
At first glance, an inquiry support API may seem to only “create inquiries, list them, view details, and reply.” However, in practice, state management and history management are more important than in ordinary CRUD.
For example, after an inquiry is created, it changes as follows:
- An inquiry arrives from a customer
- CS staff review the content
- An assignee is assigned
- Internal notes are added if needed
- A reply is sent to the customer
- The customer sends an additional reply
- The ticket is marked as resolved
- It is closed after a certain period
In other words, an inquiry is not a “single record.” It is a business object that has conversations and state transitions.
For this reason, the following designs are important in an inquiry support API:
- The ticket itself
- Message history
- Internal notes
- Assignees
- Status
- Priority
- Category
- Audit logs
- Notifications
- Search and filtering
With FastAPI, you can use the dependency system to get the current user and permissions, and separate responsibilities by using routers. Depends is one of FastAPI’s main features, and the official documentation explains it as a mechanism for easily integrating external components and shared processing.
2. Terms to Define First: Inquiry, Ticket, Message, and Internal Note
Before implementation, organizing terminology helps prevent design drift.
Inquiry / Ticket
This is a unit of consultation or question received from a customer. In CS tools, it is often called a “ticket.” In this article, we use Ticket as the central resource in the API.
Message
This is the body text sent by either a customer or a staff member. Multiple messages belong to one ticket.
Internal Note
This is an internal note that is not shown to the customer. Examples include “This customer has an Enterprise contract,” “The same issue occurred before,” or “Checking with the development team.”
Status
This is the state of the ticket. For example, we use the following values:
openpendingwaiting_customerresolvedclosed
Assignee
This is the CS member responsible for the inquiry. Whether to allow an unassigned state or require every ticket to be assigned depends on operations.
3. Basic Data Model Shape
First, represent the concepts as Pydantic models. In practice, you will also map them to SQLAlchemy models or a database schema, but first we organize them with Pydantic to think about the API shape.
from datetime import datetime
from pydantic import BaseModel, EmailStr
from typing import Literal
TicketStatus = Literal[
"open",
"pending",
"waiting_customer",
"resolved",
"closed",
]
TicketPriority = Literal["low", "normal", "high", "urgent"]
class TicketRead(BaseModel):
id: int
tenant_id: int | None = None
requester_email: EmailStr
subject: str
status: TicketStatus
priority: TicketPriority
assignee_id: int | None = None
created_at: datetime
updated_at: datetime
Messages are defined as a separate model.
MessageSenderType = Literal["customer", "agent", "system"]
class TicketMessageRead(BaseModel):
id: int
ticket_id: int
sender_type: MessageSenderType
sender_id: int | None = None
body: str
created_at: datetime
Internal notes are separated from customer-facing messages.
class InternalNoteRead(BaseModel):
id: int
ticket_id: int
author_id: int
body: str
created_at: datetime
The important point here is not to mix messages visible to customers with internal-only notes. If they are mixed, the risk of accidentally returning internal notes to customers increases.
4. Router Structure: Separate Customer-Facing and CS-Facing APIs
For inquiry support APIs, it is recommended to separate customer-facing APIs from CS-facing APIs.
app/
api/
v1/
public/
tickets.py
admin/
tickets.py
ticket_messages.py
internal_notes.py
Customer-facing APIs should be limited to inquiry creation and viewing the customer’s own inquiries.
POST /tickets
GET /tickets/{ticket_id}
POST /tickets/{ticket_id}/messages
CS-facing APIs handle search, assignee changes, status changes, internal note creation, and so on.
GET /admin/tickets
GET /admin/tickets/{ticket_id}
POST /admin/tickets/{ticket_id}/reply
POST /admin/tickets/{ticket_id}/notes
PATCH /admin/tickets/{ticket_id}/assignee
PATCH /admin/tickets/{ticket_id}/status
Separating them this way makes authorization and response design easier to organize. In FastAPI, you can attach prefix and tags to APIRouter, so admin APIs can also be shown separately in OpenAPI.
5. Inquiry Creation API: The First Entry Point for Customers
First, create an API that lets customers submit inquiries.
from fastapi import APIRouter, status
from pydantic import BaseModel, EmailStr, Field
router = APIRouter(prefix="/tickets", tags=["tickets"])
class TicketCreate(BaseModel):
requester_email: EmailStr
subject: str = Field(..., min_length=1, max_length=200)
body: str = Field(..., min_length=1, max_length=5000)
@router.post("", status_code=status.HTTP_201_CREATED)
def create_ticket(payload: TicketCreate):
return {
"id": 1,
"requester_email": payload.requester_email,
"subject": payload.subject,
"status": "open",
}
Here, status.HTTP_201_CREATED is explicitly specified. FastAPI’s status module provides constants for HTTP status codes, making the intent easier to understand than writing numbers directly.
When creating an inquiry, you should also consider the following processing:
- Spam prevention
- Rate limiting
- Attachment restrictions
- Automatic receipt email
- Automatic category classification
- Tenant detection
- Audit logs or event logs
You do not need to include everything from the beginning, but inquiry creation may be accessible from outside by anyone, so it should be treated carefully as a public API.
6. CS Ticket List API: Emphasize Search and Filtering
The ticket list is what CS staff use most often. Here, search conditions should be clearly defined.
from dataclasses import dataclass
from typing import Literal
from fastapi import Depends, Query
@dataclass
class TicketSearchParams:
q: str | None
status: str | None
priority: str | None
assignee_id: int | None
limit: int
offset: int
def get_ticket_search_params(
q: str | None = Query(default=None, description="Search by subject, body, or email address"),
status: Literal["open", "pending", "waiting_customer", "resolved", "closed"] | None = None,
priority: Literal["low", "normal", "high", "urgent"] | None = None,
assignee_id: int | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> TicketSearchParams:
return TicketSearchParams(
q=q,
status=status,
priority=priority,
assignee_id=assignee_id,
limit=limit,
offset=offset,
)
The router side becomes simple.
admin_router = APIRouter(prefix="/admin/tickets", tags=["admin-tickets"])
@admin_router.get("")
def list_tickets(
params: TicketSearchParams = Depends(get_ticket_search_params),
):
return {
"items": [],
"meta": {
"limit": params.limit,
"offset": params.offset,
},
}
As with the previous search API design, it is useful to make search conditions reusable for list views, CSV exports, and dashboard aggregation.
7. Authentication and Permissions: Not All CS Staff Can Perform the Same Operations
For CS-facing APIs, in addition to being logged into the admin panel, operation-specific permissions are needed.
As an example, consider the following roles:
support- View tickets, reply, create internal notes
support_manager- Change assignees, change priorities, close tickets
admin- All operations, deletion, export
Use a dependency function to get the admin user.
from fastapi import Depends, HTTPException, status
class AdminUser:
def __init__(self, user_id: int, role: str):
self.user_id = user_id
self.role = role
def get_current_admin() -> AdminUser:
return AdminUser(user_id=1, role="support")
def require_support(
admin: AdminUser = Depends(get_current_admin),
) -> AdminUser:
if admin.role not in {"support", "support_manager", "admin"}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="support permission required",
)
return admin
Stronger operations such as assignee changes are separated.
def require_support_manager(
admin: AdminUser = Depends(get_current_admin),
) -> AdminUser:
if admin.role not in {"support_manager", "admin"}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="manager permission required",
)
return admin
In FastAPI, you can raise HTTPException to return client errors. The official documentation also explains HTTPException as an exception used to indicate client errors, authentication errors, input errors, and similar cases.
8. Detail API: Return Messages and Internal Notes Separately
In the CS-facing detail API, you often want to display the ticket itself, customer exchanges, and internal notes together.
@admin_router.get("/{ticket_id}")
def get_ticket_detail(
ticket_id: int,
admin: AdminUser = Depends(require_support),
):
return {
"ticket": {
"id": ticket_id,
"subject": "Cannot log in",
"status": "open",
"priority": "normal",
},
"messages": [
{
"id": 1,
"sender_type": "customer",
"body": "I cannot log in.",
}
],
"internal_notes": [
{
"id": 1,
"author_id": admin.user_id,
"body": "There was a previous inquiry from the same email address.",
}
],
}
However, customer-facing APIs must never return internal notes.
@router.get("/{ticket_id}")
def get_my_ticket(ticket_id: int):
return {
"ticket": {
"id": ticket_id,
"subject": "Cannot log in",
"status": "open",
},
"messages": [
{
"id": 1,
"sender_type": "customer",
"body": "I cannot log in.",
}
],
}
It is important to separate response models for customer-facing and CS-facing APIs. If you reuse the same model, internal information may accidentally be mixed in.
9. Reply API: Save as a Customer-Visible Message
This is the API for CS staff to reply to customers.
from pydantic import BaseModel, Field
class ReplyCreate(BaseModel):
body: str = Field(..., min_length=1, max_length=5000)
@admin_router.post("/{ticket_id}/reply", status_code=status.HTTP_201_CREATED)
def reply_ticket(
ticket_id: int,
payload: ReplyCreate,
admin: AdminUser = Depends(require_support),
):
return {
"ticket_id": ticket_id,
"message": {
"sender_type": "agent",
"sender_id": admin.user_id,
"body": payload.body,
},
}
There are many things to consider when replying:
- Save the message to the database
- Update the ticket’s
updated_at - Change the status to
waiting_customer - Send an email notification to the customer
- Record an audit log
Light post-processing such as email notification can use FastAPI’s BackgroundTasks. The official documentation explains that BackgroundTasks can be used for processing that should run after the response.
from fastapi import BackgroundTasks
def send_reply_email(ticket_id: int, body: str) -> None:
# Actual email sending process
pass
@admin_router.post("/{ticket_id}/reply", status_code=status.HTTP_201_CREATED)
def reply_ticket(
ticket_id: int,
payload: ReplyCreate,
background_tasks: BackgroundTasks,
admin: AdminUser = Depends(require_support),
):
background_tasks.add_task(send_reply_email, ticket_id, payload.body)
return {"status": "reply_saved"}
Important email sending and notifications that require retries are safer when sent to a job queue. It is best to think of BackgroundTasks as suitable for lightweight processing.
10. Internal Note API: Safely Handle Conversations Not Shown to Customers
Internal notes are very useful in CS operations. However, because they handle information not shown to customers, they must be clearly separated in the API.
class InternalNoteCreate(BaseModel):
body: str = Field(..., min_length=1, max_length=3000)
@admin_router.post("/{ticket_id}/notes", status_code=status.HTTP_201_CREATED)
def create_internal_note(
ticket_id: int,
payload: InternalNoteCreate,
admin: AdminUser = Depends(require_support),
):
return {
"ticket_id": ticket_id,
"note": {
"author_id": admin.user_id,
"body": payload.body,
},
}
Internal notes may contain information that cannot be shown to customers, such as contract status, investigation notes, or requests to other departments.
Therefore, internal notes should be handled as follows:
- Never include them in customer-facing APIs
- Carefully decide whether to include them in search targets
- Record the creator and creation time in audit logs
- Strictly control permissions for deletion and editing
11. Status Transitions: Allow Only Approved Flows
In inquiry support, it is important to clearly define status transitions.
For example, consider the following transitions:
open -> pending
open -> waiting_customer
pending -> waiting_customer
waiting_customer -> open
waiting_customer -> resolved
resolved -> closed
closed -> open
Place this rule in the service layer.
ALLOWED_TRANSITIONS = {
"open": {"pending", "waiting_customer", "resolved"},
"pending": {"waiting_customer", "resolved"},
"waiting_customer": {"open", "resolved"},
"resolved": {"closed", "open"},
"closed": {"open"},
}
def can_transition(current: str, next_status: str) -> bool:
return next_status in ALLOWED_TRANSITIONS.get(current, set())
Use this function in the API.
class TicketStatusUpdate(BaseModel):
status: TicketStatus
@admin_router.patch("/{ticket_id}/status")
def update_ticket_status(
ticket_id: int,
payload: TicketStatusUpdate,
admin: AdminUser = Depends(require_support),
):
current_status = "open"
if not can_transition(current_status, payload.status):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="invalid status transition",
)
return {
"ticket_id": ticket_id,
"status": payload.status,
}
When a state transition fails, 409 Conflict is natural. Using FastAPI’s status module makes these status codes easier to read.
12. Assignee Assignment API: Clarify Who Is Responsible
As inquiries increase, assignee management becomes necessary.
class AssigneeUpdate(BaseModel):
assignee_id: int | None
@admin_router.patch("/{ticket_id}/assignee")
def update_assignee(
ticket_id: int,
payload: AssigneeUpdate,
admin: AdminUser = Depends(require_support_manager),
):
return {
"ticket_id": ticket_id,
"assignee_id": payload.assignee_id,
"updated_by": admin.user_id,
}
For assignee changes, consider rules such as the following:
- Only
support_manageror above can assign tickets to other people supportcan only take tickets for themselves- Assignees cannot be changed for closed tickets
- Notify when the assignee changes
- Record an audit log
It may look like a simple PATCH, but in practice this is a very important operation.
13. Priority and Category: Metadata for Easier Search
Adding priority and category to inquiries makes operations easier.
Category examples:
loginbillingbugfeature_requestaccountother
Priority examples:
lownormalhighurgent
class TicketMetaUpdate(BaseModel):
priority: TicketPriority | None = None
category: str | None = None
@admin_router.patch("/{ticket_id}/meta")
def update_ticket_meta(
ticket_id: int,
payload: TicketMetaUpdate,
admin: AdminUser = Depends(require_support),
):
return {
"ticket_id": ticket_id,
"priority": payload.priority,
"category": payload.category,
}
By organizing categories and priorities, dashboards and reports become easier to create. For example, you can visualize “unresolved billing tickets” or “average first response time for urgent tickets.”
14. Audit Logs: CS Responses Must Be Explainable Later
Audit logs are very important in inquiry support.
Examples of events to record:
ticket.createticket.replyticket.note.createticket.status.updateticket.assignee.updateticket.priority.updateticket.closeticket.reopen
Prepare an audit log function.
def write_audit_log(
actor_id: int | None,
action: str,
resource_type: str,
resource_id: str,
detail: dict | None = None,
) -> None:
# In practice, save to DB or structured logs
pass
Use it when replying.
@admin_router.post("/{ticket_id}/reply", status_code=status.HTTP_201_CREATED)
def reply_ticket(
ticket_id: int,
payload: ReplyCreate,
admin: AdminUser = Depends(require_support),
):
write_audit_log(
actor_id=admin.user_id,
action="ticket.reply",
resource_type="ticket",
resource_id=str(ticket_id),
detail={"body_length": len(payload.body)},
)
return {"status": "reply_saved"}
Carefully decide whether to record the body text itself in audit logs. Since it may contain personal information or confidential information, it is also effective to store only the length or message ID.
15. Notification Design: Separate Customer Notifications and Internal Notifications
Inquiry support involves multiple types of notifications.
Customer Notifications
- Inquiry receipt email
- CS reply email
- Resolution confirmation email
Internal Notifications
- New inquiry notification
- Urgent ticket notification
- Notification when assigned to you
- Notification when an SLA deadline is approaching
Even though all of these are “notifications,” their recipients and purposes differ. It is useful to distinguish them in API design as well.
def notify_customer_reply(ticket_id: int) -> None:
pass
def notify_assignee_changed(ticket_id: int, assignee_id: int) -> None:
pass
Lightweight notifications can use BackgroundTasks, while notifications that require retries should be sent to a job queue.
16. Designing with SLA in Mind: Add Response Deadlines
When CS operations become more serious, SLAs and response deadlines become necessary.
Examples:
- First reply within 24 hours
- Urgent tickets within 2 hours
- Enterprise customers receive priority support
- Automatically close 7 days after resolved
Add deadlines to tickets.
class TicketSLAInfo(BaseModel):
first_response_due_at: datetime | None = None
resolution_due_at: datetime | None = None
breached: bool = False
With SLA information, the search API becomes more powerful.
GET /admin/tickets?sla=breached
GET /admin/tickets?priority=urgent&status=open
In the future, you can move toward a design where overdue tickets are detected by a scheduled job and internal notifications are sent.
17. Integration with Customer Information: Do Not Let Tickets Stand Alone
In inquiry support, the ticket body alone is not enough. CS staff often want to see the following information as well:
- Customer’s contract plan
- Tenant name
- Payment status
- Features currently in use
- Past inquiries
- Recent errors
- Admin operation history
However, if you put everything into the detail API, it becomes heavy. At first, it is better to include only the minimum necessary customer summary in the ticket detail.
@admin_router.get("/{ticket_id}")
def get_ticket_detail(
ticket_id: int,
admin: AdminUser = Depends(require_support),
):
return {
"ticket": {"id": ticket_id, "subject": "About billing"},
"customer_summary": {
"tenant_name": "Example Inc.",
"plan": "pro",
"subscription_status": "active",
},
"messages": [],
"internal_notes": [],
}
More detailed information is easier to maintain if separated into another API.
18. Error Design: Make Errors That CS Staff Can Understand
For CS-facing APIs, vague errors cause problems for the team.
For example, you need to distinguish the following cases:
- Insufficient permissions
- Ticket does not exist
- Ticket is already closed
- Invalid status transition
- User cannot be assigned
- Notification sending failed, but reply saving succeeded
FastAPI lets you use HTTPException to clearly specify the status code and details.
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "INVALID_TICKET_STATUS_TRANSITION",
"message": "This status cannot be changed to the requested status.",
},
)
It is a good idea to align the error format with the common error format covered in previous articles. In CS-facing screens, the UI can use code to change displays and operation guidance.
19. Testing Policy: Protect Conversation History and State Transitions
For inquiry support APIs, the following tests are important:
- Customers can create inquiries
- CS staff can search ticket lists
- Users without permissions cannot view details
- Replying adds a message
- Replying changes the status to
waiting_customer - Internal notes do not appear in customer-facing APIs
- Invalid status transitions return 409
- Assignee changes require manager permission
- Audit logs are recorded
- Notification tasks are registered
FastAPI has a mechanism for overriding dependencies during tests. The official documentation explains that you can use app.dependency_overrides to replace dependency functions. This makes it easy to inject test admin users or CS staff.
20. Common Failure Patterns
20.1 Handling Messages and Internal Notes Carelessly in the Same Table or Same Response
There is a risk that internal notes may leak to customers. It is recommended to clearly separate customer-facing messages and internal notes even in API responses.
20.2 Allowing Statuses to Be Changed Freely
This can easily create operationally strange states, such as changing directly from open to closed, or repeatedly changing from closed to pending. It is safer to create a service layer that allows only permitted transitions.
20.3 Fully Combining Reply Saving and Email Sending
If reply saving also fails because email sending fails, the CS staff’s operation experience becomes poor. It is more stable to treat reply saving as the main process and email sending as a subsequent process.
20.4 Treating “Admin Can Do Everything” as the Permission Model
CS, accounting, developers, and superadmins should see different information and perform different operations. Inquiries often contain personal information, so it is worth separating permissions carefully.
20.5 Not Recording Audit Logs
If you cannot tell “who sent this reply” or “who closed this ticket,” quality management for inquiry support becomes difficult. At minimum, record replies, internal notes, status changes, and assignee changes.
21. Roadmap by Reader Type
Independent Developers and Learners
- First, create inquiry creation with
POST /tickets - Separate message history into another model
- Separate the CS-facing
/admin/ticketsAPI - Separate the reply API and internal note API
- Control status transitions with a small function
Engineers in Small Teams
- Interview CS staff about their actual workflow
- Design tickets, messages, internal notes, assignees, and statuses separately
- Define permissions for support / support_manager / admin
- Add audit logs to replies, notes, status changes, and assignee changes
- Share common conditions between list search and CSV export
SaaS Development Teams and Startups
- Treat the inquiry support API as a CS business foundation
- Integrate it with tenants, contracts, audit logs, and admin panel APIs
- Design SLAs, priorities, and escalation
- Connect it to notification infrastructure and job queues
- Turn response time, unresolved counts, and category counts into metrics
Reference Links
- FastAPI
Conclusion
- A CS inquiry support API is not merely an inquiry storage feature. It is a business foundation that includes conversation history, internal notes, assignees, statuses, audit logs, and notifications.
- In FastAPI, it is easy to design this by separating customer-facing APIs and CS-facing APIs with
APIRouter, organizing permissions withDepends, and returning clear errors withHTTPExceptionandstatus. - It is essential to separate customer-visible messages from internal-only notes. Mixing them can easily lead to information leakage.
- Status transitions should not be free-form input. They are safer when only permitted flows are controlled in the service layer.
- You do not need to build a perfect CS foundation from the start. Simply separating five things—tickets, messages, internal notes, statuses, and audit logs—already makes the inquiry support API much more practical.
As next articles, “Designing a Notification and Email Sending Foundation with FastAPI” or “Designing an SLA and Escalation Management API with FastAPI” would naturally follow this topic.
