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 GET endpoint needs a more detailed DTO that includes the full signature and on-time calculation:

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

csharp
1public interface IDeliveryConfirmationService
2{
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:

csharp
1public async Task<DeliveryConfirmationDetailDto?> GetDetailByParcelIdAsync(Guid parcelId)
2{
3 var parcel = await _db.Parcels
4 .Include(p => p.DeliveryConfirmation)
5 .FirstOrDefaultAsync(p => p.Id == parcelId);
6
7 if (parcel?.DeliveryConfirmation is null)
8 {
9 return null;
10 }
11
12 var confirmation = parcel.DeliveryConfirmation;
13
14 var isOnTime = parcel.EstimatedDeliveryDate.HasValue &&
15 confirmation.DeliveredAt.Date <= parcel.EstimatedDeliveryDate.Value.Date;
16
17 return new DeliveryConfirmationDetailDto
18 {
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.CreatedAt
28 };
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:

csharp
1[HttpGet("api/parcels/{trackingNumber}/delivery-confirmation")]
2public async Task<IActionResult> GetDeliveryConfirmation(string trackingNumber)
3{
4 var parcel = await _db.Parcels
5 .FirstOrDefaultAsync(p => p.TrackingNumber == trackingNumber);
6
7 if (parcel is null)
8 {
9 return NotFound(new
10 {
11 message = $"Parcel '{trackingNumber}' not found."
12 });
13 }
14
15 var confirmation = await _deliveryConfirmationService.GetDetailByParcelIdAsync(parcel.Id);
16
17 if (confirmation is null)
18 {
19 return NotFound(new
20 {
21 message = $"No delivery confirmation exists for parcel '{trackingNumber}'."
22 });
23 }
24
25 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:

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

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

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

csharp
1// POST response - lightweight
2new DeliveryConfirmationDto
3{
4 HasSignature = confirmation.SignatureImage is not null,
5 // ... other fields
6};
7
8// GET response - full data
9new DeliveryConfirmationDetailDto
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 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:

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 Service and Endpoint Pair

The delivery confirmation feature consists of a service layer and two endpoint handlers:

Service Layer

  • IDeliveryConfirmationService defines two operations: CreateAsync and GetDetailByParcelIdAsync
  • 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

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 input, delegates to service, and returns a lightweight response
  2. The GET endpoint delegates to service for full confirmation with calculated on-time metric
  3. 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 .Date values, 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 null for 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