Proof of Delivery and Retrieval
In this presentation, we build the GET endpoint that retrieves a delivery confirmation, handle base64 signature image storage and retrieval, calculate on-time delivery metrics, and explore the 409 Conflict pattern in more depth.
The GET Endpoint Overview
The GET endpoint returns the full delivery confirmation for a parcel, including the signature image and a calculated on-time flag:
GET /api/parcels/{trackingNumber}/delivery-confirmation
Possible responses:
| Status | Condition |
|---|---|
200 OK | Confirmation found, returns full data |
404 Not Found | Parcel does not exist, or parcel exists but has no confirmation |
The GET endpoint needs a more detailed DTO that includes the full signature and on-time calculation:
csharp1public class DeliveryConfirmationDetailDto2{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 string? SignatureImage { get; init; }8 public DateTime DeliveredAt { get; init; }9 public DateTime? EstimatedDeliveryDate { get; init; }10 public bool IsOnTime { get; init; }11 public DateTime CreatedAt { get; init; }12}
Notice that this DTO includes the full SignatureImage base64 string, the EstimatedDeliveryDate for context, and the calculated IsOnTime flag. The caller gets everything needed to display a complete proof-of-delivery screen. This is different from the lightweight DeliveryConfirmationDto used in POST responses.
Extending the Service Interface
We need to add a method to the service interface for retrieving detailed confirmation with signature and on-time calculation:
csharp1public interface IDeliveryConfirmationService2{3 Task<DeliveryConfirmationDto> CreateAsync(Guid parcelId, CreateDeliveryConfirmationRequest request);4 Task<DeliveryConfirmationDetailDto?> GetDetailByParcelIdAsync(Guid parcelId);5}
The GetDetailByParcelIdAsync method returns the full detail DTO including signature data and on-time calculation, or null if no confirmation exists.
Implementing GetDetailByParcelIdAsync
Add this method to the DeliveryConfirmationService:
csharp1public async Task<DeliveryConfirmationDetailDto?> GetDetailByParcelIdAsync(Guid parcelId)2{3 var parcel = await _db.Parcels4 .Include(p => p.DeliveryConfirmation)5 .FirstOrDefaultAsync(p => p.Id == parcelId);67 if (parcel?.DeliveryConfirmation is null)8 {9 return null;10 }1112 var confirmation = parcel.DeliveryConfirmation;1314 var isOnTime = parcel.EstimatedDeliveryDate.HasValue &&15 confirmation.DeliveredAt.Date <= parcel.EstimatedDeliveryDate.Value.Date;1617 return new DeliveryConfirmationDetailDto18 {19 Id = confirmation.Id,20 TrackingNumber = parcel.TrackingNumber,21 ReceivedBy = confirmation.ReceivedBy,22 DeliveryLocation = confirmation.DeliveryLocation,23 SignatureImage = confirmation.SignatureImage,24 DeliveredAt = confirmation.DeliveredAt,25 EstimatedDeliveryDate = parcel.EstimatedDeliveryDate,26 IsOnTime = isOnTime,27 CreatedAt = confirmation.CreatedAt28 };29}
The service handles three key operations: loading the parcel with its confirmation, calculating the on-time status, and mapping to the detailed DTO. The base64 signature is passed through as-is from the entity to the DTO.
Building the GET Controller Action
The GET action leverages the service layer. The controller action remains focused on HTTP concerns:
csharp1[HttpGet("api/parcels/{trackingNumber}/delivery-confirmation")]2public async Task<IActionResult> GetDeliveryConfirmation(string trackingNumber)3{4 var parcel = await _db.Parcels5 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);67 if (parcel is null)8 {9 return NotFound(new10 {11 message = $"Parcel '{trackingNumber}' not found."12 });13 }1415 var confirmation = await _deliveryConfirmationService.GetDetailByParcelIdAsync(parcel.Id);1617 if (confirmation is null)18 {19 return NotFound(new20 {21 message = $"No delivery confirmation exists for parcel '{trackingNumber}'."22 });23 }2425 return Ok(confirmation);26}
The controller action performs two HTTP-level concerns: looking up the parcel by tracking number and translating null results to 404 responses. The service handles the data retrieval, on-time calculation, and DTO mapping.
Two Levels of 404
The endpoint has two distinct "not found" scenarios:
- Parcel does not exist: The tracking number is wrong or the parcel was never registered
- Parcel exists but has no confirmation: The parcel is valid but has not been delivered yet
Both return 404, but with different messages. This helps API consumers distinguish between a bad tracking number and a parcel that is still in transit. Some API designs use different status codes (e.g., 404 for missing parcel, 204 for no confirmation), but returning 404 with descriptive messages is simpler and equally effective.
csharp1// Scenario 1: Bad tracking number2// Response: 404 { "message": "Parcel 'PKG-INVALID' not found." }34// Scenario 2: Parcel exists, not yet delivered5// Response: 404 { "message": "No delivery confirmation exists for parcel 'PKG-20250215-A1B2C3'." }
Calculating On-Time Delivery in the Service
The on-time calculation is performed in the service layer's GetDetailByParcelIdAsync method. It compares the actual delivery date against the estimated delivery date:
csharp1var isOnTime = parcel.EstimatedDeliveryDate.HasValue &&2 confirmation.DeliveredAt.Date <= parcel.EstimatedDeliveryDate.Value.Date;
The service has access to both the parcel and confirmation data, making it the natural place for this calculation. This line handles three scenarios:
| Scenario | EstimatedDeliveryDate | DeliveredAt | IsOnTime |
|---|---|---|---|
| On time | 2025-02-15 | 2025-02-15 14:30 | true |
| Late | 2025-02-15 | 2025-02-16 09:00 | false |
| No estimate | null | 2025-02-15 14:30 | false |
The .Date property strips the time component, so a parcel delivered at 11:59 PM on the estimated date is still on time. When there is no estimated delivery date, the result defaults to false because we cannot determine on-time status without a baseline.
An alternative approach would be to return null for IsOnTime when there is no estimate, using bool? instead of bool. This distinguishes "late" from "unknown":
csharp1bool? isOnTime = parcel.EstimatedDeliveryDate.HasValue2 ? confirmation.DeliveredAt.Date <= parcel.EstimatedDeliveryDate.Value.Date3 : null;
Both approaches are valid. The non-nullable version is simpler for consumers to handle. The nullable version is more precise.
Working with Base64 Signature Data
The signature image flows through the system as a base64-encoded string. Let us trace its path from request to storage to retrieval.
Receiving the Signature
The POST endpoint receives the base64 string in the JSON request body:
json1{2 "receivedBy": "Jane Smith",3 "deliveryLocation": "Front door",4 "signatureImage": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAA..."5}
The SignatureImage property on the request DTO is a string?. The JSON deserializer handles it like any other string property.
Storing the Signature
In the EF Core entity, the signature is a nullable string column:
csharp1public class DeliveryConfirmation2{3 public Guid Id { get; set; }4 public Guid ParcelId { get; set; }5 public string ReceivedBy { get; set; } = string.Empty;6 public string DeliveryLocation { get; set; } = string.Empty;7 public string? SignatureImage { get; set; }8 public DateTime DeliveredAt { get; set; }9 public DateTime CreatedAt { get; set; }1011 public Parcel Parcel { get; set; } = null!;12}
PostgreSQL stores it as a text column. SQL Server would use nvarchar(max). The base64 string is stored as-is without any transformation.
Size Considerations
Base64 encoding increases the data size by approximately 33%. A typical signature image might be:
| Image Size | Base64 Size |
|---|---|
| 2 KB | ~2.7 KB |
| 5 KB | ~6.7 KB |
| 10 KB | ~13.3 KB |
| 50 KB | ~66.7 KB |
For signature images, these sizes are reasonable for inline JSON storage. If you needed to handle larger files (photos, documents), you would use a different approach like multipart form uploads or pre-signed URLs to cloud storage.
Returning the Signature
The service layer handles signature data consistently across both operations. The POST endpoint returns only HasSignature: true/false to keep the creation response lightweight, while the GET endpoint returns the full base64 string:
csharp1// POST response - lightweight2new DeliveryConfirmationDto3{4 HasSignature = confirmation.SignatureImage is not null,5 // ... other fields6};78// GET response - full data9new DeliveryConfirmationDetailDto10{11 SignatureImage = confirmation.SignatureImage,12 // ... other fields13};
This asymmetry is intentional. The POST caller already has the signature data. The GET caller needs to retrieve it. The service layer performs the DTO mapping, keeping this logic out of the endpoint handlers.
The 409 Conflict Pattern in Depth
The 409 status code deserves careful attention because it serves a specific purpose that other 4xx codes do not cover.
When to Use 409
Use 409 when the request is syntactically valid and the data passes validation, but the operation cannot proceed because it conflicts with the current state of the resource:
csharp1// The request is valid JSON with all required fields2// The tracking number exists3// The parcel status allows delivery4// BUT a confirmation already exists5return Results.Conflict(new6{7 message = "Delivery confirmation already exists for this parcel.",8 trackingNumber9});
409 vs Other Status Codes
| Code | Meaning | Example |
|---|---|---|
| 400 | Request format is invalid | Missing required field, invalid JSON |
| 404 | Resource not found | Tracking number does not exist |
| 409 | Conflicts with current state | Confirmation already exists |
| 422 | Data fails business validation | DeliveredAt is in the future |
The distinction matters for API consumers. A 409 tells the client: "Your request was fine, but the operation was already done." The client can check the existing confirmation with a GET instead of retrying.
Idempotency Considerations
Some APIs make creation endpoints idempotent: if you POST the same data twice, the second request returns the existing resource instead of 409. This simplifies retry logic for unreliable networks.
For delivery confirmation, 409 is the better choice. Delivery confirmation is a business event that should happen exactly once. If a client receives a 409, it should fetch the existing confirmation and verify the data matches expectations rather than silently accepting a duplicate.
csharp1// Client-side handling of 4092if (response.StatusCode == HttpStatusCode.Conflict)3{4 // Fetch existing confirmation instead of retrying5 var existing = await httpClient.GetAsync(6 $"/api/parcels/{trackingNumber}/delivery-confirmation");7 // Verify existing data matches expectations...8}
Error Response Consistency
All error responses from the endpoint follow a consistent shape:
csharp1// 404 - Parcel not found2Results.NotFound(new { message = "Parcel 'PKG-...' not found." })34// 400 - Invalid status5Results.BadRequest(new { message = "Cannot confirm delivery...", detail = "..." })67// 409 - Already confirmed8Results.Conflict(new { message = "Delivery confirmation already exists...", trackingNumber })
Each error response includes a message field with a human-readable explanation. Some include additional context like detail or trackingNumber. Consistent error shapes make it easier for clients to parse and display error information.
In a production API, you might define a standard error DTO:
csharp1public class ApiError2{3 public required string Message { get; init; }4 public string? Detail { get; init; }5 public string? TrackingNumber { get; init; }6}
This keeps all error responses predictable and parseable.
Testing the GET Endpoint
Test the happy path and both 404 scenarios:
http1### Happy path - confirmation exists2GET /api/parcels/PKG-20250215-A1B2C3/delivery-confirmation34### Expected 200 OK5{6 "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",7 "trackingNumber": "PKG-20250215-A1B2C3",8 "receivedBy": "Jane Smith",9 "deliveryLocation": "Front door",10 "signatureImage": "iVBORw0KGgoAAAANSUhEUg...",11 "deliveredAt": "2025-02-15T14:30:00Z",12 "estimatedDeliveryDate": "2025-02-16T00:00:00Z",13 "isOnTime": true,14 "createdAt": "2025-02-15T14:35:12Z"15}
http1### Parcel not found2GET /api/parcels/PKG-NONEXISTENT/delivery-confirmation34### Expected 4045{ "message": "Parcel 'PKG-NONEXISTENT' not found." }
http1### Parcel exists but not delivered yet2GET /api/parcels/PKG-20250215-INTRANSIT/delivery-confirmation34### Expected 4045{ "message": "No delivery confirmation exists for parcel 'PKG-20250215-INTRANSIT'." }
Summary: Complete Service and Endpoint Pair
The delivery confirmation feature consists of a service layer and two endpoint handlers:
Service Layer
IDeliveryConfirmationServicedefines two operations:CreateAsyncandGetDetailByParcelIdAsync- Service encapsulates business logic: validation, multi-entity coordination, on-time calculation, DTO mapping
- Service throws exceptions that endpoints translate to HTTP status codes
Endpoint Handlers
| Method | Route | Purpose | Key Status Codes |
|---|---|---|---|
| POST | /api/parcels/{trackingNumber}/delivery-confirmation | Confirm delivery with proof | 201, 400, 404, 409 |
| GET | /api/parcels/{trackingNumber}/delivery-confirmation | Retrieve confirmation + on-time | 200, 404 |
Together, they implement a complete workflow:
- The POST endpoint validates input, delegates to service, and returns a lightweight response
- The GET endpoint delegates to service for full confirmation with calculated on-time metric
- Endpoints handle HTTP concerns: tracking number lookup, exception translation, status codes
Key Takeaways
- The service layer handles detailed retrieval with
GetDetailByParcelIdAsync, including on-time calculation - Service uses
.Include()to eager-load the one-to-one DeliveryConfirmation relationship - On-time calculation in the service compares
.Datevalues, stripping time components - Base64 signature data is stored as a plain string and returned in the detailed DTO
- Two separate DTOs keep responses appropriate: lightweight for POST, detailed for GET
- Service returns
nullfor missing confirmations, allowing endpoints to handle 404 responses - Endpoints handle HTTP concerns while services handle business logic and calculations
- 409 Conflict is the correct status for duplicate confirmation attempts
- Two distinct 404 cases (missing parcel vs. missing confirmation) use different messages