16 minlesson

Proof of Delivery and Retrieval

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:

StatusCondition
200 OKConfirmation found, returns full data
404 Not FoundParcel does not exist, or parcel exists but has no confirmation

The response DTO for the GET endpoint includes more fields than the POST response:

csharp
1public class DeliveryConfirmationDetailResponse
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 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:

csharp
1app.MapGet("/api/parcels/{trackingNumber}/delivery-confirmation",
2 async (string trackingNumber, ParcelTrackingDbContext db) =>
3{
4 var parcel = await db.Parcels
5 .Include(p => p.DeliveryConfirmation)
6 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);
7
8 if (parcel is null)
9 {
10 return Results.NotFound(new
11 {
12 message = $"Parcel '{trackingNumber}' not found."
13 });
14 }
15
16 if (parcel.DeliveryConfirmation is null)
17 {
18 return Results.NotFound(new
19 {
20 message = $"No delivery confirmation exists for parcel '{trackingNumber}'."
21 });
22 }
23
24 var confirmation = parcel.DeliveryConfirmation;
25
26 var isOnTime = parcel.EstimatedDeliveryDate.HasValue &&
27 confirmation.DeliveredAt.Date <= parcel.EstimatedDeliveryDate.Value.Date;
28
29 var response = new DeliveryConfirmationDetailResponse
30 {
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.CreatedAt
40 };
41
42 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:

  1. Parcel does not exist: The tracking number is wrong or the parcel was never registered
  2. 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.

csharp
1// Scenario 1: Bad tracking number
2// Response: 404 { "message": "Parcel 'PKG-INVALID' not found." }
3
4// Scenario 2: Parcel exists, not yet delivered
5// 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:

csharp
1var isOnTime = parcel.EstimatedDeliveryDate.HasValue &&
2 confirmation.DeliveredAt.Date <= parcel.EstimatedDeliveryDate.Value.Date;

This line handles three scenarios:

ScenarioEstimatedDeliveryDateDeliveredAtIsOnTime
On time2025-02-152025-02-15 14:30true
Late2025-02-152025-02-16 09:00false
No estimatenull2025-02-15 14:30false

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":

csharp
1bool? isOnTime = parcel.EstimatedDeliveryDate.HasValue
2 ? confirmation.DeliveredAt.Date <= parcel.EstimatedDeliveryDate.Value.Date
3 : 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:

json
1{
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:

csharp
1public class DeliveryConfirmation
2{
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; }
10
11 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 SizeBase64 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:

csharp
1// POST response - lightweight
2new DeliveryConfirmationResponse
3{
4 HasSignature = confirmation.SignatureImage is not null,
5 // ... other fields
6};
7
8// GET response - full data
9new DeliveryConfirmationDetailResponse
10{
11 SignatureImage = confirmation.SignatureImage,
12 // ... other fields
13};

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:

csharp
1// The request is valid JSON with all required fields
2// The tracking number exists
3// The parcel status allows delivery
4// BUT a confirmation already exists
5return Results.Conflict(new
6{
7 message = "Delivery confirmation already exists for this parcel.",
8 trackingNumber
9});

409 vs Other Status Codes

CodeMeaningExample
400Request format is invalidMissing required field, invalid JSON
404Resource not foundTracking number does not exist
409Conflicts with current stateConfirmation already exists
422Data fails business validationDeliveredAt 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.

csharp
1// Client-side handling of 409
2if (response.StatusCode == HttpStatusCode.Conflict)
3{
4 // Fetch existing confirmation instead of retrying
5 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:

csharp
1// 404 - Parcel not found
2Results.NotFound(new { message = "Parcel 'PKG-...' not found." })
3
4// 400 - Invalid status
5Results.BadRequest(new { message = "Cannot confirm delivery...", detail = "..." })
6
7// 409 - Already confirmed
8Results.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:

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

http
1### Happy path - confirmation exists
2GET /api/parcels/PKG-20250215-A1B2C3/delivery-confirmation
3
4### Expected 200 OK
5{
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}
http
1### Parcel not found
2GET /api/parcels/PKG-NONEXISTENT/delivery-confirmation
3
4### Expected 404
5{ "message": "Parcel 'PKG-NONEXISTENT' not found." }
http
1### Parcel exists but not delivered yet
2GET /api/parcels/PKG-20250215-INTRANSIT/delivery-confirmation
3
4### Expected 404
5{ "message": "No delivery confirmation exists for parcel 'PKG-20250215-INTRANSIT'." }

Summary: Complete Endpoint Pair

The delivery confirmation feature consists of two endpoints:

MethodRoutePurposeKey Status Codes
POST/api/parcels/{trackingNumber}/delivery-confirmationConfirm delivery with proof201, 400, 404, 409
GET/api/parcels/{trackingNumber}/delivery-confirmationRetrieve confirmation + on-time200, 404

Together, they implement a complete workflow:

  1. The POST endpoint validates, creates three entities in one transaction, and returns a lightweight response
  2. The GET endpoint retrieves the full confirmation with the calculated on-time metric
  3. 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 .Date values, 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