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 response DTO for the GET endpoint includes more fields than the POST response:
csharp1public class DeliveryConfirmationDetailResponse2{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.
Building the GET Endpoint
The endpoint loads the parcel and its delivery confirmation in a single query:
csharp1app.MapGet("/api/parcels/{trackingNumber}/delivery-confirmation",2 async (string trackingNumber, ParcelTrackingDbContext db) =>3{4 var parcel = await db.Parcels5 .Include(p => p.DeliveryConfirmation)6 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);78 if (parcel is null)9 {10 return Results.NotFound(new11 {12 message = $"Parcel '{trackingNumber}' not found."13 });14 }1516 if (parcel.DeliveryConfirmation is null)17 {18 return Results.NotFound(new19 {20 message = $"No delivery confirmation exists for parcel '{trackingNumber}'."21 });22 }2324 var confirmation = parcel.DeliveryConfirmation;2526 var isOnTime = parcel.EstimatedDeliveryDate.HasValue &&27 confirmation.DeliveredAt.Date <= parcel.EstimatedDeliveryDate.Value.Date;2829 var response = new DeliveryConfirmationDetailResponse30 {31 Id = confirmation.Id,32 TrackingNumber = trackingNumber,33 ReceivedBy = confirmation.ReceivedBy,34 DeliveryLocation = confirmation.DeliveryLocation,35 SignatureImage = confirmation.SignatureImage,36 DeliveredAt = confirmation.DeliveredAt,37 EstimatedDeliveryDate = parcel.EstimatedDeliveryDate,38 IsOnTime = isOnTime,39 CreatedAt = confirmation.CreatedAt40 };4142 return Results.Ok(response);43});
The .Include(p => p.DeliveryConfirmation) eager-loads the one-to-one relationship. Without it, parcel.DeliveryConfirmation would always be null because EF Core uses lazy loading only when explicitly configured.
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
The on-time calculation compares the actual delivery date against the estimated delivery date. Both values are available on the loaded entities:
csharp1var isOnTime = parcel.EstimatedDeliveryDate.HasValue &&2 confirmation.DeliveredAt.Date <= parcel.EstimatedDeliveryDate.Value.Date;
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 GET endpoint returns the base64 string in the JSON response. The POST endpoint returns only HasSignature: true/false to keep the creation response lightweight:
csharp1// POST response - lightweight2new DeliveryConfirmationResponse3{4 HasSignature = confirmation.SignatureImage is not null,5 // ... other fields6};78// GET response - full data9new DeliveryConfirmationDetailResponse10{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 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 Endpoint Pair
The delivery confirmation feature consists of two endpoints:
| 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, creates three entities in one transaction, and returns a lightweight response
- The GET endpoint retrieves the full confirmation with the calculated on-time metric
- Error handling uses semantically correct HTTP status codes with descriptive messages
Key Takeaways
- The GET endpoint uses
.Include()to eager-load the one-to-one DeliveryConfirmation relationship - On-time calculation compares
.Datevalues, stripping time components - Base64 signature data is stored as a plain string and returned only in the GET response
- 409 Conflict is the correct status for duplicate confirmation attempts
- Consistent error response shapes simplify client-side error handling
- Two distinct 404 cases (missing parcel vs. missing confirmation) use different messages