AfterShip provides a comprehensive package tracking API that aggregates data from 900+ carriers worldwide, offering real-time visibility into shipment status, delivery estimates, and exception handling. For Java applications, integrating with AfterShip enables robust tracking capabilities for e-commerce platforms, logistics systems, and customer service applications. This guide covers practical patterns for implementing end-to-end tracking solutions.
Understanding AfterShip API Architecture
AfterShip operates on a RESTful architecture with:
- Multi-carrier tracking with unified response format
- Webhook-driven notifications for status updates
- Automated detection of couriers and tracking numbers
- Predictive delivery dates and exception alerts
- Custom tracking pages for customer-facing status
Core Integration Patterns
1. Project Setup and Dependencies
Configure AfterShip Java client and HTTP dependencies.
Maven Configuration:
<dependencies> <!-- HTTP Client --> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.5.14</version> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <!-- Reactive Streams --> <dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-core</artifactId> <version>3.5.10</version> </dependency> </dependencies>
2. Configuration and Client Setup
Configure AfterShip API credentials and initialize the client.
AfterShip Configuration:
@Configuration @ConfigurationProperties(prefix = "aftership") @Data public class AfterShipConfig { private String apiKey; private String apiBaseUrl = "https://api.aftership.com"; private String apiVersion = "2024-10"; private String webhookSecret; private int timeout = 30000; @Bean public WebClient afterShipWebClient() { return WebClient.builder() .baseUrl(apiBaseUrl + "/" + apiVersion) .defaultHeader("Content-Type", "application/json") .defaultHeader("as-api-key", apiKey) .build(); } } @Component public class AfterShipClient { private final WebClient webClient; private final ObjectMapper objectMapper; public AfterShipClient(WebClient afterShipWebClient, ObjectMapper objectMapper) { this.webClient = afterShipWebClient; this.objectMapper = objectMapper; } public <T> Mono<T> executeGet(String path, Map<String, Object> params, Class<T> responseType) { return webClient.get() .uri(uriBuilder -> { UriBuilder builder = uriBuilder.path(path); if (params != null) { params.forEach((key, value) -> builder.queryParam(key, value.toString())); } return builder.build(); }) .retrieve() .bodyToMono(responseType); } public <T> Mono<T> executePost(String path, Object body, Class<T> responseType) { return webClient.post() .uri(path) .bodyValue(body) .retrieve() .bodyToMono(responseType); } public <T> Mono<T> executePut(String path, Object body, Class<T> responseType) { return webClient.put() .uri(path) .bodyValue(body) .retrieve() .bodyToMono(responseType); } public Mono<Void> executeDelete(String path) { return webClient.delete() .uri(path) .retrieve() .bodyToMono(Void.class); } } 3. Domain Models for Tracking Entities
Create comprehensive Java models for tracking data.
Core Domain Models:
@Data public class Tracking { private String id; private String trackingNumber; private String slug; // Carrier code private String trackingUrl; private String customerName; private String orderId; private String orderNumber; private String orderDate; private String destinationCountry; private String shippingDate; private String originCountry; private Map<String, Object> customFields; // Status information private String active; private String tag; // Current status tag private String expectedDelivery; private String shipmentType; private String shipmentWeight; private String shipmentWeightUnit; // Timeline private List<Checkpoint> checkpoints; private Date createdAt; private Date updatedAt; public boolean isDelivered() { return "Delivered".equals(tag); } public boolean hasException() { return "Exception".equals(tag) || "InfoReceived".equals(tag); } } @Data public class Checkpoint { private Date createdAt; private String checkpointTime; private String city; private String country; private String countryCode; private String state; private String zip; private String message; private String tag; private String substag; private String substagMessage; public boolean isDeliveryEvent() { return "Delivered".equals(tag) || "Pickup".equals(tag); } public boolean isExceptionEvent() { return "Exception".equals(tag) || "InfoReceived".equals(tag); } } @Data public class Courier { private String slug; private String name; private String phone; private String url; private String requiredFields; private Boolean trackable; private Boolean recommend; private List<String> supportedCountryCodes; public boolean supportsCountry(String countryCode) { return supportedCountryCodes != null && supportedCountryCodes.contains(countryCode.toUpperCase()); } } @Data public class TrackingRequest { private String trackingNumber; private String slug; // Optional - auto-detect if not provided private String customerName; private String orderId; private String orderNumber; private String orderDate; private String destinationCountry; private String shippingDate; private String originCountry; private String expectedDelivery; private Map<String, Object> customFields; public boolean hasCarrierSpecified() { return slug != null && !slug.trim().isEmpty(); } } @Data public class TrackingResponse { private Meta meta; private TrackingData data; @Data public static class Meta { private Integer code; private String message; private String type; } @Data public static class TrackingData { private Tracking tracking; } } 4. Tracking Management Service
Core service for managing package tracking.
Tracking Service:
@Service @Slf4j public class TrackingService { private final AfterShipClient client; private final CourierService courierService; public TrackingService(AfterShipClient client, CourierService courierService) { this.client = client; this.courierService = courierService; } public Mono<Tracking> createTracking(TrackingRequest request) { return validateTrackingRequest(request) .flatMap(validRequest -> { Map<String, Object> body = createTrackingBody(validRequest); return client.executePost("/trackings", body, TrackingResponse.class); }) .map(response -> { if (response.getMeta().getCode() != 201) { throw new AfterShipException("Failed to create tracking: " + response.getMeta().getMessage()); } return response.getData().getTracking(); }) .doOnSuccess(tracking -> log.info("Tracking created: {} for order {}", tracking.getTrackingNumber(), tracking.getOrderId())) .doOnError(error -> log.error("Failed to create tracking for order: {}", request.getOrderId(), error)); } public Mono<Tracking> getTracking(String trackingNumber, String slug) { String path = String.format("/trackings/%s/%s", slug, trackingNumber); return client.executeGet(path, null, TrackingResponse.class) .map(response -> { if (response.getMeta().getCode() != 200) { throw new AfterShipException("Tracking not found: " + response.getMeta().getMessage()); } return response.getData().getTracking(); }) .doOnError(error -> log.warn("Failed to get tracking: {} - {}", trackingNumber, slug, error)); } public Mono<Tracking> getTrackingById(String trackingId) { String path = String.format("/trackings/%s", trackingId); return client.executeGet(path, null, TrackingResponse.class) .map(response -> response.getData().getTracking()); } public Flux<Tracking> getTrackingsByOrder(String orderId) { Map<String, Object> params = Map.of( "order_id", orderId, "limit", 50 ); return client.executeGet("/trackings", params, TrackingListResponse.class) .flatMapMany(response -> Flux.fromIterable(response.getData().getTrackings())); } public Flux<Tracking> getActiveTrackings() { Map<String, Object> params = Map.of( "tag", "Active", "limit", 100 ); return client.executeGet("/trackings", params, TrackingListResponse.class) .flatMapMany(response -> Flux.fromIterable(response.getData().getTrackings())); } public Mono<Tracking> updateTracking(String trackingId, TrackingRequest updates) { String path = String.format("/trackings/%s", trackingId); Map<String, Object> body = createTrackingBody(updates); return client.executePut(path, body, TrackingResponse.class) .map(response -> response.getData().getTracking()); } public Mono<Void> deleteTracking(String trackingId) { String path = String.format("/trackings/%s", trackingId); return client.executeDelete(path); } public Mono<Tracking> retrack(String trackingId) { String path = String.format("/trackings/%s/retrack", trackingId); return client.executePost(path, Map.of(), TrackingResponse.class) .map(response -> response.getData().getTracking()); } private Mono<TrackingRequest> validateTrackingRequest(TrackingRequest request) { return Mono.fromCallable(() -> { if (request.getTrackingNumber() == null || request.getTrackingNumber().trim().isEmpty()) { throw new AfterShipException("Tracking number is required"); } // Auto-detect carrier if not specified if (!request.hasCarrierSpecified()) { return autoDetectCarrier(request); } return request; }); } private TrackingRequest autoDetectCarrier(TrackingRequest request) { // In a real implementation, you might call AfterShip's courier detection // For now, we'll return the request as-is and let AfterShip handle detection log.info("Auto-detecting carrier for tracking: {}", request.getTrackingNumber()); return request; } private Map<String, Object> createTrackingBody(TrackingRequest request) { Map<String, Object> body = new HashMap<>(); Map<String, Object> tracking = new HashMap<>(); tracking.put("tracking_number", request.getTrackingNumber()); if (request.hasCarrierSpecified()) { tracking.put("slug", request.getSlug()); } // Optional fields if (request.getCustomerName() != null) { tracking.put("customer_name", request.getCustomerName()); } if (request.getOrderId() != null) { tracking.put("order_id", request.getOrderId()); } if (request.getOrderNumber() != null) { tracking.put("order_number", request.getOrderNumber()); } if (request.getDestinationCountry() != null) { tracking.put("destination_country_iso3", request.getDestinationCountry()); } body.put("tracking", tracking); return body; } } 5. Courier Detection and Management
Handle carrier detection and courier information.
Courier Service:
@Service public class CourierService { private final AfterShipClient client; public CourierService(AfterShipClient client) { this.client = client; } public Flux<Courier> getAllCouriers() { return client.executeGet("/couriers", null, CourierListResponse.class) .flatMapMany(response -> Flux.fromIterable(response.getData().getCouriers())); } public Flux<Courier> detectCourier(String trackingNumber, String destinationCountry) { Map<String, Object> params = new HashMap<>(); params.put("tracking_number", trackingNumber); if (destinationCountry != null) { params.put("destination_country", destinationCountry); } return client.executeGet("/couriers/detect", params, CourierDetectionResponse.class) .flatMapMany(response -> Flux.fromIterable(response.getData().getCouriers())); } public Mono<Courier> getCourierBySlug(String slug) { return getAllCouriers() .filter(courier -> slug.equals(courier.getSlug())) .next() .switchIfEmpty(Mono.error(new AfterShipException("Courier not found: " + slug))); } public Flux<Courier> getCouriersByCountry(String countryCode) { return getAllCouriers() .filter(courier -> courier.supportsCountry(countryCode)); } public Mono<String> detectCourierSlug(String trackingNumber) { return detectCourier(trackingNumber, null) .next() .map(Courier::getSlug) .switchIfEmpty(Mono.just("unknown")); } } 6. Webhook Handling for Real-time Updates
Process AfterShip webhooks for tracking updates.
Webhook Controller:
@RestController @RequestMapping("/webhooks/aftership") @Slf4j public class AfterShipWebhookController { private final TrackingService trackingService; private final NotificationService notificationService; private final OrderService orderService; @Value("${aftership.webhook.secret}") private String webhookSecret; public AfterShipWebhookController(TrackingService trackingService, NotificationService notificationService, OrderService orderService) { this.trackingService = trackingService; this.notificationService = notificationService; this.orderService = orderService; } @PostMapping("/tracking") public ResponseEntity<String> handleTrackingWebhook( @RequestBody String payload, @RequestHeader(value = "aftership-hmac-sha256", required = false) String signature) { if (!verifyWebhookSignature(payload, signature)) { log.warn("Invalid webhook signature received"); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } try { AfterShipWebhookEvent event = parseWebhookPayload(payload); processWebhookEvent(event).subscribe(); return ResponseEntity.accepted().build(); } catch (Exception e) { log.error("Failed to process AfterShip webhook", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } private Mono<Void> processWebhookEvent(AfterShipWebhookEvent event) { return Mono.fromRunnable(() -> { String eventType = event.getEvent(); Tracking tracking = event.getData().getTracking(); String orderId = tracking.getOrderId(); log.info("Processing webhook event: {} for order: {}", eventType, orderId); switch (eventType) { case "tracking_created": handleTrackingCreated(tracking); break; case "tracking_updated": handleTrackingUpdated(tracking); break; case "tracking_delivered": handleTrackingDelivered(tracking); break; case "tracking_fulfilled": handleTrackingFulfilled(tracking); break; case "tracking_attempted": handleTrackingAttempted(tracking); break; case "tracking_undelivered": handleTrackingUndelivered(tracking); break; case "tracking_expired": handleTrackingExpired(tracking); break; } }); } private void handleTrackingUpdated(Tracking tracking) { String orderId = tracking.getOrderId(); String status = tracking.getTag(); // Update order status in local system orderService.updateOrderTrackingStatus(orderId, tracking.getTrackingNumber(), status); // Send notification to customer if (shouldNotifyCustomer(tracking)) { notificationService.sendTrackingUpdate( orderId, tracking.getTrackingNumber(), status, tracking.getCheckpoints() ); } // Check for exceptions if (tracking.hasException()) { handleTrackingException(tracking); } } private void handleTrackingDelivered(Tracking tracking) { String orderId = tracking.getOrderId(); log.info("Order delivered: {}", orderId); orderService.markOrderDelivered(orderId); notificationService.sendDeliveryConfirmation(orderId, tracking.getTrackingNumber()); // Trigger post-delivery actions triggerPostDeliveryActions(orderId); } private void handleTrackingException(Tracking tracking) { String orderId = tracking.getOrderId(); Checkpoint latestCheckpoint = getLatestCheckpoint(tracking); log.warn("Tracking exception for order {}: {}", orderId, latestCheckpoint != null ? latestCheckpoint.getMessage() : "Unknown exception"); orderService.flagOrderException(orderId, latestCheckpoint); notificationService.sendExceptionAlert(orderId, tracking, latestCheckpoint); } private boolean shouldNotifyCustomer(Tracking tracking) { // Only notify for significant status changes String currentTag = tracking.getTag(); return List.of("InTransit", "OutForDelivery", "AvailableForPickup", "Exception") .contains(currentTag); } private Checkpoint getLatestCheckpoint(Tracking tracking) { if (tracking.getCheckpoints() == null || tracking.getCheckpoints().isEmpty()) { return null; } return tracking.getCheckpoints().get(tracking.getCheckpoints().size() - 1); } private void triggerPostDeliveryActions(String orderId) { // Trigger review requests, feedback surveys, etc. log.info("Triggering post-delivery actions for order: {}", orderId); } private boolean verifyWebhookSignature(String payload, String signature) { if (webhookSecret == null || webhookSecret.isEmpty()) { log.warn("Webhook secret not configured, skipping signature verification"); return true; } try { Mac sha256Hmac = Mac.getInstance("HmacSHA256"); SecretKeySpec secretKey = new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256"); sha256Hmac.init(secretKey); String computedSignature = Hex.encodeHexString( sha256Hmac.doFinal(payload.getBytes())); return computedSignature.equals(signature); } catch (Exception e) { log.error("Webhook signature verification failed", e); return false; } } } 7. Notification and Alert Service
Handle customer notifications and exception alerts.
Notification Service:
@Service public class NotificationService { public Mono<Void> sendTrackingUpdate(String orderId, String trackingNumber, String status, List<Checkpoint> checkpoints) { return Mono.fromRunnable(() -> { // Implement email/SMS/push notification logic log.info("Sending tracking update for order {}: {} - {}", orderId, trackingNumber, status); // Example: Send email notification String subject = String.format("Order %s Update", orderId); String message = buildTrackingMessage(orderId, trackingNumber, status, checkpoints); // emailService.send(order.getCustomerEmail(), subject, message); }); } public Mono<Void> sendDeliveryConfirmation(String orderId, String trackingNumber) { return Mono.fromRunnable(() -> { log.info("Sending delivery confirmation for order {}: {}", orderId, trackingNumber); String subject = String.format("Order %s Delivered", orderId); String message = String.format( "Your order %s has been delivered. Tracking: %s", orderId, trackingNumber); // emailService.send(order.getCustomerEmail(), subject, message); }); } public Mono<Void> sendExceptionAlert(String orderId, Tracking tracking, Checkpoint exceptionCheckpoint) { return Mono.fromRunnable(() -> { log.warn("Sending exception alert for order {}: {}", orderId, exceptionCheckpoint.getMessage()); // Notify customer service team String alertMessage = String.format( "Tracking exception for order %s: %s - %s", orderId, tracking.getTrackingNumber(), exceptionCheckpoint.getMessage()); // alertService.notifyCustomerService(alertMessage); }); } private String buildTrackingMessage(String orderId, String trackingNumber, String status, List<Checkpoint> checkpoints) { StringBuilder message = new StringBuilder(); message.append(String.format("Order %s Tracking Update\n\n", orderId)); message.append(String.format("Tracking Number: %s\n", trackingNumber)); message.append(String.format("Current Status: %s\n\n", status)); if (checkpoints != null && !checkpoints.isEmpty()) { message.append("Recent Updates:\n"); checkpoints.stream() .sorted((c1, c2) -> c2.getCreatedAt().compareTo(c1.getCreatedAt())) .limit(3) .forEach(checkpoint -> { message.append(String.format("- %s: %s\n", checkpoint.getCheckpointTime(), checkpoint.getMessage())); }); } return message.toString(); } } 8. Batch Tracking Operations
Handle high-volume tracking operations.
Batch Tracking Service:
@Service public class BatchTrackingService { private final TrackingService trackingService; private final CourierService courierService; public BatchTrackingService(TrackingService trackingService, CourierService courierService) { this.trackingService = trackingService; this.courierService = courierService; } @Async public CompletableFuture<List<BatchResult>> createBatchTrackings( List<TrackingRequest> requests) { List<CompletableFuture<BatchResult>> futures = requests.stream() .map(request -> CompletableFuture.supplyAsync(() -> processSingleTracking(request))) .collect(Collectors.toList()); return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .thenApply(v -> futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList())); } public Flux<Tracking> syncActiveTrackings() { return trackingService.getActiveTrackings() .flatMap(tracking -> trackingService.retrack(tracking.getId())) .doOnNext(tracking -> log.info("Synced tracking: {}", tracking.getTrackingNumber())) .onErrorContinue((error, tracking) -> log.warn("Failed to sync tracking: {}", error.getMessage())); } private BatchResult processSingleTracking(TrackingRequest request) { try { Tracking tracking = trackingService.createTracking(request).block(); return BatchResult.success(request.getOrderId(), tracking); } catch (Exception e) { log.error("Batch tracking failed for order: {}", request.getOrderId(), e); return BatchResult.failed(request.getOrderId(), e.getMessage()); } } @Data public static class BatchResult { private String orderId; private boolean success; private Tracking tracking; private String errorMessage; public static BatchResult success(String orderId, Tracking tracking) { BatchResult result = new BatchResult(); result.setOrderId(orderId); result.setSuccess(true); result.setTracking(tracking); return result; } public static BatchResult failed(String orderId, String errorMessage) { BatchResult result = new BatchResult(); result.setOrderId(orderId); result.setSuccess(false); result.setErrorMessage(errorMessage); return result; } } } Best Practices for AfterShip Integration
- Webhook Security: Always verify webhook signatures to prevent spoofing
- Error Handling: Implement comprehensive error handling for API failures
- Rate Limiting: Respect AfterShip API rate limits with proper backoff strategies
- Data Validation: Validate tracking numbers and carrier codes before creating trackings
- Caching: Cache frequently accessed tracking data and courier information
- Monitoring: Monitor webhook delivery and tracking sync status
- Retry Logic: Implement retry mechanisms for failed tracking updates
Conclusion: Enterprise-Grade Package Visibility
AfterShip provides Java developers with a powerful platform for unifying package tracking across hundreds of carriers worldwide. By implementing comprehensive tracking management, real-time webhook processing, and proactive notification systems, businesses can deliver exceptional post-purchase experiences while maintaining operational visibility.
This integration demonstrates that modern package tracking doesn't require complex carrier-specific implementations—AfterShip's unified API combined with Java's reactive capabilities creates a robust foundation for scalable logistics visibility, enabling businesses to provide transparent shipping experiences while reducing customer service overhead.