Building the Confirmation Endpoint
In this presentation, we build the POST endpoint that confirms parcel delivery. The endpoint coordinates multiple operations: validating the parcel status, checking for duplicate confirmations, creating a DeliveryConfirmation record, auto-creating a "Delivered" tracking event, and updating the parcel's status and actual delivery date.
The Request and Response DTOs
Before writing the endpoint, define the data shapes. The request DTO captures the proof-of-delivery data from the delivery driver:
csharp1public class ConfirmDeliveryRequest2{3 public required string ReceivedBy { get; init; }4 public required string DeliveryLocation { get; init; }5 public string? SignatureImage { get; init; }6 public required DateTime DeliveredAt { get; init; }7}
The ReceivedBy and DeliveryLocation fields are required. A delivery must always record who received the package and where it was left. The SignatureImage is optional because not all deliveries require a signature. The DeliveredAt timestamp comes from the driver's device.
The response DTO returns the created confirmation along with parcel context:
csharp1public class DeliveryConfirmationResponse2{3 public Guid Id { get; init; }4 public string TrackingNumber { get; init; } = string.Empty;5 public string ReceivedBy { get; init; } = string.Empty;6 public string DeliveryLocation { get; init; } = string.Empty;7 public bool HasSignature { get; init; }8 public DateTime DeliveredAt { get; init; }9 public DateTime CreatedAt { get; init; }10}
Notice HasSignature instead of returning the full base64 string in the POST response. The signature data can be large, and the caller already has it since they just sent it. The GET endpoint returns the full signature when needed.
Endpoint Route and Method
The endpoint follows REST conventions for creating a sub-resource:
csharp1app.MapPost("/api/parcels/{trackingNumber}/delivery-confirmation",2 async (string trackingNumber, ConfirmDeliveryRequest request,3 ParcelTrackingDbContext db) =>4{5 // Implementation follows...6});
The trackingNumber path parameter identifies the parcel. The request body contains the delivery proof data. This route reads naturally: "confirm delivery for this parcel."
Step 1: Load the Parcel
The first step is to find the parcel by its tracking number. If no parcel exists with that tracking number, return 404:
csharp1var parcel = await db.Parcels2 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);34if (parcel is null)5{6 return Results.NotFound(new { message = $"Parcel '{trackingNumber}' not found." });7}
We load the parcel first because every subsequent step depends on it. The status validation, duplicate check, and entity updates all need parcel data.
Step 2: Validate the Parcel Status
Only parcels with status InTransit or OutForDelivery can be confirmed as delivered:
csharp1if (parcel.Status != ParcelStatus.InTransit &&2 parcel.Status != ParcelStatus.OutForDelivery)3{4 return Results.BadRequest(new5 {6 message = $"Cannot confirm delivery. Parcel status is '{parcel.Status}'.",7 detail = "Parcel must be in 'InTransit' or 'OutForDelivery' status."8 });9}
Returning a 400 with a descriptive message helps API consumers understand why their request was rejected. The response includes both the current status and the valid statuses.
Why not use a simple list check? With only two valid statuses, explicit comparisons are clearer than new[] { ... }.Contains(...). If the valid set grows in the future, you can refactor then.
Step 3: Check for Duplicate Confirmations
A parcel can only be delivered once. Before creating a new record, check if one already exists:
csharp1var existingConfirmation = await db.DeliveryConfirmations2 .AnyAsync(dc => dc.ParcelId == parcel.Id);34if (existingConfirmation)5{6 return Results.Conflict(new7 {8 message = "Delivery confirmation already exists for this parcel.",9 trackingNumber10 });11}
Using AnyAsync is more efficient than FirstOrDefaultAsync here because we only need to know whether a record exists, not retrieve its data. The database can short-circuit after finding the first match.
The 409 Conflict status code is semantically correct: the request format is valid, but it conflicts with the current state of the resource. This is distinct from 400 Bad Request (invalid format) and 422 Unprocessable Entity (validation failure on the data itself).
Step 4: Create the DeliveryConfirmation Record
With all validations passed, create the confirmation entity:
csharp1var confirmation = new DeliveryConfirmation2{3 Id = Guid.NewGuid(),4 ParcelId = parcel.Id,5 ReceivedBy = request.ReceivedBy,6 DeliveryLocation = request.DeliveryLocation,7 SignatureImage = request.SignatureImage,8 DeliveredAt = request.DeliveredAt,9 CreatedAt = DateTime.UtcNow10};1112db.DeliveryConfirmations.Add(confirmation);
Each field maps directly from the request DTO to the entity. The Id is generated server-side as a GUID. The ParcelId links the confirmation to the parcel. CreatedAt records when the confirmation was processed, which may differ from DeliveredAt (when the package was physically delivered).
Step 5: Auto-Create the Tracking Event
Every status change in the parcel's lifecycle should be recorded as a tracking event. Delivery is no exception:
csharp1var trackingEvent = new TrackingEvent2{3 Id = Guid.NewGuid(),4 ParcelId = parcel.Id,5 EventType = EventType.Delivered,6 Timestamp = request.DeliveredAt,7 Description = $"Delivered to {request.ReceivedBy} at {request.DeliveryLocation}",8 LocationCity = parcel.RecipientAddress?.City,9 LocationState = parcel.RecipientAddress?.State,10 LocationCountry = parcel.RecipientAddress?.CountryCode,11 CreatedAt = DateTime.UtcNow12};1314db.TrackingEvents.Add(trackingEvent);
The tracking event uses the same DeliveredAt timestamp from the request, keeping the timeline consistent. The description is human-readable, combining the recipient name and location. Location fields come from the recipient address since that is where delivery occurred.
This auto-creation pattern keeps the tracking history complete without requiring the API caller to make a separate request to create the event.
Step 6: Update the Parcel
The final data modification updates the parcel's status and delivery date:
csharp1parcel.Status = ParcelStatus.Delivered;2parcel.ActualDeliveryDate = request.DeliveredAt;3parcel.UpdatedAt = DateTime.UtcNow;
Because we loaded the parcel in Step 1, EF Core is already tracking it. Modifying its properties marks it as Modified in the change tracker. No explicit Update call is needed.
Setting ActualDeliveryDate enables the on-time calculation in the GET endpoint. The UpdatedAt timestamp records when the parcel entity was last modified.
Step 7: Save All Changes
All three modifications (confirmation, tracking event, parcel update) are saved in a single call:
csharp1await db.SaveChangesAsync();
EF Core wraps all pending changes in a database transaction. Either all three writes succeed, or none of them are applied. This guarantees transactional consistency without explicit transaction management code.
If SaveChangesAsync throws a DbUpdateException, the database rolls back all changes automatically.
Step 8: Return the Response
Return a 201 Created response with the confirmation data:
csharp1var response = new DeliveryConfirmationResponse2{3 Id = confirmation.Id,4 TrackingNumber = trackingNumber,5 ReceivedBy = confirmation.ReceivedBy,6 DeliveryLocation = confirmation.DeliveryLocation,7 HasSignature = confirmation.SignatureImage is not null,8 DeliveredAt = confirmation.DeliveredAt,9 CreatedAt = confirmation.CreatedAt10};1112return Results.Created(13 $"/api/parcels/{trackingNumber}/delivery-confirmation",14 response);
The 201 Created response includes a Location header pointing to the GET endpoint where the full confirmation can be retrieved. The HasSignature boolean tells the caller whether signature data was stored without returning the entire base64 string.
The Complete Endpoint
Here is the full endpoint with all steps combined:
csharp1app.MapPost("/api/parcels/{trackingNumber}/delivery-confirmation",2 async (string trackingNumber, ConfirmDeliveryRequest request,3 ParcelTrackingDbContext db) =>4{5 var parcel = await db.Parcels6 .Include(p => p.RecipientAddress)7 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);89 if (parcel is null)10 return Results.NotFound(new { message = $"Parcel '{trackingNumber}' not found." });1112 if (parcel.Status != ParcelStatus.InTransit &&13 parcel.Status != ParcelStatus.OutForDelivery)14 {15 return Results.BadRequest(new16 {17 message = $"Cannot confirm delivery. Parcel status is '{parcel.Status}'.",18 detail = "Parcel must be in 'InTransit' or 'OutForDelivery' status."19 });20 }2122 var existingConfirmation = await db.DeliveryConfirmations23 .AnyAsync(dc => dc.ParcelId == parcel.Id);2425 if (existingConfirmation)26 {27 return Results.Conflict(new28 {29 message = "Delivery confirmation already exists for this parcel.",30 trackingNumber31 });32 }3334 var confirmation = new DeliveryConfirmation35 {36 Id = Guid.NewGuid(),37 ParcelId = parcel.Id,38 ReceivedBy = request.ReceivedBy,39 DeliveryLocation = request.DeliveryLocation,40 SignatureImage = request.SignatureImage,41 DeliveredAt = request.DeliveredAt,42 CreatedAt = DateTime.UtcNow43 };4445 var trackingEvent = new TrackingEvent46 {47 Id = Guid.NewGuid(),48 ParcelId = parcel.Id,49 EventType = EventType.Delivered,50 Timestamp = request.DeliveredAt,51 Description = $"Delivered to {request.ReceivedBy} at {request.DeliveryLocation}",52 LocationCity = parcel.RecipientAddress?.City,53 LocationState = parcel.RecipientAddress?.State,54 LocationCountry = parcel.RecipientAddress?.CountryCode,55 CreatedAt = DateTime.UtcNow56 };5758 db.DeliveryConfirmations.Add(confirmation);59 db.TrackingEvents.Add(trackingEvent);6061 parcel.Status = ParcelStatus.Delivered;62 parcel.ActualDeliveryDate = request.DeliveredAt;63 parcel.UpdatedAt = DateTime.UtcNow;6465 await db.SaveChangesAsync();6667 var response = new DeliveryConfirmationResponse68 {69 Id = confirmation.Id,70 TrackingNumber = trackingNumber,71 ReceivedBy = confirmation.ReceivedBy,72 DeliveryLocation = confirmation.DeliveryLocation,73 HasSignature = confirmation.SignatureImage is not null,74 DeliveredAt = confirmation.DeliveredAt,75 CreatedAt = confirmation.CreatedAt76 };7778 return Results.Created(79 $"/api/parcels/{trackingNumber}/delivery-confirmation",80 response);81});
Notice the .Include(p => p.RecipientAddress) in the query. This eager-loads the recipient address so we can use its city, state, and country in the tracking event description.
Input Validation Considerations
The endpoint should validate the request data before proceeding with business logic:
csharp1if (string.IsNullOrWhiteSpace(request.ReceivedBy))2 return Results.BadRequest(new { message = "'ReceivedBy' is required." });34if (string.IsNullOrWhiteSpace(request.DeliveryLocation))5 return Results.BadRequest(new { message = "'DeliveryLocation' is required." });67if (request.DeliveredAt > DateTime.UtcNow)8 return Results.BadRequest(new { message = "'DeliveredAt' cannot be in the future." });
These checks catch invalid data early, before hitting the database. The future-date check prevents obviously incorrect timestamps.
For the optional SignatureImage, you could validate that it is valid base64 if provided:
csharp1if (request.SignatureImage is not null)2{3 try4 {5 Convert.FromBase64String(request.SignatureImage);6 }7 catch (FormatException)8 {9 return Results.BadRequest(new { message = "'SignatureImage' is not valid base64." });10 }11}
This validation decodes the string to verify its format but does not store the decoded bytes. The original base64 string is what gets persisted.
Testing the Endpoint
Test the endpoint with a tool like curl or the HTTP file in your IDE:
http1POST /api/parcels/PKG-20250215-A1B2C3/delivery-confirmation2Content-Type: application/json34{5 "receivedBy": "Jane Smith",6 "deliveryLocation": "Front door",7 "signatureImage": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",8 "deliveredAt": "2025-02-15T14:30:00Z"9}
Expected response (201 Created):
json1{2 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",3 "trackingNumber": "PKG-20250215-A1B2C3",4 "receivedBy": "Jane Smith",5 "deliveryLocation": "Front door",6 "hasSignature": true,7 "deliveredAt": "2025-02-15T14:30:00Z",8 "createdAt": "2025-02-15T14:35:12Z"9}
Test the error cases too:
- POST to a non-existent tracking number: expect 404
- POST when parcel status is
LabelCreated: expect 400 - POST a second time for the same parcel: expect 409
Key Takeaways
- The POST endpoint follows a clear sequence: load, validate status, check duplicates, create records, save
- Status validation prevents logically inconsistent data
409 Conflictcommunicates duplicate confirmation attempts- Auto-creating a tracking event keeps the history complete
SaveChangesAsyncprovides transactional consistency across all three writes- The response uses
HasSignatureinstead of returning the full base64 data - Input validation catches bad data before business logic runs