Exception Reporting Endpoint
When a delivery attempt fails, the carrier system needs to report the exception through the API. This presentation walks through building the endpoint that transitions a parcel to Exception status, records the reason, and creates a tracking event.
API Design
The exception reporting endpoint uses POST because it creates a new state (an exception) on an existing resource:
POST /api/parcels/{id}/exception
The request body contains the reason for the exception:
json1{2 "reason": "RecipientUnavailable"3}
A successful response returns the updated parcel with a 200 OK status code.
The ExceptionReason Enum
We model exception reasons as a C# enum with JsonStringEnumConverter so the API accepts and returns string values instead of integers:
csharp1using System.Text.Json.Serialization;23[JsonConverter(typeof(JsonStringEnumConverter))]4public enum ExceptionReason5{6 AddressNotFound,7 RecipientUnavailable,8 DamagedPackage,9 WeatherDelay,10 CustomsHold,11 RefusedByRecipient12}
The JsonStringEnumConverter attribute means clients send "RecipientUnavailable" instead of 1. This makes the API self-documenting and less error-prone.
The Request DTO
The request body is simple, containing only the exception reason:
csharp1public class ReportExceptionRequest2{3 public ExceptionReason Reason { get; set; }4}
ASP.NET Core model binding will automatically deserialize the JSON string into the enum value. If the client sends an invalid reason string, the framework returns a 400 response before your code runs.
Validating the Current Status
Not every parcel can receive an exception. Only parcels that are actively being delivered should transition to Exception. We validate that the parcel is in an eligible status before proceeding:
csharp1private static readonly ParcelStatus[] ExceptionEligibleStatuses =2{3 ParcelStatus.InTransit,4 ParcelStatus.OutForDelivery5};
If the parcel is in LabelCreated, Delivered, Returned, or already in Exception, reporting a new exception does not make sense. The endpoint returns a 400 Bad Request with a message explaining the invalid transition.
The Controller Action
Here is the complete controller action for reporting a delivery exception:
csharp1[HttpPost("{id}/exception")]2public async Task<ActionResult<ParcelResponse>> ReportException(3 Guid id,4 ReportExceptionRequest request)5{6 var parcel = await _context.Parcels.FindAsync(id);78 if (parcel == null)9 return NotFound(new { message = "Parcel not found" });1011 if (!ExceptionEligibleStatuses.Contains(parcel.Status))12 {13 return BadRequest(new14 {15 message = $"Cannot report exception for parcel in {parcel.Status} status"16 });17 }1819 parcel.Status = ParcelStatus.Exception;20 parcel.DeliveryAttempts++;21 parcel.UpdatedAt = DateTime.UtcNow;2223 var trackingEvent = new TrackingEvent24 {25 ParcelId = parcel.Id,26 EventType = EventType.DeliveryAttempted,27 Description = $"Delivery exception: {request.Reason}",28 DelayReason = request.Reason.ToString(),29 Timestamp = DateTime.UtcNow,30 LocationCity = parcel.RecipientAddress?.City,31 LocationState = parcel.RecipientAddress?.State,32 LocationCountry = parcel.RecipientAddress?.CountryCode33 };3435 _context.TrackingEvents.Add(trackingEvent);36 await _context.SaveChangesAsync();3738 return Ok(MapToResponse(parcel));39}
Let us break down what happens in this action step by step.
Step 1: Find the Parcel
csharp1var parcel = await _context.Parcels.FindAsync(id);23if (parcel == null)4 return NotFound(new { message = "Parcel not found" });
The FindAsync method uses the primary key to look up the parcel. If the parcel does not exist, we return 404 Not Found immediately.
Step 2: Validate the Status
csharp1if (!ExceptionEligibleStatuses.Contains(parcel.Status))2{3 return BadRequest(new4 {5 message = $"Cannot report exception for parcel in {parcel.Status} status"6 });7}
The status check prevents invalid transitions. A parcel that is already Delivered or Returned cannot receive a new exception. Including the current status in the error message helps the client understand why the request failed.
Step 3: Update the Parcel
csharp1parcel.Status = ParcelStatus.Exception;2parcel.DeliveryAttempts++;3parcel.UpdatedAt = DateTime.UtcNow;
Three fields change on the parcel:
- Status transitions to
Exception - DeliveryAttempts increments by one to track the failed attempt count
- UpdatedAt records when this change occurred
The DeliveryAttempts counter is critical for the retry logic that we build in the next presentation. Once this counter reaches 3, no more retries are allowed.
Step 4: Create the Tracking Event
csharp1var trackingEvent = new TrackingEvent2{3 ParcelId = parcel.Id,4 EventType = EventType.DeliveryAttempted,5 Description = $"Delivery exception: {request.Reason}",6 DelayReason = request.Reason.ToString(),7 Timestamp = DateTime.UtcNow,8 LocationCity = parcel.RecipientAddress?.City,9 LocationState = parcel.RecipientAddress?.State,10 LocationCountry = parcel.RecipientAddress?.CountryCode11};1213_context.TrackingEvents.Add(trackingEvent);
The tracking event captures:
- EventType:
DeliveryAttemptedindicates a delivery was tried but failed - Description: A human-readable message including the reason
- DelayReason: The exception reason stored as a string for querying and filtering
- Location: The recipient's address, since that is where the delivery attempt happened
- Timestamp: When the exception occurred
Step 5: Save and Respond
csharp1await _context.SaveChangesAsync();2return Ok(MapToResponse(parcel));
Both the parcel update and the new tracking event are saved in a single SaveChangesAsync call. EF Core wraps this in a transaction, so either both changes succeed or neither does. The response returns the updated parcel data.
Loading the Recipient Address
The tracking event uses the recipient's address for location data. If the parcel was loaded without its navigation properties, those fields would be null. To ensure the address is available, load it with Include:
csharp1var parcel = await _context.Parcels2 .Include(p => p.RecipientAddress)3 .FirstOrDefaultAsync(p => p.Id == id);
This eager-loads the recipient address in the same database query as the parcel, avoiding a second round-trip.
Error Response Examples
When the parcel does not exist:
json1// POST /api/parcels/999/exception2// 404 Not Found3{4 "message": "Parcel not found"5}
When the parcel is in an invalid status:
json1// POST /api/parcels/1/exception (parcel is Delivered)2// 400 Bad Request3{4 "message": "Cannot report exception for parcel in Delivered status"5}
When the request contains an invalid reason:
json1// POST /api/parcels/1/exception2// Body: { "reason": "InvalidReason" }3// 400 Bad Request (automatic model binding error)4{5 "errors": {6 "reason": ["The value 'InvalidReason' is not valid."]7 }8}
Testing the Endpoint
Verify the endpoint works correctly with these test scenarios:
-
Happy path: Report an exception on an
InTransitparcel. Verify the status changes toException,DeliveryAttemptsincrements, and a tracking event is created. -
Invalid status: Try to report an exception on a
Deliveredparcel. Verify a400response. -
Not found: Report an exception on a non-existent parcel ID. Verify a
404response. -
Invalid reason: Send an invalid enum value. Verify the framework returns a
400response. -
Multiple exceptions: Report two exceptions on the same parcel (with a retry in between). Verify
DeliveryAttemptsreaches 2.
Complete Request/Response Example
Request:
http1POST /api/parcels/42/exception2Content-Type: application/json34{5 "reason": "RecipientUnavailable"6}
Response:
json1// 200 OK2{3 "id": 42,4 "trackingNumber": "PKG-20250215-X7K9M2",5 "status": "Exception",6 "serviceType": "Express",7 "deliveryAttempts": 1,8 "estimatedDeliveryDate": "2025-02-17T00:00:00Z",9 "updatedAt": "2025-02-15T14:30:00Z"10}
The response confirms the status changed to Exception and the delivery attempts count is now 1.
Key Takeaways
- The exception endpoint validates the parcel exists and is in an eligible status before transitioning
ExceptionReasonis an enum withJsonStringEnumConverterfor readable API valuesDeliveryAttemptsincrements on every exception report to track the failure count- A
DeliveryAttemptedtracking event preserves the exception reason and location - EF Core's
SaveChangesAsyncprovides transactional consistency for the parcel update and tracking event
Next, we will build the retry endpoint that moves parcels from Exception back to InTransit and enforces the maximum attempt limit.