18 minlesson

Building the Confirmation Endpoint

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:

csharp
1public class ConfirmDeliveryRequest
2{
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:

csharp
1public class DeliveryConfirmationResponse
2{
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:

csharp
1app.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:

csharp
1var parcel = await db.Parcels
2 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);
3
4if (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:

csharp
1if (parcel.Status != ParcelStatus.InTransit &&
2 parcel.Status != ParcelStatus.OutForDelivery)
3{
4 return Results.BadRequest(new
5 {
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:

csharp
1var existingConfirmation = await db.DeliveryConfirmations
2 .AnyAsync(dc => dc.ParcelId == parcel.Id);
3
4if (existingConfirmation)
5{
6 return Results.Conflict(new
7 {
8 message = "Delivery confirmation already exists for this parcel.",
9 trackingNumber
10 });
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:

csharp
1var confirmation = new DeliveryConfirmation
2{
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.UtcNow
10};
11
12db.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:

csharp
1var trackingEvent = new TrackingEvent
2{
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.UtcNow
12};
13
14db.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:

csharp
1parcel.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:

csharp
1await 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:

csharp
1var response = new DeliveryConfirmationResponse
2{
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.CreatedAt
10};
11
12return 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:

csharp
1app.MapPost("/api/parcels/{trackingNumber}/delivery-confirmation",
2 async (string trackingNumber, ConfirmDeliveryRequest request,
3 ParcelTrackingDbContext db) =>
4{
5 var parcel = await db.Parcels
6 .Include(p => p.RecipientAddress)
7 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);
8
9 if (parcel is null)
10 return Results.NotFound(new { message = $"Parcel '{trackingNumber}' not found." });
11
12 if (parcel.Status != ParcelStatus.InTransit &&
13 parcel.Status != ParcelStatus.OutForDelivery)
14 {
15 return Results.BadRequest(new
16 {
17 message = $"Cannot confirm delivery. Parcel status is '{parcel.Status}'.",
18 detail = "Parcel must be in 'InTransit' or 'OutForDelivery' status."
19 });
20 }
21
22 var existingConfirmation = await db.DeliveryConfirmations
23 .AnyAsync(dc => dc.ParcelId == parcel.Id);
24
25 if (existingConfirmation)
26 {
27 return Results.Conflict(new
28 {
29 message = "Delivery confirmation already exists for this parcel.",
30 trackingNumber
31 });
32 }
33
34 var confirmation = new DeliveryConfirmation
35 {
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.UtcNow
43 };
44
45 var trackingEvent = new TrackingEvent
46 {
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.UtcNow
56 };
57
58 db.DeliveryConfirmations.Add(confirmation);
59 db.TrackingEvents.Add(trackingEvent);
60
61 parcel.Status = ParcelStatus.Delivered;
62 parcel.ActualDeliveryDate = request.DeliveredAt;
63 parcel.UpdatedAt = DateTime.UtcNow;
64
65 await db.SaveChangesAsync();
66
67 var response = new DeliveryConfirmationResponse
68 {
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.CreatedAt
76 };
77
78 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:

csharp
1if (string.IsNullOrWhiteSpace(request.ReceivedBy))
2 return Results.BadRequest(new { message = "'ReceivedBy' is required." });
3
4if (string.IsNullOrWhiteSpace(request.DeliveryLocation))
5 return Results.BadRequest(new { message = "'DeliveryLocation' is required." });
6
7if (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:

csharp
1if (request.SignatureImage is not null)
2{
3 try
4 {
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:

http
1POST /api/parcels/PKG-20250215-A1B2C3/delivery-confirmation
2Content-Type: application/json
3
4{
5 "receivedBy": "Jane Smith",
6 "deliveryLocation": "Front door",
7 "signatureImage": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
8 "deliveredAt": "2025-02-15T14:30:00Z"
9}

Expected response (201 Created):

json
1{
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 Conflict communicates duplicate confirmation attempts
  • Auto-creating a tracking event keeps the history complete
  • SaveChangesAsync provides transactional consistency across all three writes
  • The response uses HasSignature instead of returning the full base64 data
  • Input validation catches bad data before business logic runs